mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
1ad2e92142
The bulk of what SonarQube still had open on the reliability side, read site by
site rather than swept. Two clusters are worth calling out because they are
defects, not lint.
**Four regexes could be made to hang the process.** The worst is
`/.+@.+\..+/` in the forgot-password path: three unbounded `.+` that all
overlap, unanchored, on an endpoint that needs no login and whose schema is a
bare z.string() under a 100kb body cap. A thousand '@' characters had not
finished after 100 seconds — one request pins the event loop, and Node has one.
EMAIL_REGEX in auth.helpers is the same shape on register and profile update
(310ms for 8kb). The two tag strippers over an uploaded KML description and over
Commons metadata took 0.52s on a 16k run of '<'. Each rewrite was proven
equivalent by brute force against the original — millions of strings for the
coordinate patterns, 671,876 for the tag stripper — because a subtly different
regex on a parsing path is worse than the finding.
`<[^>]+>` could not be fixed in the pattern: `[^>]` matches `<` too, so the only
unambiguous form changes what happens to malformed markup. It is now a scan in
common/stripHtmlTags.ts, quirks included — `<>` is left alone, `<a<b>` is one
tag — with the equivalence pinned by a test that compares against the old regex
over every string up to length six.
**And roughly 500 places could only be reached with a mouse.** Clickable divs
became real buttons where the markup allowed (52 of them), took
role + tabIndex + Enter/Space where they had to stay divs, and modal backdrops
took role="presentation" — the honest role, since a backdrop is a mouse shortcut
for a Cancel control that is already in the tab order, and making it a button
plants a nameless full-screen tab stop around its own contents.
Alongside those: `.sort()` calls that now say the order they always had, `{n &&
<X/>}` guards that rendered a literal "0" for a zero-byte file and for a place at
latitude 0, an optimistic budget write that stored a boolean where the contract
and both transports use 0/1, and the Number statics.
sonar-project.properties documents the six that were deliberately left: two wiki
patterns with no overlapping quantifier pair whose "fix" would stop parsing
`[see [1]](url)`, four tar header fields bounded at 12-155 bytes by the format,
two djb2 hashes where the 32-bit wrap is the algorithm, two `| 0` that are also
coercing undefined to 0 on a path where the API sends no user_id, and the MCP
session maps Sonar reads as empty because they are filled from another module.
582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
import {
|
||
ArrowRightLeft,
|
||
CalendarDays,
|
||
Clock,
|
||
Database,
|
||
FileText,
|
||
Github,
|
||
Globe,
|
||
Key,
|
||
ListChecks,
|
||
Map,
|
||
Puzzle,
|
||
Shield,
|
||
Upload,
|
||
Users,
|
||
Wallet,
|
||
} from 'lucide-react';
|
||
import React, { useEffect, useState } from 'react';
|
||
import { useTranslation } from '../../i18n';
|
||
|
||
interface DemoTexts {
|
||
titleBefore: string;
|
||
titleAfter: string;
|
||
title: string;
|
||
description: string;
|
||
resetIn: string;
|
||
minutes: string;
|
||
uploadNote: string;
|
||
fullVersionTitle: string;
|
||
features: string[];
|
||
addonsTitle: string;
|
||
addons: [string, string][];
|
||
whatIs: string;
|
||
whatIsDesc: string;
|
||
selfHost: string;
|
||
selfHostLink: string;
|
||
close: string;
|
||
}
|
||
|
||
const texts: Record<string, DemoTexts> = {
|
||
de: {
|
||
titleBefore: 'Willkommen bei ',
|
||
titleAfter: '',
|
||
title: 'Willkommen zur TREK Demo',
|
||
description:
|
||
'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||
resetIn: 'Naechster Reset in',
|
||
minutes: 'Minuten',
|
||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||
fullVersionTitle: 'In der Vollversion zusaetzlich:',
|
||
features: [
|
||
'Datei-Uploads (Fotos, Dokumente, Cover)',
|
||
'API-Schluessel (Google Maps, Wetter)',
|
||
'Benutzer- & Rechteverwaltung',
|
||
'Automatische Backups',
|
||
'Addon-Verwaltung (aktivieren/deaktivieren)',
|
||
'OIDC / SSO Single Sign-On',
|
||
],
|
||
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
|
||
addons: [
|
||
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
|
||
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
|
||
['Packliste', 'Checklisten pro Reise'],
|
||
['Budget', 'Kostenplanung mit Splitting'],
|
||
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||
],
|
||
whatIs: 'Was ist TREK?',
|
||
whatIsDesc:
|
||
'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||
selfHost: 'Open Source — ',
|
||
selfHostLink: 'selbst hosten',
|
||
close: 'Verstanden',
|
||
},
|
||
en: {
|
||
titleBefore: 'Welcome to ',
|
||
titleAfter: '',
|
||
title: 'Welcome to the TREK Demo',
|
||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||
resetIn: 'Next reset in',
|
||
minutes: 'minutes',
|
||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||
fullVersionTitle: 'Additionally in the full version:',
|
||
features: [
|
||
'File uploads (photos, documents, covers)',
|
||
'API key management (Google Maps, Weather)',
|
||
'User & permission management',
|
||
'Automatic backups',
|
||
'Addon management (enable/disable)',
|
||
'OIDC / SSO single sign-on',
|
||
],
|
||
addonsTitle: 'Modular Addons (can be deactivated in full version)',
|
||
addons: [
|
||
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
|
||
['Atlas', 'World map with visited countries & travel stats'],
|
||
['Packing', 'Checklists per trip'],
|
||
['Budget', 'Expense tracking with splitting'],
|
||
['Documents', 'Attach files to trips'],
|
||
['Widgets', 'Currency converter & timezones'],
|
||
],
|
||
whatIs: 'What is TREK?',
|
||
whatIsDesc:
|
||
'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||
selfHost: 'Open source — ',
|
||
selfHostLink: 'self-host it',
|
||
close: 'Got it',
|
||
},
|
||
es: {
|
||
titleBefore: 'Bienvenido a ',
|
||
titleAfter: '',
|
||
title: 'Bienvenido a la demo de TREK',
|
||
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
|
||
resetIn: 'Próximo reinicio en',
|
||
minutes: 'minutos',
|
||
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
|
||
fullVersionTitle: 'Además, en la versión completa:',
|
||
features: [
|
||
'Subida de archivos (fotos, documentos, portadas)',
|
||
'Gestión de claves API (Google Maps, tiempo)',
|
||
'Gestión de usuarios y permisos',
|
||
'Copias de seguridad automáticas',
|
||
'Gestión de addons (activar/desactivar)',
|
||
'Inicio de sesión único OIDC / SSO',
|
||
],
|
||
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
|
||
addons: [
|
||
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
|
||
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
|
||
['Equipaje', 'Listas de comprobación para cada viaje'],
|
||
['Presupuesto', 'Control de gastos con reparto'],
|
||
['Documentos', 'Adjunta archivos a los viajes'],
|
||
['Widgets', 'Conversor de divisas y zonas horarias'],
|
||
],
|
||
whatIs: '¿Qué es TREK?',
|
||
whatIsDesc:
|
||
'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
|
||
selfHost: 'Código abierto — ',
|
||
selfHostLink: 'alójalo tú mismo',
|
||
close: 'Entendido',
|
||
},
|
||
zh: {
|
||
titleBefore: '欢迎来到 ',
|
||
titleAfter: '',
|
||
title: '欢迎来到 TREK 演示版',
|
||
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
|
||
resetIn: '下次重置将在',
|
||
minutes: '分钟后',
|
||
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
|
||
fullVersionTitle: '完整版本还包括:',
|
||
features: [
|
||
'文件上传(照片、文档、封面)',
|
||
'API 密钥管理(Google Maps、天气)',
|
||
'用户和权限管理',
|
||
'自动备份',
|
||
'附加组件管理(启用/禁用)',
|
||
'OIDC / SSO 单点登录',
|
||
],
|
||
addonsTitle: '模块化附加组件(完整版本可禁用)',
|
||
addons: [
|
||
['Vacay', '带日历、节假日和用户融合的假期规划器'],
|
||
['Atlas', '带已访问国家和旅行统计的世界地图'],
|
||
['Packing', '按旅行管理清单'],
|
||
['Budget', '支持分摊的费用追踪'],
|
||
['Documents', '将文件附加到旅行'],
|
||
['Widgets', '货币换算和时区工具'],
|
||
],
|
||
whatIs: '什么是 TREK?',
|
||
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
|
||
selfHost: '开源项目 - ',
|
||
selfHostLink: '自行部署',
|
||
close: '知道了',
|
||
},
|
||
'zh-TW': {
|
||
titleBefore: '歡迎來到 ',
|
||
titleAfter: '',
|
||
title: '歡迎來到 TREK 展示版',
|
||
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
|
||
resetIn: '下次重設將在',
|
||
minutes: '分鐘後',
|
||
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
|
||
fullVersionTitle: '完整版本還包含:',
|
||
features: [
|
||
'檔案上傳(照片、文件、封面)',
|
||
'API 金鑰管理(Google Maps、天氣)',
|
||
'使用者與權限管理',
|
||
'自動備份',
|
||
'附加元件管理(啟用/停用)',
|
||
'OIDC / SSO 單一登入',
|
||
],
|
||
addonsTitle: '模組化附加元件(完整版本可停用)',
|
||
addons: [
|
||
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
|
||
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
|
||
['Packing', '依行程管理的檢查清單'],
|
||
['Budget', '支援分攤的費用追蹤'],
|
||
['Documents', '將檔案附加到行程'],
|
||
['Widgets', '貨幣換算與時區工具'],
|
||
],
|
||
whatIs: 'TREK 是什麼?',
|
||
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
|
||
selfHost: '開源專案 - ',
|
||
selfHostLink: '自行架設',
|
||
close: '知道了',
|
||
},
|
||
ar: {
|
||
titleBefore: 'مرحبًا بك في ',
|
||
titleAfter: '',
|
||
title: 'مرحبًا بك في النسخة التجريبية من TREK',
|
||
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
|
||
resetIn: 'إعادة الضبط التالية خلال',
|
||
minutes: 'دقيقة',
|
||
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
|
||
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
|
||
features: [
|
||
'رفع الملفات (الصور والمستندات وصور الغلاف)',
|
||
'إدارة مفاتيح API (خرائط Google والطقس)',
|
||
'إدارة المستخدمين والصلاحيات',
|
||
'نسخ احتياطية تلقائية',
|
||
'إدارة الإضافات (تفعيل/تعطيل)',
|
||
'تسجيل دخول موحد OIDC / SSO',
|
||
],
|
||
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
|
||
addons: [
|
||
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
|
||
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
|
||
['Packing', 'قوائم تجهيز لكل رحلة'],
|
||
['Budget', 'تتبع المصروفات مع التقسيم'],
|
||
['Documents', 'إرفاق الملفات بالرحلات'],
|
||
['Widgets', 'محول عملات ومناطق زمنية'],
|
||
],
|
||
whatIs: 'ما هو TREK؟',
|
||
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
|
||
selfHost: 'مفتوح المصدر — ',
|
||
selfHostLink: 'استضفه بنفسك',
|
||
close: 'فهمت',
|
||
},
|
||
id: {
|
||
titleBefore: 'Selamat datang di ',
|
||
titleAfter: '',
|
||
title: 'Selamat datang di Demo TREK',
|
||
description:
|
||
'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
|
||
resetIn: 'Atur ulang berikutnya dalam',
|
||
minutes: 'menit',
|
||
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
|
||
fullVersionTitle: 'Selain itu dalam versi lengkap:',
|
||
features: [
|
||
'Unggah file (foto, dokumen, sampul)',
|
||
'Manajemen kunci API (Google Maps, Cuaca)',
|
||
'Manajemen pengguna & izin',
|
||
'Pencadangan otomatis',
|
||
'Manajemen Addon (aktifkan/nonaktifkan)',
|
||
'OIDC / SSO single sign-on',
|
||
],
|
||
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
|
||
addons: [
|
||
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
|
||
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
|
||
['Pengepakan', 'Daftar periksa per perjalanan'],
|
||
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
|
||
['Dokumen', 'Lampirkan file ke perjalanan'],
|
||
['Widget', 'Konverter mata uang & zona waktu'],
|
||
],
|
||
whatIs: 'Apa itu TREK?',
|
||
whatIsDesc:
|
||
'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
|
||
selfHost: 'Buka sumber — ',
|
||
selfHostLink: 'host mandiri',
|
||
close: 'Mengerti',
|
||
},
|
||
};
|
||
|
||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield];
|
||
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft];
|
||
|
||
export default function DemoBanner(): React.ReactElement | null {
|
||
const [dismissed, setDismissed] = useState<boolean>(false);
|
||
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes());
|
||
const { language } = useTranslation();
|
||
const t = texts[language] || texts.en;
|
||
|
||
useEffect(() => {
|
||
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000);
|
||
return () => clearInterval(interval);
|
||
}, []);
|
||
|
||
if (dismissed) return null;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
zIndex: 99999,
|
||
background: 'rgba(0,0,0,0.6)',
|
||
backdropFilter: 'blur(8px)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||
paddingLeft: 16,
|
||
paddingRight: 16,
|
||
overflow: 'auto',
|
||
fontFamily: 'var(--font-system)',
|
||
}}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label={t.close}
|
||
onClick={() => setDismissed(true)}
|
||
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||
if (e.target !== e.currentTarget) return;
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
setDismissed(true);
|
||
}
|
||
}}
|
||
>
|
||
<div
|
||
role="presentation"
|
||
style={{
|
||
background: 'white',
|
||
borderRadius: 20,
|
||
padding: '28px 24px 0',
|
||
maxWidth: 480,
|
||
width: '100%',
|
||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||
overflow: 'auto',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}}
|
||
onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||
<h2
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))',
|
||
fontWeight: 700,
|
||
color: '#111827',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 5,
|
||
}}
|
||
>
|
||
{t.titleBefore}
|
||
<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />
|
||
{t.titleAfter}
|
||
</h2>
|
||
</div>
|
||
|
||
<p
|
||
style={{
|
||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||
color: '#6b7280',
|
||
lineHeight: 1.6,
|
||
margin: '0 0 12px',
|
||
}}
|
||
>
|
||
{t.description}
|
||
</p>
|
||
|
||
{/* Timer + Upload note */}
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
background: '#f0f9ff',
|
||
border: '1px solid #bae6fd',
|
||
borderRadius: 10,
|
||
padding: '8px 10px',
|
||
}}
|
||
>
|
||
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}>
|
||
{t.resetIn} {minutesLeft} {t.minutes}
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
background: '#fffbeb',
|
||
border: '1px solid #fde68a',
|
||
borderRadius: 10,
|
||
padding: '8px 10px',
|
||
}}
|
||
>
|
||
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>
|
||
{t.uploadNote}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* What is TREK */}
|
||
<div
|
||
style={{
|
||
background: '#f8fafc',
|
||
borderRadius: 12,
|
||
padding: '12px 14px',
|
||
marginBottom: 16,
|
||
border: '1px solid #e2e8f0',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||
<Map size={14} style={{ color: '#111827' }} />
|
||
<span
|
||
style={{
|
||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||
fontWeight: 700,
|
||
color: '#111827',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 4,
|
||
}}
|
||
>
|
||
{t.whatIs}
|
||
</span>
|
||
</div>
|
||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>
|
||
{t.whatIsDesc}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Addons */}
|
||
<p
|
||
style={{
|
||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||
fontWeight: 700,
|
||
color: '#374151',
|
||
margin: '0 0 8px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.08em',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
}}
|
||
>
|
||
<Puzzle size={12} />
|
||
{t.addonsTitle}
|
||
</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||
{t.addons.map(([name, desc], i) => {
|
||
const Icon = addonIcons[i];
|
||
return (
|
||
<div
|
||
key={name}
|
||
style={{
|
||
background: '#f8fafc',
|
||
borderRadius: 10,
|
||
padding: '8px 10px',
|
||
border: '1px solid #f1f5f9',
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
|
||
<span
|
||
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}
|
||
>
|
||
{name}
|
||
</span>
|
||
</div>
|
||
<p
|
||
style={{
|
||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||
color: '#94a3b8',
|
||
margin: 0,
|
||
lineHeight: 1.3,
|
||
paddingLeft: 18,
|
||
}}
|
||
>
|
||
{desc}
|
||
</p>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Full version features */}
|
||
<p
|
||
style={{
|
||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||
fontWeight: 700,
|
||
color: '#374151',
|
||
margin: '0 0 8px',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: '0.08em',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
}}
|
||
>
|
||
<Shield size={12} />
|
||
{t.fullVersionTitle}
|
||
</p>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||
{t.features.map((text, i) => {
|
||
const Icon = featureIcons[i];
|
||
return (
|
||
<div
|
||
key={text}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||
color: '#4b5563',
|
||
padding: '4px 0',
|
||
}}
|
||
>
|
||
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
|
||
<span>{text}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div
|
||
style={{
|
||
padding: '14px 0 20px',
|
||
borderTop: '1px solid #e5e7eb',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
position: 'sticky',
|
||
bottom: 0,
|
||
background: 'white',
|
||
marginTop: 'auto',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||
color: '#9ca3af',
|
||
}}
|
||
>
|
||
<Github size={13} />
|
||
<span>{t.selfHost}</span>
|
||
<a
|
||
href="https://github.com/liketrek/TREK"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}
|
||
>
|
||
{t.selfHostLink}
|
||
</a>
|
||
</div>
|
||
<button type="button"
|
||
onClick={() => setDismissed(true)}
|
||
style={{
|
||
background: '#111827',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: 10,
|
||
padding: '8px 20px',
|
||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||
fontWeight: 600,
|
||
cursor: 'pointer',
|
||
fontFamily: 'inherit',
|
||
}}
|
||
>
|
||
{t.close}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|