From 60906cf1d1bd6324c18e1a0b67babcad97b54e1d Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Apr 2026 10:39:43 +0200 Subject: [PATCH 1/9] fix: hide MCP tokens tab when addon inactive, move permissions to users tab - MCP tokens tab only shown when MCP addon is enabled - Permissions panel moved from own tab to users tab below invite links - Fixed inconsistent dropdown widths in permissions panel --- client/src/components/Admin/PermissionsPanel.tsx | 1 + client/src/pages/AdminPage.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx index 3b3af479..85a6f2a4 100644 --- a/client/src/components/Admin/PermissionsPanel.tsx +++ b/client/src/components/Admin/PermissionsPanel.tsx @@ -154,6 +154,7 @@ export default function PermissionsPanel(): React.ReactElement { value: l, label: t(LEVEL_LABELS[l] || l), }))} + style={{ minWidth: 160 }} /> diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index b1c44dba..c57aad65 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import apiClient, { adminApi, authApi, notificationsApi } from '../api/client' import { useAuthStore } from '../store/authStore' import { useSettingsStore } from '../store/settingsStore' +import { useAddonStore } from '../store/addonStore' import { useTranslation } from '../i18n' import { getApiErrorMessage } from '../types' import Navbar from '../components/Layout/Navbar' @@ -58,15 +59,15 @@ export default function AdminPage(): React.ReactElement { const { demoMode, serverTimezone } = useAuthStore() const { t, locale } = useTranslation() const hour12 = useSettingsStore(s => s.settings.time_format) === '12h' + const mcpEnabled = useAddonStore(s => s.isEnabled('mcp')) const TABS = [ { id: 'users', label: t('admin.tabs.users') }, { id: 'config', label: t('admin.tabs.config') }, { id: 'addons', label: t('admin.tabs.addons') }, - { id: 'permissions', label: t('admin.tabs.permissions') }, { id: 'settings', label: t('admin.tabs.settings') }, { id: 'backup', label: t('admin.tabs.backup') }, { id: 'audit', label: t('admin.tabs.audit') }, - { id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }, + ...(mcpEnabled ? [{ id: 'mcp-tokens', label: t('admin.tabs.mcpTokens') }] : []), { id: 'github', label: t('admin.tabs.github') }, ] @@ -636,6 +637,8 @@ export default function AdminPage(): React.ReactElement { )} + {activeTab === 'users' &&
} + {/* Create Invite Modal */} setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
@@ -1155,8 +1158,6 @@ export default function AdminPage(): React.ReactElement {
)} - {activeTab === 'permissions' && } - {activeTab === 'backup' && } {activeTab === 'audit' && } From 4ebf9c5f119c22637d7dcaef87d9ce13183a6d77 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Apr 2026 12:16:11 +0200 Subject: [PATCH 2/9] feat: add expense date and CSV export to budget - New expense_date column on budget items (DB migration #42) - Date column in budget table with custom date picker - CSV export button with BOM, semicolon separator, localized dates, currency in header, per-person/day calculations - CustomDatePicker compact/borderless modes for inline table use - i18n keys for all 12 languages --- client/src/components/Budget/BudgetPanel.tsx | 88 +++++++++++++++---- .../shared/CustomDateTimePicker.tsx | 16 ++-- client/src/i18n/translations/ar.ts | 2 + client/src/i18n/translations/br.ts | 2 + client/src/i18n/translations/cs.ts | 2 + client/src/i18n/translations/de.ts | 2 + client/src/i18n/translations/en.ts | 2 + client/src/i18n/translations/es.ts | 2 + client/src/i18n/translations/fr.ts | 2 + client/src/i18n/translations/hu.ts | 2 + client/src/i18n/translations/it.ts | 2 + client/src/i18n/translations/nl.ts | 2 + client/src/i18n/translations/ru.ts | 2 + client/src/i18n/translations/zh.ts | 2 + client/src/types.ts | 1 + server/src/db/migrations.ts | 3 + server/src/routes/budget.ts | 13 +-- 17 files changed, 117 insertions(+), 28 deletions(-) diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index e95d990f..b5dba5a0 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -4,9 +4,10 @@ import DOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useTranslation } from '../../i18n' -import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react' +import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { budgetApi } from '../../api/client' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' import type { BudgetItem, BudgetMember } from '../../types' import { currencyDecimals } from '../../utils/formatters' @@ -88,7 +89,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder return (
{ if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip} - style={{ cursor: readOnly ? 'default' : 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center', + style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center', justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s', color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }} onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }} @@ -100,7 +101,7 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder // ── Add Item Row ───────────────────────────────────────────────────────────── interface AddItemRowProps { - onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void + onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void t: (key: string) => string } @@ -110,12 +111,13 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) { const [persons, setPersons] = useState('') const [days, setDays] = useState('') const [note, setNote] = useState('') + const [expenseDate, setExpenseDate] = useState('') const nameRef = useRef(null) const handleAdd = () => { if (!name.trim()) return - onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null }) - setName(''); setPrice(''); setPersons(''); setDays(''); setNote('') + onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null }) + setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('') setTimeout(() => nameRef.current?.focus(), 50) } @@ -133,15 +135,20 @@ function AddItemRow({ onAdd, t }: AddItemRowProps) { setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} /> + placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} /> + placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} /> - - - + +
+ +
+ setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> @@ -476,6 +483,41 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro setNewCategoryName('') } + const handleExportCsv = () => { + const sep = ';' + const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s } + const d = currencyDecimals(currency) + const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' + + const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) } + const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] + const rows = [header.join(sep)] + + for (const cat of categoryNames) { + for (const item of (grouped[cat] || [])) { + const pp = calcPP(item.total_price, item.persons) + const pd = calcPD(item.total_price, item.days) + const ppd = calcPPD(item.total_price, item.persons, item.days) + rows.push([ + esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')), + fmtPrice(item.total_price), item.persons ?? '', item.days ?? '', + fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd), + esc(item.note || ''), + ].join(sep)) + } + } + + const bom = '\uFEFF' + const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim() + a.download = `budget-${safeName}.csv` + a.click() + URL.revokeObjectURL(url) + } + const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' } const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' } @@ -512,6 +554,10 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro

{t('budget.title')}

+
@@ -564,14 +610,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro - - - - - - + + + + + + - + + @@ -623,6 +670,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro +
{t('budget.table.name')}{t('budget.table.total')}{t('budget.table.persons')}{t('budget.table.days')}{t('budget.table.perPerson')}{t('budget.table.perDay')}{t('budget.table.name')}{t('budget.table.total')}{t('budget.table.persons')}{t('budget.table.days')}{t('budget.table.perPerson')}{t('budget.table.perDay')} {t('budget.table.perPersonDay')}{t('budget.table.note')}{t('budget.table.date')}{t('budget.table.note')}
{pp != null ? fmt(pp, currency) : '-'} {pd != null ? fmt(pd, currency) : '-'} {ppd != null ? fmt(ppd, currency) : '-'} + {canEdit ? ( +
+ handleUpdateField(item.id, 'expense_date', v || null)} placeholder="—" compact borderless /> +
+ ) : ( + {item.expense_date || '—'} + )} +
handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} readOnly={!canEdit} /> {canEdit && ( @@ -645,7 +701,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro })} -
+
{t('budget.totalBudget')}
-
+
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
{SYMBOLS[currency] || currency} {currency}
diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index ccbf0896..e764d210 100644 --- a/client/src/components/shared/CustomDateTimePicker.tsx +++ b/client/src/components/shared/CustomDateTimePicker.tsx @@ -11,9 +11,11 @@ interface CustomDatePickerProps { onChange: (value: string) => void placeholder?: string style?: React.CSSProperties + compact?: boolean + borderless?: boolean } -export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) { +export function CustomDatePicker({ value, onChange, placeholder, style = {}, compact = false, borderless = false }: CustomDatePickerProps) { const { locale, t } = useTranslation() const [open, setOpen] = useState(false) const ref = useRef(null) @@ -45,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' })) - const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null + const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit' } : { day: 'numeric', month: 'short', year: 'numeric' }) : null const selectDay = (day: number) => { const y = String(viewYear) @@ -97,16 +99,16 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }: C ) : ( )} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index dbb14b12..cd3fe118 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -931,6 +931,7 @@ const ar: Record = { // Budget 'budget.title': 'الميزانية', + 'budget.exportCsv': 'تصدير CSV', 'budget.emptyTitle': 'لم يتم إنشاء ميزانية بعد', 'budget.emptyText': 'أنشئ فئات وإدخالات لتخطيط ميزانية سفرك', 'budget.emptyPlaceholder': 'أدخل اسم الفئة...', @@ -945,6 +946,7 @@ const ar: Record = { 'budget.table.perDay': 'لكل يوم', 'budget.table.perPersonDay': 'لكل شخص / يوم', 'budget.table.note': 'ملاحظة', + 'budget.table.date': 'التاريخ', 'budget.newEntry': 'إدخال جديد', 'budget.defaultEntry': 'إدخال جديد', 'budget.defaultCategory': 'فئة جديدة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index f4e7c698..a0ba58d5 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -910,6 +910,7 @@ const br: Record = { // Budget 'budget.title': 'Orçamento', + 'budget.exportCsv': 'Exportar CSV', 'budget.emptyTitle': 'Nenhum orçamento criado ainda', 'budget.emptyText': 'Crie categorias e lançamentos para planejar o orçamento da viagem', 'budget.emptyPlaceholder': 'Nome da categoria...', @@ -924,6 +925,7 @@ const br: Record = { 'budget.table.perDay': 'Por dia', 'budget.table.perPersonDay': 'P. p. / dia', 'budget.table.note': 'Obs.', + 'budget.table.date': 'Data', 'budget.newEntry': 'Novo lançamento', 'budget.defaultEntry': 'Novo lançamento', 'budget.defaultCategory': 'Nova categoria', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 3b69a1c9..4d234677 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -931,6 +931,7 @@ const cs: Record = { // Rozpočet (Budget) 'budget.title': 'Rozpočet', + 'budget.exportCsv': 'Exportovat CSV', 'budget.emptyTitle': 'Zatím nebyl vytvořen žádný rozpočet', 'budget.emptyText': 'Vytvořte kategorie a položky pro plánování cestovního rozpočtu', 'budget.emptyPlaceholder': 'Zadejte název kategorie...', @@ -945,6 +946,7 @@ const cs: Record = { 'budget.table.perDay': 'Za den', 'budget.table.perPersonDay': 'Os. / den', 'budget.table.note': 'Poznámka', + 'budget.table.date': 'Datum', 'budget.newEntry': 'Nová položka', 'budget.defaultEntry': 'Nová položka', 'budget.defaultCategory': 'Nová kategorie', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index eb0ffa4e..c2d13bea 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -928,6 +928,7 @@ const de: Record = { // Budget 'budget.title': 'Budget', + 'budget.exportCsv': 'CSV exportieren', 'budget.emptyTitle': 'Noch kein Budget erstellt', 'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen', 'budget.emptyPlaceholder': 'Kategoriename eingeben...', @@ -942,6 +943,7 @@ const de: Record = { 'budget.table.perDay': 'Pro Tag', 'budget.table.perPersonDay': 'P. p / Tag', 'budget.table.note': 'Notiz', + 'budget.table.date': 'Datum', 'budget.newEntry': 'Neuer Eintrag', 'budget.defaultEntry': 'Neuer Eintrag', 'budget.defaultCategory': 'Neue Kategorie', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 989e8ccf..e0b7087c 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -924,6 +924,7 @@ const en: Record = { // Budget 'budget.title': 'Budget', + 'budget.exportCsv': 'Export CSV', 'budget.emptyTitle': 'No budget created yet', 'budget.emptyText': 'Create categories and entries to plan your travel budget', 'budget.emptyPlaceholder': 'Enter category name...', @@ -938,6 +939,7 @@ const en: Record = { 'budget.table.perDay': 'Per Day', 'budget.table.perPersonDay': 'P. p / Day', 'budget.table.note': 'Note', + 'budget.table.date': 'Date', 'budget.newEntry': 'New Entry', 'budget.defaultEntry': 'New Entry', 'budget.defaultCategory': 'New Category', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index de28bef2..102e343a 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -888,6 +888,7 @@ const es: Record = { // Budget 'budget.title': 'Presupuesto', + 'budget.exportCsv': 'Exportar CSV', 'budget.emptyTitle': 'Aún no se ha creado ningún presupuesto', 'budget.emptyText': 'Crea categorías y entradas para planificar el presupuesto de tu viaje', 'budget.emptyPlaceholder': 'Introduce el nombre de la categoría...', @@ -902,6 +903,7 @@ const es: Record = { 'budget.table.perDay': 'Por día', 'budget.table.perPersonDay': 'Por pers. / día', 'budget.table.note': 'Nota', + 'budget.table.date': 'Fecha', 'budget.newEntry': 'Nueva entrada', 'budget.defaultEntry': 'Nueva entrada', 'budget.defaultCategory': 'Nueva categoría', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index c335ae44..9bcb2d37 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -927,6 +927,7 @@ const fr: Record = { // Budget 'budget.title': 'Budget', + 'budget.exportCsv': 'Exporter CSV', 'budget.emptyTitle': 'Aucun budget créé', 'budget.emptyText': 'Créez des catégories et des entrées pour planifier votre budget de voyage', 'budget.emptyPlaceholder': 'Nom de la catégorie…', @@ -941,6 +942,7 @@ const fr: Record = { 'budget.table.perDay': 'Par jour', 'budget.table.perPersonDay': 'P. p / Jour', 'budget.table.note': 'Note', + 'budget.table.date': 'Date', 'budget.newEntry': 'Nouvelle entrée', 'budget.defaultEntry': 'Nouvelle entrée', 'budget.defaultCategory': 'Nouvelle catégorie', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index c1836785..fc64a99c 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -926,6 +926,7 @@ const hu: Record = { // Költségvetés 'budget.title': 'Költségvetés', + 'budget.exportCsv': 'CSV exportálás', 'budget.emptyTitle': 'Még nincs költségvetés létrehozva', 'budget.emptyText': 'Hozz létre kategóriákat és bejegyzéseket az utazási költségvetés tervezéséhez', 'budget.emptyPlaceholder': 'Kategória neve...', @@ -940,6 +941,7 @@ const hu: Record = { 'budget.table.perDay': 'Naponta', 'budget.table.perPersonDay': 'Fő / Nap', 'budget.table.note': 'Megjegyzés', + 'budget.table.date': 'Dátum', 'budget.newEntry': 'Új bejegyzés', 'budget.defaultEntry': 'Új bejegyzés', 'budget.defaultCategory': 'Új kategória', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index bc5fac4a..629db66e 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -926,6 +926,7 @@ const it: Record = { // Budget 'budget.title': 'Budget', + 'budget.exportCsv': 'Esporta CSV', 'budget.emptyTitle': 'Ancora nessun budget creato', 'budget.emptyText': 'Crea categorie e voci per pianificare il budget del tuo viaggio', 'budget.emptyPlaceholder': 'Inserisci nome categoria...', @@ -940,6 +941,7 @@ const it: Record = { 'budget.table.perDay': 'Per giorno', 'budget.table.perPersonDay': 'P. p / gio.', 'budget.table.note': 'Nota', + 'budget.table.date': 'Data', 'budget.newEntry': 'Nuova voce', 'budget.defaultEntry': 'Nuova voce', 'budget.defaultCategory': 'Nuova categoria', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 82859632..e87e4eb8 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -927,6 +927,7 @@ const nl: Record = { // Budget 'budget.title': 'Budget', + 'budget.exportCsv': 'CSV exporteren', 'budget.emptyTitle': 'Nog geen budget aangemaakt', 'budget.emptyText': 'Maak categorieën en invoeren aan om je reisbudget te plannen', 'budget.emptyPlaceholder': 'Categorienaam invoeren...', @@ -941,6 +942,7 @@ const nl: Record = { 'budget.table.perDay': 'Per dag', 'budget.table.perPersonDay': 'P. p. / dag', 'budget.table.note': 'Notitie', + 'budget.table.date': 'Datum', 'budget.newEntry': 'Nieuwe invoer', 'budget.defaultEntry': 'Nieuwe invoer', 'budget.defaultCategory': 'Nieuwe categorie', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 54544584..20886f20 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -927,6 +927,7 @@ const ru: Record = { // Budget 'budget.title': 'Бюджет', + 'budget.exportCsv': 'Экспорт CSV', 'budget.emptyTitle': 'Бюджет ещё не создан', 'budget.emptyText': 'Создайте категории и записи для планирования бюджета поездки', 'budget.emptyPlaceholder': 'Введите название категории...', @@ -941,6 +942,7 @@ const ru: Record = { 'budget.table.perDay': 'В день', 'budget.table.perPersonDay': 'Чел. / день', 'budget.table.note': 'Заметка', + 'budget.table.date': 'Дата', 'budget.newEntry': 'Новая запись', 'budget.defaultEntry': 'Новая запись', 'budget.defaultCategory': 'Новая категория', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 9608ceb8..bd613cf2 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -927,6 +927,7 @@ const zh: Record = { // Budget 'budget.title': '预算', + 'budget.exportCsv': '导出 CSV', 'budget.emptyTitle': '尚未创建预算', 'budget.emptyText': '创建分类和条目来规划旅行预算', 'budget.emptyPlaceholder': '输入分类名称...', @@ -941,6 +942,7 @@ const zh: Record = { 'budget.table.perDay': '日均', 'budget.table.perPersonDay': '人日均', 'budget.table.note': '备注', + 'budget.table.date': '日期', 'budget.newEntry': '新建条目', 'budget.defaultEntry': '新建条目', 'budget.defaultCategory': '新分类', diff --git a/client/src/types.ts b/client/src/types.ts index 59159f5b..8141888f 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -110,6 +110,7 @@ export interface BudgetItem { paid_by: number | null persons: number members: BudgetMember[] + expense_date: string | null } export interface BudgetMember { diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index cb5c256c..e6f29291 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -436,6 +436,9 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); } catch {} }, + () => { + try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts index 9befbc48..201790ef 100644 --- a/server/src/routes/budget.ts +++ b/server/src/routes/budget.ts @@ -79,7 +79,7 @@ router.get('/summary/per-person', authenticate, (req: Request, res: Response) => router.post('/', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId } = req.params; - const { category, name, total_price, persons, days, note } = req.body; + const { category, name, total_price, persons, days, note, expense_date } = req.body; const trip = verifyTripOwnership(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); @@ -93,7 +93,7 @@ router.post('/', authenticate, (req: Request, res: Response) => { const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; const result = db.prepare( - 'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ).run( tripId, category || 'Other', @@ -102,7 +102,8 @@ router.post('/', authenticate, (req: Request, res: Response) => { persons != null ? persons : null, days !== undefined && days !== null ? days : null, note || null, - sortOrder + sortOrder, + expense_date || null ); const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid) as BudgetItem & { members?: BudgetItemMember[] }; @@ -114,7 +115,7 @@ router.post('/', authenticate, (req: Request, res: Response) => { router.put('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params; - const { category, name, total_price, persons, days, note, sort_order } = req.body; + const { category, name, total_price, persons, days, note, sort_order, expense_date } = req.body; const trip = verifyTripOwnership(tripId, authReq.user.id); if (!trip) return res.status(404).json({ error: 'Trip not found' }); @@ -133,7 +134,8 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { persons = CASE WHEN ? IS NOT NULL THEN ? ELSE persons END, days = CASE WHEN ? THEN ? ELSE days END, note = CASE WHEN ? THEN ? ELSE note END, - sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END + sort_order = CASE WHEN ? IS NOT NULL THEN ? ELSE sort_order END, + expense_date = CASE WHEN ? THEN ? ELSE expense_date END WHERE id = ? `).run( category || null, @@ -143,6 +145,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => { days !== undefined ? 1 : 0, days !== undefined ? days : null, note !== undefined ? 1 : 0, note !== undefined ? note : null, sort_order !== undefined ? 1 : null, sort_order !== undefined ? sort_order : 0, + expense_date !== undefined ? 1 : 0, expense_date !== undefined ? (expense_date || null) : null, id ); From e89ba2ecfcf6a8a57a7b0dc78fd499148660421d Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Apr 2026 12:17:53 +0200 Subject: [PATCH 3/9] fix: add referrerPolicy to TileLayer to fix OSM tile blocking (#264) OpenStreetMap requires a Referer header per their tile usage policy. Without it, tiles are blocked with "Access blocked" error. --- client/src/components/Map/MapView.tsx | 1 + client/src/pages/SharedTripPage.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 2a89f6a3..7489ef64 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -485,6 +485,7 @@ export const MapView = memo(function MapView({ attribution='© OpenStreetMap' maxZoom={19} keepBuffer={4} + referrerPolicy="strict-origin-when-cross-origin" /> diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 4d304a6f..d8fb54a3 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -168,7 +168,7 @@ export default function SharedTripPage() { {activeTab === 'plan' && (<>
- + {mapPlaces.map((p: any) => ( From 5c04074d54f4db8c71b12c62c9f55d2144477819 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Apr 2026 12:20:03 +0200 Subject: [PATCH 4/9] fix: allow unauthenticated SMTP by saving empty user/pass fields (#265) The test-smtp button filtered out empty SMTP user/password values before saving, preventing unauthenticated SMTP setups from working. Changed filter from truthy check to !== undefined so empty strings are properly persisted. --- client/src/pages/AdminPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index c57aad65..d195d030 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -1122,7 +1122,7 @@ export default function AdminPage(): React.ReactElement { onClick={async () => { const smtpKeys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify'] const payload: Record = {} - for (const k of smtpKeys) { if (smtpValues[k]) payload[k] = smtpValues[k] } + for (const k of smtpKeys) { if (smtpValues[k] !== undefined) payload[k] = smtpValues[k] } await authApi.updateAppSettings(payload).catch(() => {}) try { const result = await notificationsApi.testSmtp() From 7d0ae631b8344971a2790690bb72c0c8ea60e0ca Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Apr 2026 12:38:44 +0200 Subject: [PATCH 5/9] fix: mobile place editing and detail view (#269) - PlacesSidebar mobile: tap opens action sheet with view details, edit, assign to day, and delete options - PlaceInspector renders as fullscreen portal overlay on mobile - DayPlanSidebar mobile: tapping a place closes overlay and opens inspector - Inspector closes when edit or delete is triggered on mobile - i18n: added places.viewDetails for all 12 languages --- .../src/components/Planner/PlacesSidebar.tsx | 93 ++++++++++++------- client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/pages/TripPlannerPage.tsx | 59 +++++++++++- 14 files changed, 127 insertions(+), 37 deletions(-) diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 17cbbe82..ef7db095 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -2,7 +2,7 @@ import React from 'react' import ReactDOM from 'react-dom' import { useState, useRef, useMemo, useCallback } from 'react' import DOM from 'react-dom' -import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin } from 'lucide-react' +import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' @@ -92,6 +92,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ } const [dayPickerPlace, setDayPickerPlace] = useState(null) const [catDropOpen, setCatDropOpen] = useState(false) + const [mobileShowDays, setMobileShowDays] = useState(false) // Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen) const plannedIds = useMemo(() => new Set( @@ -286,7 +287,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ window.__dragData = { placeId: String(place.id) } }} onClick={() => { - if (isMobile && days?.length > 0) { + if (isMobile) { setDayPickerPlace(place) } else { onPlaceClick(isSelected ? null : place.id) @@ -353,49 +354,75 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ )}
- {dayPickerPlace && days?.length > 0 && ReactDOM.createPortal( + {dayPickerPlace && ReactDOM.createPortal(
setDayPickerPlace(null)} + onClick={() => { setDayPickerPlace(null); setMobileShowDays(false) }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }} >
e.stopPropagation()} - style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '60vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }} + style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'env(safe-area-inset-bottom)' }} >
{dayPickerPlace.name}
-
{t('places.assignToDay')}
+ {dayPickerPlace.address &&
{dayPickerPlace.address}
}
-
- {days.map((day, i) => { - return ( +
+ {/* View details */} + + {/* Edit */} + {canEditPlaces && ( + + )} + {/* Assign to day */} + {days?.length > 0 && ( + <> - ) - })} + {mobileShowDays && ( +
+ {days.map((day, i) => ( + + ))} +
+ )} + + )} + {/* Delete */} + {canEditPlaces && ( + + )}
, diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index cd3fe118..a68fe291 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -791,6 +791,7 @@ const ar: Record = { 'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.', 'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"', 'places.googleListError': 'فشل استيراد قائمة Google Maps', + 'places.viewDetails': 'عرض التفاصيل', 'places.urlResolved': 'تم استيراد المكان من الرابط', 'places.assignToDay': 'إلى أي يوم تريد الإضافة؟', 'places.all': 'الكل', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index a0ba58d5..044c39fb 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -771,6 +771,7 @@ const br: Record = { 'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.', 'places.googleListImported': '{count} lugares importados de "{list}"', 'places.googleListError': 'Falha ao importar lista do Google Maps', + 'places.viewDetails': 'Ver detalhes', 'places.urlResolved': 'Lugar importado da URL', 'places.assignToDay': 'Adicionar a qual dia?', 'places.all': 'Todos', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 4d234677..996edb41 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -792,6 +792,7 @@ const cs: Record = { 'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.', 'places.googleListImported': '{count} míst importováno ze seznamu "{list}"', 'places.googleListError': 'Import seznamu Google Maps se nezdařil', + 'places.viewDetails': 'Zobrazit detaily', 'places.assignToDay': 'Přidat do kterého dne?', 'places.all': 'Vše', 'places.unplanned': 'Nezařazené', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index c2d13bea..09139b3c 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -790,6 +790,7 @@ const de: Record = { 'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.', 'places.googleListImported': '{count} Orte aus "{list}" importiert', 'places.googleListError': 'Google Maps Liste konnte nicht importiert werden', + 'places.viewDetails': 'Details anzeigen', 'places.assignToDay': 'Zu welchem Tag hinzufügen?', 'places.all': 'Alle', 'places.unplanned': 'Ungeplant', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index e0b7087c..2f2231f9 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -786,6 +786,7 @@ const en: Record = { 'places.googleListHint': 'Paste a shared Google Maps list link to import all places.', 'places.googleListImported': '{count} places imported from "{list}"', 'places.googleListError': 'Failed to import Google Maps list', + 'places.viewDetails': 'View Details', 'places.assignToDay': 'Add to which day?', 'places.all': 'All', 'places.unplanned': 'Unplanned', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 102e343a..be7cf014 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -765,6 +765,7 @@ const es: Record = { 'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.', 'places.googleListImported': '{count} lugares importados de "{list}"', 'places.googleListError': 'Error al importar la lista de Google Maps', + 'places.viewDetails': 'Ver detalles', 'places.urlResolved': 'Lugar importado desde URL', 'places.assignToDay': '¿A qué día añadirlo?', 'places.all': 'Todo', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 9bcb2d37..eb7d0ad7 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -788,6 +788,7 @@ const fr: Record = { 'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.', 'places.googleListImported': '{count} lieux importés depuis "{list}"', 'places.googleListError': 'Impossible d\'importer la liste Google Maps', + 'places.viewDetails': 'Voir les détails', 'places.urlResolved': 'Lieu importé depuis l\'URL', 'places.assignToDay': 'Ajouter à quel jour ?', 'places.all': 'Tous', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index fc64a99c..1cbb682a 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -788,6 +788,7 @@ const hu: Record = { 'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.', 'places.googleListImported': '{count} hely importalva a(z) "{list}" listabol', 'places.googleListError': 'Google Maps lista importalasa sikertelen', + 'places.viewDetails': 'Részletek megtekintése', 'places.assignToDay': 'Melyik naphoz adod?', 'places.all': 'Összes', 'places.unplanned': 'Nem tervezett', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 629db66e..46fe748c 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -788,6 +788,7 @@ const it: Record = { 'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.', 'places.googleListImported': '{count} luoghi importati da "{list}"', 'places.googleListError': 'Importazione lista Google Maps non riuscita', + 'places.viewDetails': 'Visualizza dettagli', 'places.assignToDay': 'A quale giorno aggiungere?', 'places.all': 'Tutti', 'places.unplanned': 'Non pianificati', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index e87e4eb8..c99fee71 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -788,6 +788,7 @@ const nl: Record = { 'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.', 'places.googleListImported': '{count} plaatsen geimporteerd uit "{list}"', 'places.googleListError': 'Google Maps lijst importeren mislukt', + 'places.viewDetails': 'Details bekijken', 'places.urlResolved': 'Plaats geïmporteerd van URL', 'places.assignToDay': 'Aan welke dag toevoegen?', 'places.all': 'Alle', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 20886f20..2ea67fa5 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -788,6 +788,7 @@ const ru: Record = { 'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.', 'places.googleListImported': '{count} мест импортировано из "{list}"', 'places.googleListError': 'Не удалось импортировать список Google Maps', + 'places.viewDetails': 'Подробности', 'places.urlResolved': 'Место импортировано из URL', 'places.assignToDay': 'Добавить в какой день?', 'places.all': 'Все', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index bd613cf2..5fc6a1fb 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -788,6 +788,7 @@ const zh: Record = { 'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。', 'places.googleListImported': '已从"{list}"导入 {count} 个地点', 'places.googleListError': 'Google Maps 列表导入失败', + 'places.viewDetails': '查看详情', 'places.urlResolved': '已从 URL 导入地点', 'places.assignToDay': '添加到哪一天?', 'places.all': '全部', diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index eff748fb..5bc90550 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -562,7 +562,7 @@ export default function TripPlannerPage(): React.ReactElement | null { ) })()} - {selectedPlace && ( + {selectedPlace && !isMobile && ( setSelectedPlaceId(null)} onEdit={() => { - // When editing from assignment context, use assignment-level times if (selectedAssignmentId) { const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId) const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace @@ -609,6 +608,58 @@ export default function TripPlannerPage(): React.ReactElement | null { /> )} + {selectedPlace && isMobile && ReactDOM.createPortal( +
setSelectedPlaceId(null)}> +
e.stopPropagation()}> + setSelectedPlaceId(null)} + onEdit={() => { + if (selectedAssignmentId) { + const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId) + const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace + setEditingPlace(placeWithAssignmentTimes) + } else { + setEditingPlace(selectedPlace) + } + setEditingAssignmentId(selectedAssignmentId || null) + setShowPlaceForm(true) + setSelectedPlaceId(null) + }} + onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }} + onAssignToDay={handleAssignToDay} + onRemoveAssignment={handleRemoveAssignment} + files={files} + onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} + tripMembers={tripMembers} + onSetParticipants={async (assignmentId, dayId, userIds) => { + try { + const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds) + useTripStore.setState(state => ({ + assignments: { + ...state.assignments, + [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => + a.id === assignmentId ? { ...a, participants: data.participants } : a + ), + } + })) + } catch {} + }} + onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} + leftWidth={0} + rightWidth={0} + /> +
+
, + document.body + )} + {mobileSidebarOpen && ReactDOM.createPortal(
setMobileSidebarOpen(null)}>
e.stopPropagation()}> @@ -620,8 +671,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> - : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> + : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> }
From 95cb81b0e5a484e0c79c1552c7c9676b1cc69991 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Apr 2026 14:56:01 +0200 Subject: [PATCH 6/9] perf: major trip planner performance overhaul (#218) Store & re-render optimization: - TripPlannerPage uses selective Zustand selectors instead of full store - placesSlice only updates affected days on place update/delete - Route calculation only reacts to selected day's assignments - DayPlanSidebar uses stable action refs instead of full store Map marker performance: - Shared photoService for PlaceAvatar and MapView (single cache, no duplicate requests) - Client-side base64 thumbnail generation via canvas (CORS-safe for Wikimedia) - Map markers use base64 data URL tags for smooth zoom (no external image decode) - Sidebar uses same base64 thumbnails with IntersectionObserver for visible-first loading - Icon cache prevents duplicate L.divIcon creation - MarkerClusterGroup with animate:false and optimized chunk settings - Photo fetch deduplication and batched state updates Server optimizations: - Wikimedia image size reduced to 400px (from 600px) - Photo cache: 5min TTL for errors (was 12h), prevents stale 404 caching - Removed unused image-proxy endpoint UX improvements: - Splash screen with plane animation during initial photo preload - Markdown rendering in DayPlanSidebar place descriptions - Missing i18n keys added, all 12 languages synced to 1376 keys --- client/src/components/Map/MapView.tsx | 124 +++++++------ .../src/components/Planner/DayPlanSidebar.tsx | 42 +++-- client/src/components/shared/PlaceAvatar.tsx | 78 ++++---- client/src/hooks/useRouteCalculation.ts | 9 +- client/src/i18n/translations/ar.ts | 4 +- client/src/i18n/translations/br.ts | 20 ++- client/src/i18n/translations/cs.ts | 2 +- client/src/i18n/translations/de.ts | 4 +- client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 4 +- client/src/i18n/translations/fr.ts | 4 +- client/src/i18n/translations/hu.ts | 2 + client/src/i18n/translations/it.ts | 4 +- client/src/i18n/translations/nl.ts | 4 +- client/src/i18n/translations/ru.ts | 4 +- client/src/i18n/translations/zh.ts | 4 +- client/src/pages/TripPlannerPage.tsx | 167 ++++++++++++------ client/src/services/photoService.ts | 128 ++++++++++++++ client/src/store/slices/placesSlice.ts | 48 +++-- server/src/routes/maps.ts | 15 +- 20 files changed, 456 insertions(+), 212 deletions(-) create mode 100644 client/src/services/photoService.ts diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 7489ef64..26b11172 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -34,7 +34,12 @@ function escAttr(s) { return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') } +const iconCache = new Map() + function createPlaceIcon(place, orderNumbers, isSelected) { + const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}` + const cached = iconCache.get(cacheKey) + if (cached) return cached const size = isSelected ? 44 : 36 const borderColor = isSelected ? '#111827' : 'white' const borderWidth = isSelected ? 3 : 2.5 @@ -42,9 +47,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) { ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)' : '0 2px 8px rgba(0,0,0,0.22)' const bgColor = place.category_color || '#6b7280' - const icon = place.category_icon || '📍' - // Number badges (bottom-right), supports multiple numbers for duplicate places + // Number badges (bottom-right) let badgeHtml = '' if (orderNumbers && orderNumbers.length > 0) { const label = orderNumbers.join(' · ') @@ -62,28 +66,30 @@ function createPlaceIcon(place, orderNumbers, isSelected) { ">${label}` } - if (place.image_url) { - return L.divIcon({ + // Base64 data URL thumbnails — no external image fetch during zoom + // Only use base64 data URLs for markers — external URLs cause zoom lag + if (place.image_url && place.image_url.startsWith('data:')) { + const imgIcon = L.divIcon({ className: '', html: `
-
- -
+ ${badgeHtml}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) + iconCache.set(cacheKey, imgIcon) + return imgIcon } - return L.divIcon({ + const fallbackIcon = L.divIcon({ className: '', html: `
${categoryIconSvg(place.category_icon, isSelected ? 18 : 15)} ${badgeHtml} @@ -100,6 +107,8 @@ function createPlaceIcon(place, orderNumbers, isSelected) { iconAnchor: [size / 2, size / 2], tooltipAnchor: [size / 2 + 6, 0], }) + iconCache.set(cacheKey, fallbackIcon) + return fallbackIcon } interface SelectionControllerProps { @@ -174,6 +183,16 @@ interface MapClickHandlerProps { onClick: ((e: L.LeafletMouseEvent) => void) | null } +function ZoomTracker({ onZoomStart, onZoomEnd }: { onZoomStart: () => void; onZoomEnd: () => void }) { + const map = useMap() + useEffect(() => { + map.on('zoomstart', onZoomStart) + map.on('zoomend', onZoomEnd) + return () => { map.off('zoomstart', onZoomStart); map.off('zoomend', onZoomEnd) } + }, [map, onZoomStart, onZoomEnd]) + return null +} + function MapClickHandler({ onClick }: MapClickHandlerProps) { const map = useMap() useEffect(() => { @@ -245,8 +264,7 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { } // Module-level photo cache shared with PlaceAvatar -const mapPhotoCache = new Map() -const mapPhotoInFlight = new Set() +import { getCached, isLoading, fetchPhoto, onPhotoLoaded, onThumbReady, getAllThumbs } from '../../services/photoService' // Live location tracker — blue dot with pulse animation (like Apple/Google Maps) function LocationTracker() { @@ -366,51 +384,46 @@ export const MapView = memo(function MapView({ const right = rightWidth + 40 return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] } }, [leftWidth, rightWidth, hasInspector]) - const [photoUrls, setPhotoUrls] = useState({}) - // Fetch photos for places with concurrency limit to avoid blocking map rendering + // photoUrls: only base64 thumbs for smooth map zoom + const [photoUrls, setPhotoUrls] = useState>(getAllThumbs) + + // Fetch photos via shared service — subscribe to thumb (base64) availability + const placeIds = useMemo(() => places.map(p => p.id).join(','), [places]) useEffect(() => { - const queue = places.filter(place => { - if (place.image_url) return false + if (!places || places.length === 0) return + const cleanups: (() => void)[] = [] + + const setThumb = (cacheKey: string, thumb: string) => { + iconCache.clear() + setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb }) + } + + for (const place of places) { + if (place.image_url) continue const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` - if (!cacheKey) return false - if (mapPhotoCache.has(cacheKey)) { - const cached = mapPhotoCache.get(cacheKey) - if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached })) - return false + if (!cacheKey) continue + + const cached = getCached(cacheKey) + if (cached?.thumbDataUrl) { + setThumb(cacheKey, cached.thumbDataUrl) + continue } - if (mapPhotoInFlight.has(cacheKey)) return false - const photoId = place.google_place_id || place.osm_id - if (!photoId && !(place.lat && place.lng)) return false - return true - }) - let active = 0 - const MAX_CONCURRENT = 3 - let idx = 0 + // Subscribe for when thumb becomes available + cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb))) - const fetchNext = () => { - while (active < MAX_CONCURRENT && idx < queue.length) { - const place = queue[idx++] - const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + // Start fetch if not yet started + if (!cached && !isLoading(cacheKey)) { const photoId = place.google_place_id || place.osm_id - mapPhotoInFlight.add(cacheKey) - active++ - mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) - .then(data => { - if (data.photoUrl) { - mapPhotoCache.set(cacheKey, data.photoUrl) - setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl })) - } else { - mapPhotoCache.set(cacheKey, null) - } - }) - .catch(() => { mapPhotoCache.set(cacheKey, null) }) - .finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() }) + if (photoId || (place.lat && place.lng)) { + fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) + } } } - fetchNext() - }, [places]) + + return () => cleanups.forEach(fn => fn()) + }, [placeIds]) const clusterIconCreateFunction = useCallback((cluster) => { const count = cluster.getChildCount() @@ -426,10 +439,10 @@ export const MapView = memo(function MapView({ const markers = useMemo(() => places.map((place) => { const isSelected = place.id === selectedPlaceId - const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` - const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null + const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}` + const resolvedPhoto = place.image_url || (pck && photoUrls[pck]) || null const orderNumbers = dayOrderMap[place.id] ?? null - const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected) + const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected) return ( @@ -497,12 +513,14 @@ export const MapView = memo(function MapView({ {markers} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 38c61ffd..2c85a4f5 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -96,7 +96,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const { t, language, locale } = useTranslation() const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' - const tripStore = useTripStore() + const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canEditDays = can('day_edit', trip) @@ -425,7 +425,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ try { if (assignmentIds.length) await onReorder(dayId, assignmentIds) for (const n of noteUpdates) { - await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) + await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) } if (transportUpdates.length) { for (const tu of transportUpdates) { @@ -518,7 +518,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ currentAssignments[key] = currentAssignments[key].map(a => a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a ) - tripStore.setAssignments(currentAssignments) + tripActions.setAssignments(currentAssignments) } } catch (err) { toast.error(err instanceof Error ? err.message : 'Unknown error') @@ -653,9 +653,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), dayId) } else if (assignmentId && fromDayId !== dayId) { - tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId && fromDayId !== dayId) { - tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } setDraggingId(null) setDropTargetKey(null) @@ -911,11 +911,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (assignmentId && fromDayId !== day.id) { - tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (assignmentId) { handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId) } else if (noteId && fromDayId !== day.id) { - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId) } @@ -929,11 +929,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ setDropTargetKey(null); window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { - tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) @@ -1028,7 +1028,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ setDropTargetKey(null); window.__dragData = null } else if (fromAssignmentId && fromDayId !== day.id) { const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id) - tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id) @@ -1036,7 +1036,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) @@ -1121,10 +1121,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ )}
{(place.description || place.address || cat?.name) && ( -
- - {place.description || place.address || cat?.name} - +
+ {place.description || place.address || cat?.name || ''}
)} {(() => { @@ -1217,11 +1215,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (fromAssignmentId && fromDayId !== day.id) { - tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id) } else if (noteId && fromDayId !== day.id) { - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id) } @@ -1290,7 +1288,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null) } else if (fromNoteId && fromNoteId !== String(note.id)) { handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id) @@ -1298,7 +1296,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const tm = getMergedItems(day.id) const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length - tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) @@ -1363,11 +1361,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { - tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { - tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) + tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) @@ -1618,7 +1616,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ {/* Dateien */} {(() => { - const resFiles = (tripStore.files || []).filter(f => + const resFiles = (useTripStore.getState().files || []).filter(f => !f.deleted_at && ( f.reservation_id === res.id || (f.linked_reservation_ids && f.linked_reservation_ids.includes(res.id)) diff --git a/client/src/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx index ba682cf4..43d8a4fd 100644 --- a/client/src/components/shared/PlaceAvatar.tsx +++ b/client/src/components/shared/PlaceAvatar.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from 'react' -import { mapsApi } from '../../api/client' +import React, { useState, useEffect, useRef } from 'react' import { getCategoryIcon } from './categoryIcons' +import { getCached, isLoading, fetchPhoto, onThumbReady } from '../../services/photoService' import type { Place } from '../../types' interface Category { @@ -14,57 +14,52 @@ interface PlaceAvatarProps { category?: Category | null } -const photoCache = new Map() -const photoInFlight = new Set() -// Event-based notification instead of polling intervals -const photoListeners = new Map void>>() - -function notifyListeners(key: string, url: string | null) { - const listeners = photoListeners.get(key) - if (listeners) { - listeners.forEach(fn => fn(url)) - photoListeners.delete(key) - } -} - export default React.memo(function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) { const [photoSrc, setPhotoSrc] = useState(place.image_url || null) + const [visible, setVisible] = useState(false) + const ref = useRef(null) + + // Observe visibility — fetch photo only when avatar enters viewport + useEffect(() => { + if (place.image_url) { setVisible(true); return } + const el = ref.current + if (!el) return + // Check if already cached — show immediately without waiting for intersection + const photoId = place.google_place_id || place.osm_id + const cacheKey = photoId || `${place.lat},${place.lng}` + if (cacheKey && getCached(cacheKey)) { setVisible(true); return } + + const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' }) + io.observe(el) + return () => io.disconnect() + }, [place.id]) useEffect(() => { + if (!visible) return if (place.image_url) { setPhotoSrc(place.image_url); return } const photoId = place.google_place_id || place.osm_id if (!photoId && !(place.lat && place.lng)) { setPhotoSrc(null); return } const cacheKey = photoId || `${place.lat},${place.lng}` - if (photoCache.has(cacheKey)) { - const cached = photoCache.get(cacheKey) - if (cached) setPhotoSrc(cached) + + const cached = getCached(cacheKey) + if (cached) { + setPhotoSrc(cached.thumbDataUrl || cached.photoUrl) + if (!cached.thumbDataUrl && cached.photoUrl) { + return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb)) + } return } - if (photoInFlight.has(cacheKey)) { - // Subscribe to notification instead of polling - if (!photoListeners.has(cacheKey)) photoListeners.set(cacheKey, new Set()) - const handler = (url: string | null) => { if (url) setPhotoSrc(url) } - photoListeners.get(cacheKey)!.add(handler) - return () => { photoListeners.get(cacheKey)?.delete(handler) } + if (isLoading(cacheKey)) { + return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb)) } - photoInFlight.add(cacheKey) - mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name) - .then((data: { photoUrl?: string }) => { - const url = data.photoUrl || null - photoCache.set(cacheKey, url) - if (url) setPhotoSrc(url) - notifyListeners(cacheKey, url) - photoInFlight.delete(cacheKey) - }) - .catch(() => { - photoCache.set(cacheKey, null) - notifyListeners(cacheKey, null) - photoInFlight.delete(cacheKey) - }) - }, [place.id, place.image_url, place.google_place_id, place.osm_id]) + fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name, + entry => { setPhotoSrc(entry.thumbDataUrl || entry.photoUrl) } + ) + return onThumbReady(cacheKey, thumb => setPhotoSrc(thumb)) + }, [visible, place.id, place.image_url, place.google_place_id, place.osm_id]) const bgColor = category?.color || '#6366f1' const IconComp = getCategoryIcon(category?.icon) @@ -81,11 +76,10 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P if (photoSrc) { return ( -
+
{place.name} setPhotoSrc(null)} @@ -95,7 +89,7 @@ export default React.memo(function PlaceAvatar({ place, size = 32, category }: P } return ( -
+
) diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 2cd5c0e1..e78c3fb6 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -19,7 +19,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu const updateRouteForDay = useCallback(async (dayId: number | null) => { if (routeAbortRef.current) routeAbortRef.current.abort() if (!dayId) { setRoute(null); setRouteSegments([]); return } - const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) + const currentAssignments = tripStore.assignments || {} + const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng) if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return } setRoute(waypoints.map((p) => [p.lat!, p.lng!])) @@ -33,12 +34,14 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([]) else if (!(err instanceof Error)) setRouteSegments([]) } - }, [tripStore, routeCalcEnabled]) + }, [routeCalcEnabled]) + // Only recalculate when assignments for the SELECTED day change + const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null useEffect(() => { if (!selectedDayId) { setRoute(null); setRouteSegments([]); return } updateRouteForDay(selectedDayId) - }, [selectedDayId, tripStore.assignments]) + }, [selectedDayId, selectedDayAssignments]) return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } } diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index a68fe291..1b08bf87 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -248,6 +248,7 @@ const ar: Record = { 'settings.roleAdmin': 'مسؤول', 'settings.oidcLinked': 'مرتبط مع', 'settings.changePassword': 'تغيير كلمة المرور', + 'settings.mustChangePassword': 'يجب عليك تغيير كلمة المرور قبل المتابعة. يرجى تعيين كلمة مرور جديدة أدناه.', 'settings.currentPassword': 'كلمة المرور الحالية', 'settings.currentPasswordRequired': 'كلمة المرور الحالية مطلوبة', 'settings.newPassword': 'كلمة المرور الجديدة', @@ -695,7 +696,6 @@ const ar: Record = { 'atlas.statsTab': 'الإحصائيات', 'atlas.bucketTab': 'قائمة الأمنيات', 'atlas.addBucket': 'إضافة إلى قائمة الأمنيات', - 'atlas.bucketNamePlaceholder': 'مكان أو وجهة...', 'atlas.bucketNotesPlaceholder': 'ملاحظات (اختياري)', 'atlas.bucketEmpty': 'قائمة أمنياتك فارغة', 'atlas.bucketEmptyHint': 'أضف أماكن تحلم بزيارتها', @@ -708,7 +708,6 @@ const ar: Record = { 'atlas.nextTrip': 'الرحلة القادمة', 'atlas.daysLeft': 'يوم متبقٍ', 'atlas.streak': 'سلسلة', - 'atlas.year': 'سنة', 'atlas.years': 'سنوات', 'atlas.yearInRow': 'سنة متتالية', 'atlas.yearsInRow': 'سنوات متتالية', @@ -738,6 +737,7 @@ const ar: Record = { 'trip.tabs.budget': 'الميزانية', 'trip.tabs.files': 'الملفات', 'trip.loading': 'جارٍ تحميل الرحلة...', + 'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...', 'trip.mobilePlan': 'الخطة', 'trip.mobilePlaces': 'الأماكن', 'trip.toast.placeUpdated': 'تم تحديث المكان', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 044c39fb..054ae5f5 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -294,6 +294,7 @@ const br: Record = { 'settings.mcp.toast.createError': 'Falha ao criar token', 'settings.mcp.toast.deleted': 'Token excluído', 'settings.mcp.toast.deleteError': 'Falha ao excluir token', + 'settings.mustChangePassword': 'Você deve alterar sua senha antes de continuar. Defina uma nova senha abaixo.', // Login 'login.error': 'Falha no login. Verifique suas credenciais.', @@ -503,11 +504,13 @@ const br: Record = { 'admin.addons.disabled': 'Desativado', 'admin.addons.type.trip': 'Viagem', 'admin.addons.type.global': 'Global', + 'admin.addons.type.integration': 'Integração', 'admin.addons.tripHint': 'Disponível como aba em cada viagem', 'admin.addons.globalHint': 'Disponível como seção própria na navegação principal', 'admin.addons.toast.updated': 'Complemento atualizado', 'admin.addons.toast.error': 'Falha ao atualizar complemento', 'admin.addons.noAddons': 'Nenhum complemento disponível', + 'admin.addons.integrationHint': 'Serviços de backend e integrações de API sem página dedicada', // Weather info 'admin.weather.title': 'Dados meteorológicos', 'admin.weather.badge': 'Desde 24 de março de 2026', @@ -675,7 +678,6 @@ const br: Record = { 'atlas.statsTab': 'Estatísticas', 'atlas.bucketTab': 'Lista de desejos', 'atlas.addBucket': 'Adicionar à lista de desejos', - 'atlas.bucketNamePlaceholder': 'Lugar ou destino...', 'atlas.bucketNotesPlaceholder': 'Notas (opcional)', 'atlas.bucketEmpty': 'Sua lista de desejos está vazia', 'atlas.bucketEmptyHint': 'Adicione lugares que sonha em visitar', @@ -688,7 +690,6 @@ const br: Record = { 'atlas.nextTrip': 'Próxima viagem', 'atlas.daysLeft': 'dias restantes', 'atlas.streak': 'Sequência', - 'atlas.year': 'ano', 'atlas.years': 'anos', 'atlas.yearInRow': 'ano seguido', 'atlas.yearsInRow': 'anos seguidos', @@ -730,6 +731,7 @@ const br: Record = { 'trip.toast.reservationAdded': 'Reserva adicionada', 'trip.toast.deleted': 'Excluído', 'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?', + 'trip.loadingPhotos': 'Carregando fotos dos lugares...', // Day Plan Sidebar 'dayplan.emptyDay': 'Nenhum lugar planejado para este dia', @@ -1414,6 +1416,20 @@ const br: Record = { // Permissions 'admin.tabs.permissions': 'Permissões', + 'admin.tabs.mcpTokens': 'Tokens MCP', + 'admin.mcpTokens.title': 'Tokens MCP', + 'admin.mcpTokens.subtitle': 'Gerenciar tokens de API de todos os usuários', + 'admin.mcpTokens.owner': 'Proprietário', + 'admin.mcpTokens.tokenName': 'Nome do Token', + 'admin.mcpTokens.created': 'Criado', + 'admin.mcpTokens.lastUsed': 'Último uso', + 'admin.mcpTokens.never': 'Nunca', + 'admin.mcpTokens.empty': 'Nenhum token MCP foi criado ainda', + 'admin.mcpTokens.deleteTitle': 'Excluir Token', + 'admin.mcpTokens.deleteMessage': 'Isso revogará o token imediatamente. O usuário perderá o acesso MCP por este token.', + 'admin.mcpTokens.deleteSuccess': 'Token excluído', + 'admin.mcpTokens.deleteError': 'Falha ao excluir token', + 'admin.mcpTokens.loadError': 'Falha ao carregar tokens', 'perm.title': 'Configurações de Permissões', 'perm.subtitle': 'Controle quem pode realizar ações no aplicativo', 'perm.saved': 'Configurações de permissões salvas', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 996edb41..26f0c338 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -695,7 +695,6 @@ const cs: Record = { 'atlas.statsTab': 'Statistiky', 'atlas.bucketTab': 'Bucket List', 'atlas.addBucket': 'Přidat na Bucket List', - 'atlas.bucketNamePlaceholder': 'Místo nebo destinace...', 'atlas.bucketNotesPlaceholder': 'Poznámky (volitelné)', 'atlas.bucketEmpty': 'Váš seznam přání je prázdný', 'atlas.bucketEmptyHint': 'Přidejte místa, která sníte navštívit', @@ -738,6 +737,7 @@ const cs: Record = { 'trip.tabs.budget': 'Rozpočet', 'trip.tabs.files': 'Soubory', 'trip.loading': 'Načítání cesty...', + 'trip.loadingPhotos': 'Načítání fotek míst...', 'trip.mobilePlan': 'Plán', 'trip.mobilePlaces': 'Místa', 'trip.toast.placeUpdated': 'Místo bylo aktualizováno', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 09139b3c..0c32841b 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -243,6 +243,7 @@ const de: Record = { 'settings.roleAdmin': 'Administrator', 'settings.oidcLinked': 'Verknüpft mit', 'settings.changePassword': 'Passwort ändern', + 'settings.mustChangePassword': 'Sie müssen Ihr Passwort ändern, bevor Sie fortfahren können. Bitte legen Sie unten ein neues Passwort fest.', 'settings.currentPassword': 'Aktuelles Passwort', 'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt', 'settings.newPassword': 'Neues Passwort', @@ -693,7 +694,6 @@ const de: Record = { 'atlas.statsTab': 'Statistik', 'atlas.bucketTab': 'Bucket List', 'atlas.addBucket': 'Zur Bucket List hinzufügen', - 'atlas.bucketNamePlaceholder': 'Ort oder Reiseziel...', 'atlas.bucketNotesPlaceholder': 'Notizen (optional)', 'atlas.bucketEmpty': 'Deine Bucket List ist leer', 'atlas.bucketEmptyHint': 'Füge Orte hinzu, die du besuchen möchtest', @@ -706,7 +706,6 @@ const de: Record = { 'atlas.nextTrip': 'Nächster Trip', 'atlas.daysLeft': 'Tage', 'atlas.streak': 'Streak', - 'atlas.year': 'Jahr', 'atlas.years': 'Jahre', 'atlas.yearInRow': 'Jahr in Folge', 'atlas.yearsInRow': 'Jahre in Folge', @@ -736,6 +735,7 @@ const de: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Dateien', 'trip.loading': 'Reise wird geladen...', + 'trip.loadingPhotos': 'Fotos der Orte werden geladen...', 'trip.mobilePlan': 'Planung', 'trip.mobilePlaces': 'Orte', 'trip.toast.placeUpdated': 'Ort aktualisiert', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 2f2231f9..d20130d4 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -732,6 +732,7 @@ const en: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Files', 'trip.loading': 'Loading trip...', + 'trip.loadingPhotos': 'Loading place photos...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Places', 'trip.toast.placeUpdated': 'Place updated', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index be7cf014..9c0dedce 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -244,6 +244,7 @@ const es: Record = { 'settings.roleAdmin': 'Administrador', 'settings.oidcLinked': 'Vinculado con', 'settings.changePassword': 'Cambiar contraseña', + 'settings.mustChangePassword': 'Debe cambiar su contraseña antes de continuar. Establezca una nueva contraseña a continuación.', 'settings.currentPassword': 'Contraseña actual', 'settings.newPassword': 'Nueva contraseña', 'settings.confirmPassword': 'Confirmar nueva contraseña', @@ -697,9 +698,7 @@ const es: Record = { 'atlas.addToBucket': 'Añadir a lista de deseos', 'atlas.addPoi': 'Añadir lugar', 'atlas.searchCountry': 'Buscar un país...', - 'atlas.bucketNamePlaceholder': 'Nombre (país, ciudad, lugar…)', 'atlas.month': 'Mes', - 'atlas.year': 'Año', 'atlas.addToBucketHint': 'Guardar como lugar que quieres visitar', 'atlas.bucketWhen': '¿Cuándo planeas visitarlo?', @@ -712,6 +711,7 @@ const es: Record = { 'trip.tabs.budget': 'Presupuesto', 'trip.tabs.files': 'Archivos', 'trip.loading': 'Cargando viaje...', + 'trip.loadingPhotos': 'Cargando fotos de los lugares...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lugares', 'trip.toast.placeUpdated': 'Lugar actualizado', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index eb7d0ad7..d0caa3af 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -243,6 +243,7 @@ const fr: Record = { 'settings.roleAdmin': 'Administrateur', 'settings.oidcLinked': 'Lié avec', 'settings.changePassword': 'Changer le mot de passe', + 'settings.mustChangePassword': 'Vous devez changer votre mot de passe avant de continuer. Veuillez définir un nouveau mot de passe ci-dessous.', 'settings.currentPassword': 'Mot de passe actuel', 'settings.currentPasswordRequired': 'Le mot de passe actuel est requis', 'settings.newPassword': 'Nouveau mot de passe', @@ -720,9 +721,7 @@ const fr: Record = { 'atlas.addToBucket': 'Ajouter à la bucket list', 'atlas.addPoi': 'Ajouter un lieu', 'atlas.searchCountry': 'Rechercher un pays…', - 'atlas.bucketNamePlaceholder': 'Nom (pays, ville, lieu…)', 'atlas.month': 'Mois', - 'atlas.year': 'Année', 'atlas.addToBucketHint': 'Sauvegarder comme lieu à visiter', 'atlas.bucketWhen': 'Quand prévoyez-vous d\'y aller ?', @@ -735,6 +734,7 @@ const fr: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Fichiers', 'trip.loading': 'Chargement du voyage…', + 'trip.loadingPhotos': 'Chargement des photos des lieux...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Lieux', 'trip.toast.placeUpdated': 'Lieu mis à jour', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 1cbb682a..34589ce3 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -246,6 +246,7 @@ const hu: Record = { 'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve', 'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva', 'settings.mfa.demoBlocked': 'Demo módban nem érhető el', + 'settings.mustChangePassword': 'A folytatás előtt meg kell változtatnod a jelszavad. Kérjük, adj meg egy új jelszót alább.', 'admin.notifications.title': 'Értesítések', 'admin.notifications.hint': 'Válasszon értesítési csatornát. Egyszerre csak egy lehet aktív.', 'admin.notifications.none': 'Kikapcsolva', @@ -746,6 +747,7 @@ const hu: Record = { 'trip.toast.reservationAdded': 'Foglalás hozzáadva', 'trip.toast.deleted': 'Törölve', 'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?', + 'trip.loadingPhotos': 'Helyek fotóinak betöltése...', // Napi terv oldalsáv 'dayplan.emptyDay': 'Nincs tervezett hely erre a napra', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 46fe748c..377a2dd4 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -246,6 +246,7 @@ const it: Record = { 'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata', 'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata', 'settings.mfa.demoBlocked': 'Non disponibile in modalità demo', + 'settings.mustChangePassword': 'Devi cambiare la password prima di continuare. Imposta una nuova password qui sotto.', 'admin.notifications.title': 'Notifiche', 'admin.notifications.hint': 'Scegli un canale di notifica. Solo uno può essere attivo alla volta.', 'admin.notifications.none': 'Disattivato', @@ -691,7 +692,6 @@ const it: Record = { 'atlas.statsTab': 'Statistiche', 'atlas.bucketTab': 'Lista desideri', 'atlas.addBucket': 'Aggiungi alla lista desideri', - 'atlas.bucketNamePlaceholder': 'Luogo o destinazione...', 'atlas.bucketNotesPlaceholder': 'Note (opzionale)', 'atlas.bucketEmpty': 'La tua lista desideri è vuota', 'atlas.bucketEmptyHint': 'Aggiungi luoghi che sogni di visitare', @@ -724,6 +724,7 @@ const it: Record = { 'atlas.tripPlural': 'Viaggi', 'atlas.placeVisited': 'Luogo visitato', 'atlas.placesVisited': 'Luoghi visitati', + 'atlas.searchCountry': 'Cerca un paese...', // Trip Planner 'trip.tabs.plan': 'Programma', @@ -746,6 +747,7 @@ const it: Record = { 'trip.toast.reservationAdded': 'Prenotazione aggiunta', 'trip.toast.deleted': 'Eliminato', 'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?', + 'trip.loadingPhotos': 'Caricamento foto dei luoghi...', // Day Plan Sidebar 'dayplan.emptyDay': 'Nessun luogo programmato per questo giorno', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index c99fee71..eb22e5b8 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -243,6 +243,7 @@ const nl: Record = { 'settings.roleAdmin': 'Beheerder', 'settings.oidcLinked': 'Gekoppeld met', 'settings.changePassword': 'Wachtwoord wijzigen', + 'settings.mustChangePassword': 'U moet uw wachtwoord wijzigen voordat u kunt doorgaan. Stel hieronder een nieuw wachtwoord in.', 'settings.currentPassword': 'Huidig wachtwoord', 'settings.currentPasswordRequired': 'Huidig wachtwoord is verplicht', 'settings.newPassword': 'Nieuw wachtwoord', @@ -720,9 +721,7 @@ const nl: Record = { 'atlas.addToBucket': 'Aan bucket list toevoegen', 'atlas.addPoi': 'Plaats toevoegen', 'atlas.searchCountry': 'Zoek een land...', - 'atlas.bucketNamePlaceholder': 'Naam (land, stad, plek…)', 'atlas.month': 'Maand', - 'atlas.year': 'Jaar', 'atlas.addToBucketHint': 'Opslaan als plek die je wilt bezoeken', 'atlas.bucketWhen': 'Wanneer ben je van plan te gaan?', @@ -735,6 +734,7 @@ const nl: Record = { 'trip.tabs.budget': 'Budget', 'trip.tabs.files': 'Bestanden', 'trip.loading': 'Reis laden...', + 'trip.loadingPhotos': 'Plaatsfoto laden...', 'trip.mobilePlan': 'Plan', 'trip.mobilePlaces': 'Plaatsen', 'trip.toast.placeUpdated': 'Plaats bijgewerkt', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 2ea67fa5..03c843ae 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -243,6 +243,7 @@ const ru: Record = { 'settings.roleAdmin': 'Администратор', 'settings.oidcLinked': 'Связан с', 'settings.changePassword': 'Изменить пароль', + 'settings.mustChangePassword': 'Вы должны сменить пароль перед продолжением. Пожалуйста, установите новый пароль ниже.', 'settings.currentPassword': 'Текущий пароль', 'settings.currentPasswordRequired': 'Текущий пароль обязателен', 'settings.newPassword': 'Новый пароль', @@ -720,9 +721,7 @@ const ru: Record = { 'atlas.addToBucket': 'В список желаний', 'atlas.addPoi': 'Добавить место', 'atlas.searchCountry': 'Поиск страны...', - 'atlas.bucketNamePlaceholder': 'Название (страна, город, место…)', 'atlas.month': 'Месяц', - 'atlas.year': 'Год', 'atlas.addToBucketHint': 'Сохранить как место для посещения', 'atlas.bucketWhen': 'Когда вы планируете поехать?', @@ -735,6 +734,7 @@ const ru: Record = { 'trip.tabs.budget': 'Бюджет', 'trip.tabs.files': 'Файлы', 'trip.loading': 'Загрузка поездки...', + 'trip.loadingPhotos': 'Загрузка фото мест...', 'trip.mobilePlan': 'План', 'trip.mobilePlaces': 'Места', 'trip.toast.placeUpdated': 'Место обновлено', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 5fc6a1fb..156dec53 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -243,6 +243,7 @@ const zh: Record = { 'settings.roleAdmin': '管理员', 'settings.oidcLinked': '已关联', 'settings.changePassword': '修改密码', + 'settings.mustChangePassword': '您必须更改密码才能继续。请在下方设置新密码。', 'settings.currentPassword': '当前密码', 'settings.currentPasswordRequired': '请输入当前密码', 'settings.newPassword': '新密码', @@ -720,9 +721,7 @@ const zh: Record = { 'atlas.addToBucket': '添加到心愿单', 'atlas.addPoi': '添加地点', 'atlas.searchCountry': '搜索国家...', - 'atlas.bucketNamePlaceholder': '名称(国家、城市、地点…)', 'atlas.month': '月份', - 'atlas.year': '年份', 'atlas.addToBucketHint': '保存为想去的地方', 'atlas.bucketWhen': '你计划什么时候去?', @@ -735,6 +734,7 @@ const zh: Record = { 'trip.tabs.budget': '预算', 'trip.tabs.files': '文件', 'trip.loading': '加载旅行中...', + 'trip.loadingPhotos': '正在加载地点照片...', 'trip.mobilePlan': '计划', 'trip.mobilePlaces': '地点', 'trip.toast.placeUpdated': '地点已更新', diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 5bc90550..3b507446 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react' +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import ReactDOM from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useTripStore } from '../store/tripStore' import { useCanDo } from '../store/permissionsStore' import { useSettingsStore } from '../store/settingsStore' import { MapView } from '../components/Map/MapView' +import { getCached, fetchPhoto } from '../services/photoService' import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import PlacesSidebar from '../components/Planner/PlacesSidebar' import PlaceInspector from '../components/Planner/PlaceInspector' @@ -23,7 +24,7 @@ import Navbar from '../components/Layout/Navbar' import { useToast } from '../components/shared/Toast' import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react' import { useTranslation } from '../i18n' -import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client' +import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import ConfirmDialog from '../components/shared/ConfirmDialog' import { useResizablePanels } from '../hooks/useResizablePanels' import { useTripWebSocket } from '../hooks/useTripWebSocket' @@ -37,8 +38,19 @@ export default function TripPlannerPage(): React.ReactElement | null { const toast = useToast() const { t, language } = useTranslation() const { settings } = useSettingsStore() - const tripStore = useTripStore() - const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore + const trip = useTripStore(s => s.trip) + const days = useTripStore(s => s.days) + const places = useTripStore(s => s.places) + const assignments = useTripStore(s => s.assignments) + const packingItems = useTripStore(s => s.packingItems) + const categories = useTripStore(s => s.categories) + const reservations = useTripStore(s => s.reservations) + const budgetItems = useTripStore(s => s.budgetItems) + const files = useTripStore(s => s.files) + const selectedDayId = useTripStore(s => s.selectedDayId) + const isLoading = useTripStore(s => s.isLoading) + // Actions — stable references, don't cause re-renders + const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canUploadFiles = can('file_upload', trip) @@ -50,7 +62,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const loadAccommodations = useCallback(() => { if (tripId) { accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) - tripStore.loadReservations(tripId) + tripActions.loadReservations(tripId) } }, [tripId]) @@ -83,8 +95,8 @@ export default function TripPlannerPage(): React.ReactElement | null { const handleTabChange = (tabId: string): void => { setActiveTab(tabId) sessionStorage.setItem(`trip-tab-${tripId}`, tabId) - if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId) - if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId) + if (tabId === 'finanzplan') tripActions.loadBudgetItems?.(tripId) + if (tabId === 'dateien' && (!files || files.length === 0)) tripActions.loadFiles?.(tripId) } const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels() const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection() @@ -109,11 +121,25 @@ export default function TripPlannerPage(): React.ReactElement | null { return () => mq.removeEventListener('change', handler) }, []) + // Start photo fetches during splash screen so images are ready when map mounts + useEffect(() => { + if (isLoading || !places || places.length === 0) return + for (const p of places) { + if (p.image_url) continue + const cacheKey = p.google_place_id || p.osm_id || `${p.lat},${p.lng}` + if (!cacheKey || getCached(cacheKey)) continue + const photoId = p.google_place_id || p.osm_id + if (photoId || (p.lat && p.lng)) { + fetchPhoto(cacheKey, photoId || `coords:${p.lat}:${p.lng}`, p.lat, p.lng, p.name) + } + } + }, [isLoading, places]) + // Load trip + files (needed for place inspector file section) useEffect(() => { if (tripId) { - tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) - tripStore.loadFiles(tripId) + tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) + tripActions.loadFiles(tripId) loadAccommodations() tripsApi.getMembers(tripId).then(d => { // Combine owner + members into one list @@ -124,7 +150,7 @@ export default function TripPlannerPage(): React.ReactElement | null { }, [tripId]) useEffect(() => { - if (tripId) tripStore.loadReservations(tripId) + if (tripId) tripActions.loadReservations(tripId) }, [tripId]) useTripWebSocket(tripId) @@ -139,15 +165,15 @@ export default function TripPlannerPage(): React.ReactElement | null { }) }, [places, mapCategoryFilter]) - const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId) + const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId) const handleSelectDay = useCallback((dayId, skipFit) => { const changed = dayId !== selectedDayId - tripStore.setSelectedDay(dayId) + tripActions.setSelectedDay(dayId) if (changed && !skipFit) setFitKey(k => k + 1) setMobileSidebarOpen(null) updateRouteForDay(dayId) - }, [tripStore, updateRouteForDay, selectedDayId]) + }, [updateRouteForDay, selectedDayId]) const handlePlaceClick = useCallback((placeId, assignmentId) => { if (assignmentId) { @@ -191,11 +217,11 @@ export default function TripPlannerPage(): React.ReactElement | null { if (editingPlace) { // Always strip time fields from place update — time is per-assignment only const { place_time, end_time, ...placeData } = data - await tripStore.updatePlace(tripId, editingPlace.id, placeData) + await tripActions.updatePlace(tripId, editingPlace.id, placeData) // If editing from assignment context, save time per-assignment if (editingAssignmentId) { await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null }) - await tripStore.refreshDays(tripId) + await tripActions.refreshDays(tripId) } // Upload pending files with place_id if (pendingFiles?.length > 0) { @@ -203,23 +229,23 @@ export default function TripPlannerPage(): React.ReactElement | null { const fd = new FormData() fd.append('file', file) fd.append('place_id', editingPlace.id) - try { await tripStore.addFile(tripId, fd) } catch {} + try { await tripActions.addFile(tripId, fd) } catch {} } } toast.success(t('trip.toast.placeUpdated')) } else { - const place = await tripStore.addPlace(tripId, data) + const place = await tripActions.addPlace(tripId, data) if (pendingFiles?.length > 0 && place?.id) { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) fd.append('place_id', place.id) - try { await tripStore.addFile(tripId, fd) } catch {} + try { await tripActions.addFile(tripId, fd) } catch {} } } toast.success(t('trip.toast.placeAdded')) } - }, [editingPlace, editingAssignmentId, tripId, tripStore, toast]) + }, [editingPlace, editingAssignmentId, tripId, toast]) const handleDeletePlace = useCallback((placeId) => { setDeletePlaceId(placeId) @@ -228,34 +254,34 @@ export default function TripPlannerPage(): React.ReactElement | null { const confirmDeletePlace = useCallback(async () => { if (!deletePlaceId) return try { - await tripStore.deletePlace(tripId, deletePlaceId) + await tripActions.deletePlace(tripId, deletePlaceId) if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null) toast.success(t('trip.toast.placeDeleted')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId]) + }, [deletePlaceId, tripId, toast, selectedPlaceId]) const handleAssignToDay = useCallback(async (placeId, dayId, position) => { const target = dayId || selectedDayId if (!target) { toast.error(t('trip.toast.selectDay')); return } try { - await tripStore.assignPlaceToDay(tripId, target, placeId, position) + await tripActions.assignPlaceToDay(tripId, target, placeId, position) toast.success(t('trip.toast.assignedToDay')) updateRouteForDay(target) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [selectedDayId, tripId, tripStore, toast, updateRouteForDay]) + }, [selectedDayId, tripId, toast, updateRouteForDay]) const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => { try { - await tripStore.removeAssignment(tripId, dayId, assignmentId) + await tripActions.removeAssignment(tripId, dayId, assignmentId) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [tripId, tripStore, toast, updateRouteForDay]) + }, [tripId, toast, updateRouteForDay]) const handleReorder = useCallback((dayId, orderedIds) => { try { - tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {}) + tripActions.reorderAssignments(tripId, dayId, orderedIds).catch(() => {}) // Update route immediately from orderedIds - const dayItems = tripStore.assignments[String(dayId)] || [] + const dayItems = useTripStore.getState().assignments[String(dayId)] || [] const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean) const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng) if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng])) @@ -263,17 +289,17 @@ export default function TripPlannerPage(): React.ReactElement | null { setRouteInfo(null) } catch { toast.error(t('trip.toast.reorderError')) } - }, [tripId, tripStore, toast]) + }, [tripId, toast]) const handleUpdateDayTitle = useCallback(async (dayId, title) => { - try { await tripStore.updateDayTitle(tripId, dayId, title) } + try { await tripActions.updateDayTitle(tripId, dayId, title) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } - }, [tripId, tripStore, toast]) + }, [tripId, toast]) const handleSaveReservation = async (data) => { try { if (editingReservation) { - const r = await tripStore.updateReservation(tripId, editingReservation.id, data) + const r = await tripActions.updateReservation(tripId, editingReservation.id, data) toast.success(t('trip.toast.reservationUpdated')) setShowReservationModal(false) if (data.type === 'hotel') { @@ -281,7 +307,7 @@ export default function TripPlannerPage(): React.ReactElement | null { } return r } else { - const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null }) + const r = await tripActions.addReservation(tripId, { ...data, day_id: selectedDayId || null }) toast.success(t('trip.toast.reservationAdded')) setShowReservationModal(false) // Refresh accommodations if hotel was created @@ -295,7 +321,7 @@ export default function TripPlannerPage(): React.ReactElement | null { const handleDeleteReservation = async (id) => { try { - await tripStore.deleteReservation(tripId, id) + await tripActions.deleteReservation(tripId, id) toast.success(t('trip.toast.deleted')) // Refresh accommodations in case a hotel booking was deleted accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) @@ -332,12 +358,53 @@ export default function TripPlannerPage(): React.ReactElement | null { const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" } - if (isLoading) { + // Splash screen — show for initial load + a brief moment for photos to start loading + const [splashDone, setSplashDone] = useState(false) + useEffect(() => { + if (!isLoading && trip) { + const timer = setTimeout(() => setSplashDone(true), 1500) + return () => clearTimeout(timer) + } + }, [isLoading, trip]) + + if (isLoading || !splashDone) { return ( -
-
-
- {t('trip.loading')} +
+ +
+ + + +
+
+ {trip?.title || 'TREK'} +
+
+ {t('trip.loadingPhotos')} +
+
+ {[0, 1, 2].map(i => ( +
+ ))}
) @@ -452,7 +519,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} - onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }} + onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} @@ -587,7 +654,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onAssignToDay={handleAssignToDay} onRemoveAssignment={handleRemoveAssignment} files={files} - onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} + onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} tripMembers={tripMembers} onSetParticipants={async (assignmentId, dayId, userIds) => { try { @@ -602,7 +669,7 @@ export default function TripPlannerPage(): React.ReactElement | null { })) } catch {} }} - onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} + onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} leftWidth={leftCollapsed ? 0 : leftWidth} rightWidth={rightCollapsed ? 0 : rightWidth} /> @@ -636,7 +703,7 @@ export default function TripPlannerPage(): React.ReactElement | null { onAssignToDay={handleAssignToDay} onRemoveAssignment={handleRemoveAssignment} files={files} - onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} + onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} tripMembers={tripMembers} onSetParticipants={async (assignmentId, dayId, userIds) => { try { @@ -651,7 +718,7 @@ export default function TripPlannerPage(): React.ReactElement | null { })) } catch {} }} - onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} + onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} leftWidth={0} rightWidth={0} /> @@ -671,7 +738,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> }
@@ -714,9 +781,9 @@ export default function TripPlannerPage(): React.ReactElement | null {
tripStore.addFile(tripId, fd)} - onDelete={(id) => tripStore.deleteFile(tripId, id)} - onUpdate={(id, data) => tripStore.loadFiles(tripId)} + onUpload={(fd) => tripActions.addFile(tripId, fd)} + onDelete={(id) => tripActions.deleteFile(tripId, id)} + onUpdate={(id, data) => tripActions.loadFiles(tripId)} places={places} days={days} assignments={assignments} @@ -740,10 +807,10 @@ export default function TripPlannerPage(): React.ReactElement | null { )}
- { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} /> - setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> + { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} /> + setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> - { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripStore.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} accommodations={tripAccommodations} /> + { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} /> setDeletePlaceId(null)} diff --git a/client/src/services/photoService.ts b/client/src/services/photoService.ts new file mode 100644 index 00000000..62fbb8ea --- /dev/null +++ b/client/src/services/photoService.ts @@ -0,0 +1,128 @@ +import { mapsApi } from '../api/client' + +// Shared photo cache — used by PlaceAvatar (sidebar) and MapView (map markers) +interface PhotoEntry { + photoUrl: string | null + thumbDataUrl: string | null +} + +const cache = new Map() +const inFlight = new Set() +const listeners = new Map void>>() +// Separate thumb listeners — called when thumbDataUrl becomes available after initial load +const thumbListeners = new Map void>>() + +function notify(key: string, entry: PhotoEntry) { + listeners.get(key)?.forEach(fn => fn(entry)) + listeners.delete(key) +} + +function notifyThumb(key: string, thumb: string) { + thumbListeners.get(key)?.forEach(fn => fn(thumb)) + thumbListeners.delete(key) +} + +export function onPhotoLoaded(key: string, fn: (entry: PhotoEntry) => void): () => void { + if (!listeners.has(key)) listeners.set(key, new Set()) + listeners.get(key)!.add(fn) + return () => { listeners.get(key)?.delete(fn) } +} + +// Subscribe to thumb availability — called when base64 thumb is ready (may be after photoUrl) +export function onThumbReady(key: string, fn: (thumb: string) => void): () => void { + if (!thumbListeners.has(key)) thumbListeners.set(key, new Set()) + thumbListeners.get(key)!.add(fn) + return () => { thumbListeners.get(key)?.delete(fn) } +} + +export function getCached(key: string): PhotoEntry | undefined { + return cache.get(key) +} + +export function isLoading(key: string): boolean { + return inFlight.has(key) +} + +// Convert image URL to base64 via canvas (CORS required — Wikimedia supports it) +function urlToBase64(url: string, size: number = 48): Promise { + return new Promise(resolve => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => { + try { + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d')! + const s = Math.min(img.naturalWidth, img.naturalHeight) + const sx = (img.naturalWidth - s) / 2 + const sy = (img.naturalHeight - s) / 2 + ctx.beginPath() + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2) + ctx.clip() + ctx.drawImage(img, sx, sy, s, s, 0, 0, size, size) + resolve(canvas.toDataURL('image/webp', 0.6)) + } catch { resolve(null) } + } + img.onerror = () => resolve(null) + img.src = url + }) +} + +export function fetchPhoto( + cacheKey: string, + photoId: string, + lat?: number, + lng?: number, + name?: string, + callback?: (entry: PhotoEntry) => void +) { + const cached = cache.get(cacheKey) + if (cached) { callback?.(cached); return } + + if (inFlight.has(cacheKey)) { + if (callback) onPhotoLoaded(cacheKey, callback) + return + } + + inFlight.add(cacheKey) + mapsApi.placePhoto(photoId, lat, lng, name) + .then(async (data: { photoUrl?: string }) => { + const photoUrl = data.photoUrl || null + if (!photoUrl) { + const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null } + cache.set(cacheKey, entry) + callback?.(entry) + notify(cacheKey, entry) + return + } + + // Store URL first — sidebar can show immediately + const entry: PhotoEntry = { photoUrl, thumbDataUrl: null } + cache.set(cacheKey, entry) + callback?.(entry) + notify(cacheKey, entry) + + // Generate base64 thumb in background + const thumb = await urlToBase64(photoUrl) + if (thumb) { + entry.thumbDataUrl = thumb + notifyThumb(cacheKey, thumb) + } + }) + .catch(() => { + const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null } + cache.set(cacheKey, entry) + callback?.(entry) + notify(cacheKey, entry) + }) + .finally(() => { inFlight.delete(cacheKey) }) +} + +export function getAllThumbs(): Record { + const r: Record = {} + for (const [k, v] of cache.entries()) { + if (v.thumbDataUrl) r[k] = v.thumbDataUrl + } + return r +} diff --git a/client/src/store/slices/placesSlice.ts b/client/src/store/slices/placesSlice.ts index 834d0bd3..01cab189 100644 --- a/client/src/store/slices/placesSlice.ts +++ b/client/src/store/slices/placesSlice.ts @@ -37,15 +37,22 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => updatePlace: async (tripId, placeId, placeData) => { try { const data = await placesApi.update(tripId, placeId, placeData) - set(state => ({ - places: state.places.map(p => p.id === placeId ? data.place : p), - assignments: Object.fromEntries( - Object.entries(state.assignments).map(([dayId, items]) => [ - dayId, - items.map((a: Assignment) => a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a) - ]) - ), - })) + set(state => { + const updatedAssignments = { ...state.assignments } + let changed = false + for (const [dayId, items] of Object.entries(state.assignments)) { + if (items.some((a: Assignment) => a.place?.id === placeId)) { + updatedAssignments[dayId] = items.map((a: Assignment) => + a.place?.id === placeId ? { ...a, place: { ...data.place, place_time: a.place.place_time, end_time: a.place.end_time } } : a + ) + changed = true + } + } + return { + places: state.places.map(p => p.id === placeId ? data.place : p), + ...(changed ? { assignments: updatedAssignments } : {}), + } + }) return data.place } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error updating place')) @@ -55,15 +62,20 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => deletePlace: async (tripId, placeId) => { try { await placesApi.delete(tripId, placeId) - set(state => ({ - places: state.places.filter(p => p.id !== placeId), - assignments: Object.fromEntries( - Object.entries(state.assignments).map(([dayId, items]) => [ - dayId, - items.filter((a: Assignment) => a.place?.id !== placeId) - ]) - ), - })) + set(state => { + const updatedAssignments = { ...state.assignments } + let changed = false + for (const [dayId, items] of Object.entries(state.assignments)) { + if (items.some((a: Assignment) => a.place?.id === placeId)) { + updatedAssignments[dayId] = items.filter((a: Assignment) => a.place?.id !== placeId) + changed = true + } + } + return { + places: state.places.filter(p => p.id !== placeId), + ...(changed ? { assignments: updatedAssignments } : {}), + } + }) } catch (err: unknown) { throw new Error(getApiErrorMessage(err, 'Error deleting place')) } diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index ea707f1e..46ced7d9 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -154,7 +154,7 @@ async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Pro ggslimit: '5', prop: 'imageinfo', iiprop: 'url|extmetadata|mime', - iiurlwidth: '600', + iiurlwidth: '400', }); try { const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } }); @@ -380,11 +380,14 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp const { placeId } = req.params; const cached = photoCache.get(placeId); - if (cached && Date.now() - cached.fetchedAt < PHOTO_TTL) { - if (cached.error) { - return res.status(404).json({ error: `(Cache) No photo available` }); + const ERROR_TTL = 5 * 60 * 1000; // 5 min for errors + if (cached) { + const ttl = cached.error ? ERROR_TTL : PHOTO_TTL; + if (Date.now() - cached.fetchedAt < ttl) { + if (cached.error) return res.status(404).json({ error: `(Cache) No photo available` }); + return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); } - return res.json({ photoUrl: cached.photoUrl, attribution: cached.attribution }); + photoCache.delete(placeId); } // Wikimedia Commons fallback for OSM places (using lat/lng query params) @@ -436,7 +439,7 @@ router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Resp const attribution = photo.authorAttributions?.[0]?.displayName || null; const mediaRes = await fetch( - `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=600&skipHttpRedirect=true`, + `https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400&skipHttpRedirect=true`, { headers: { 'X-Goog-Api-Key': apiKey } } ); const mediaData = await mediaRes.json() as { photoUri?: string }; From ef9880a2a51575b4d9fd8d14ed6bbef1d91f36cb Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Apr 2026 15:21:20 +0200 Subject: [PATCH 7/9] feat: Immich album linking with auto-sync (#206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Link Immich albums to trips — photos sync automatically - Album picker shows all user's Immich albums - Linked albums displayed as chips with sync/unlink buttons - Auto-sync on link: fetches all album photos and adds to trip - Manual re-sync button for each linked album - DB migration: trip_album_links table fix: shared Immich photos visible to other trip members - Thumbnail/original proxy now uses photo owner's Immich credentials when userId query param is provided, fixing 404 for shared photos - i18n: album keys for all 12 languages --- .../src/components/Memories/MemoriesPanel.tsx | 174 +++++++++++++++++- client/src/i18n/translations/ar.ts | 6 + client/src/i18n/translations/br.ts | 6 + client/src/i18n/translations/cs.ts | 6 + client/src/i18n/translations/de.ts | 6 + client/src/i18n/translations/en.ts | 6 + client/src/i18n/translations/es.ts | 6 + client/src/i18n/translations/fr.ts | 6 + client/src/i18n/translations/hu.ts | 6 + client/src/i18n/translations/it.ts | 6 + client/src/i18n/translations/nl.ts | 6 + client/src/i18n/translations/ru.ts | 6 + client/src/i18n/translations/zh.ts | 6 + server/src/db/migrations.ts | 16 ++ server/src/routes/immich.ts | 116 +++++++++++- 15 files changed, 365 insertions(+), 13 deletions(-) diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 74a72a0b..2bc4f7d9 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react' -import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react' +import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen } from 'lucide-react' import apiClient from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' @@ -52,6 +52,59 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const [sortAsc, setSortAsc] = useState(true) const [locationFilter, setLocationFilter] = useState('') + // Album linking + const [showAlbumPicker, setShowAlbumPicker] = useState(false) + const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number }[]>([]) + const [albumsLoading, setAlbumsLoading] = useState(false) + const [albumLinks, setAlbumLinks] = useState<{ id: number; immich_album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) + const [syncing, setSyncing] = useState(null) + + const loadAlbumLinks = async () => { + try { + const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) + setAlbumLinks(res.data.links || []) + } catch { setAlbumLinks([]) } + } + + const openAlbumPicker = async () => { + setShowAlbumPicker(true) + setAlbumsLoading(true) + try { + const res = await apiClient.get('/integrations/immich/albums') + setAlbums(res.data.albums || []) + } catch { setAlbums([]) } + finally { setAlbumsLoading(false) } + } + + const linkAlbum = async (albumId: string, albumName: string) => { + try { + await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName }) + setShowAlbumPicker(false) + await loadAlbumLinks() + // Auto-sync after linking + const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) + const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId) + if (newLink) await syncAlbum(newLink.id) + } catch {} + } + + const unlinkAlbum = async (linkId: number) => { + try { + await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) + loadAlbumLinks() + } catch {} + } + + const syncAlbum = async (linkId: number) => { + setSyncing(linkId) + try { + await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`) + await loadAlbumLinks() + await loadPhotos() + } catch {} + finally { setSyncing(null) } + } + // Lightbox const [lightboxId, setLightboxId] = useState(null) const [lightboxUserId, setLightboxUserId] = useState(null) @@ -89,6 +142,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setConnected(false) } await loadPhotos() + await loadAlbumLinks() setLoading(false) } @@ -224,6 +278,72 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa // ── Photo Picker Modal ──────────────────────────────────────────────────── + // ── Album Picker Modal ────────────────────────────────────────────────── + + if (showAlbumPicker) { + const linkedIds = new Set(albumLinks.map(l => l.immich_album_id)) + return ( +
+
+
+

+ {t('memories.selectAlbum')} +

+ +
+
+
+ {albumsLoading ? ( +
+
+
+ ) : albums.length === 0 ? ( +

+ {t('memories.noAlbums')} +

+ ) : ( +
+ {albums.map(album => { + const isLinked = linkedIds.has(album.id) + return ( + + ) + })} +
+ )} +
+
+ ) + } + + // ── Photo Picker Modal ──────────────────────────────────────────────────── + if (showPicker) { const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id)) @@ -404,16 +524,52 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa

{connected && ( - +
+ + +
)}
+ + {/* Linked Albums */} + {albumLinks.length > 0 && ( +
+ {albumLinks.map(link => ( +
+ + {link.album_name} + {link.username !== currentUser?.username && ({link.username})} + + {link.user_id === currentUser?.id && ( + + )} +
+ ))} +
+ )}
{/* Filter & Sort bar */} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 1b08bf87..e0587a9f 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1350,6 +1350,12 @@ const ar: Record = { 'memories.newest': 'الأحدث أولاً', 'memories.allLocations': 'جميع المواقع', 'memories.addPhotos': 'إضافة صور', + 'memories.linkAlbum': 'ربط ألبوم', + 'memories.selectAlbum': 'اختيار ألبوم Immich', + 'memories.noAlbums': 'لم يتم العثور على ألبومات', + 'memories.syncAlbum': 'مزامنة الألبوم', + 'memories.unlinkAlbum': 'إلغاء الربط', + 'memories.photos': 'صور', 'memories.selectPhotos': 'اختيار صور من Immich', 'memories.selectHint': 'انقر على الصور لتحديدها.', 'memories.selected': 'محدد', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 054ae5f5..9ea1d247 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1398,6 +1398,12 @@ const br: Record = { 'memories.connectionError': 'Não foi possível conectar ao Immich', 'memories.saved': 'Configurações do Immich salvas', 'memories.addPhotos': 'Adicionar fotos', + 'memories.linkAlbum': 'Vincular álbum', + 'memories.selectAlbum': 'Selecionar álbum do Immich', + 'memories.noAlbums': 'Nenhum álbum encontrado', + 'memories.syncAlbum': 'Sincronizar álbum', + 'memories.unlinkAlbum': 'Desvincular', + 'memories.photos': 'fotos', 'memories.selectPhotos': 'Selecionar fotos do Immich', 'memories.selectHint': 'Toque nas fotos para selecioná-las.', 'memories.selected': 'selecionadas', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 26f0c338..09c625ba 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1347,6 +1347,12 @@ const cs: Record = { 'memories.connectionError': 'Nepodařilo se připojit k Immich', 'memories.saved': 'Nastavení Immich uloženo', 'memories.addPhotos': 'Přidat fotky', + 'memories.linkAlbum': 'Propojit album', + 'memories.selectAlbum': 'Vybrat album z Immich', + 'memories.noAlbums': 'Žádná alba nenalezena', + 'memories.syncAlbum': 'Synchronizovat album', + 'memories.unlinkAlbum': 'Odpojit', + 'memories.photos': 'fotek', 'memories.selectPhotos': 'Vybrat fotky z Immich', 'memories.selectHint': 'Klepněte na fotky pro jejich výběr.', 'memories.selected': 'vybráno', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 0c32841b..f7ba5da7 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1344,6 +1344,12 @@ const de: Record = { 'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen', 'memories.saved': 'Immich-Einstellungen gespeichert', 'memories.addPhotos': 'Fotos hinzufügen', + 'memories.linkAlbum': 'Album verknüpfen', + 'memories.selectAlbum': 'Immich-Album auswählen', + 'memories.noAlbums': 'Keine Alben gefunden', + 'memories.syncAlbum': 'Album synchronisieren', + 'memories.unlinkAlbum': 'Album trennen', + 'memories.photos': 'Fotos', 'memories.selectPhotos': 'Fotos aus Immich auswählen', 'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.', 'memories.selected': 'ausgewählt', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index d20130d4..3a6cef88 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1341,6 +1341,12 @@ const en: Record = { 'memories.connectionError': 'Could not connect to Immich', 'memories.saved': 'Immich settings saved', 'memories.addPhotos': 'Add photos', + 'memories.linkAlbum': 'Link Album', + 'memories.selectAlbum': 'Select Immich Album', + 'memories.noAlbums': 'No albums found', + 'memories.syncAlbum': 'Sync album', + 'memories.unlinkAlbum': 'Unlink album', + 'memories.photos': 'photos', 'memories.selectPhotos': 'Select photos from Immich', 'memories.selectHint': 'Tap photos to select them.', 'memories.selected': 'selected', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 9c0dedce..82d2bd6f 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1300,6 +1300,12 @@ const es: Record = { 'memories.newest': 'Más recientes', 'memories.allLocations': 'Todas las ubicaciones', 'memories.addPhotos': 'Añadir fotos', + 'memories.linkAlbum': 'Vincular álbum', + 'memories.selectAlbum': 'Seleccionar álbum de Immich', + 'memories.noAlbums': 'No se encontraron álbumes', + 'memories.syncAlbum': 'Sincronizar álbum', + 'memories.unlinkAlbum': 'Desvincular', + 'memories.photos': 'fotos', 'memories.selectPhotos': 'Seleccionar fotos de Immich', 'memories.selectHint': 'Toca las fotos para seleccionarlas.', 'memories.selected': 'seleccionado(s)', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index d0caa3af..2abd8435 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1346,6 +1346,12 @@ const fr: Record = { 'memories.newest': 'Plus récentes', 'memories.allLocations': 'Tous les lieux', 'memories.addPhotos': 'Ajouter des photos', + 'memories.linkAlbum': 'Lier un album', + 'memories.selectAlbum': 'Choisir un album Immich', + 'memories.noAlbums': 'Aucun album trouvé', + 'memories.syncAlbum': 'Synchroniser', + 'memories.unlinkAlbum': 'Délier', + 'memories.photos': 'photos', 'memories.selectPhotos': 'Sélectionner des photos depuis Immich', 'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.', 'memories.selected': 'sélectionné(s)', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 34589ce3..9f0c6080 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1414,6 +1414,12 @@ const hu: Record = { 'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez', 'memories.saved': 'Immich beállítások mentve', 'memories.addPhotos': 'Fotók hozzáadása', + 'memories.linkAlbum': 'Album csatolása', + 'memories.selectAlbum': 'Immich album kiválasztása', + 'memories.noAlbums': 'Nem található album', + 'memories.syncAlbum': 'Album szinkronizálása', + 'memories.unlinkAlbum': 'Leválasztás', + 'memories.photos': 'fotó', 'memories.selectPhotos': 'Fotók kiválasztása az Immichből', 'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.', 'memories.selected': 'kijelölve', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 377a2dd4..5c3ee54d 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1344,6 +1344,12 @@ const it: Record = { 'memories.connectionError': 'Impossibile connettersi a Immich', 'memories.saved': 'Impostazioni Immich salvate', 'memories.addPhotos': 'Aggiungi foto', + 'memories.linkAlbum': 'Collega album', + 'memories.selectAlbum': 'Seleziona album Immich', + 'memories.noAlbums': 'Nessun album trovato', + 'memories.syncAlbum': 'Sincronizza album', + 'memories.unlinkAlbum': 'Scollega', + 'memories.photos': 'foto', 'memories.selectPhotos': 'Seleziona foto da Immich', 'memories.selectHint': 'Tocca le foto per selezionarle.', 'memories.selected': 'selezionate', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index eb22e5b8..b47f1980 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1346,6 +1346,12 @@ const nl: Record = { 'memories.newest': 'Nieuwste eerst', 'memories.allLocations': 'Alle locaties', 'memories.addPhotos': 'Foto\'s toevoegen', + 'memories.linkAlbum': 'Album koppelen', + 'memories.selectAlbum': 'Immich-album selecteren', + 'memories.noAlbums': 'Geen albums gevonden', + 'memories.syncAlbum': 'Album synchroniseren', + 'memories.unlinkAlbum': 'Ontkoppelen', + 'memories.photos': 'fotos', 'memories.selectPhotos': 'Selecteer foto\'s uit Immich', 'memories.selectHint': 'Tik op foto\'s om ze te selecteren.', 'memories.selected': 'geselecteerd', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 03c843ae..9f741023 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1346,6 +1346,12 @@ const ru: Record = { 'memories.newest': 'Сначала новые', 'memories.allLocations': 'Все места', 'memories.addPhotos': 'Добавить фото', + 'memories.linkAlbum': 'Привязать альбом', + 'memories.selectAlbum': 'Выбрать альбом Immich', + 'memories.noAlbums': 'Альбомы не найдены', + 'memories.syncAlbum': 'Синхронизировать', + 'memories.unlinkAlbum': 'Отвязать', + 'memories.photos': 'фото', 'memories.selectPhotos': 'Выбрать фото из Immich', 'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.', 'memories.selected': 'выбрано', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 156dec53..b376baf1 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1346,6 +1346,12 @@ const zh: Record = { 'memories.newest': '最新优先', 'memories.allLocations': '所有地点', 'memories.addPhotos': '添加照片', + 'memories.linkAlbum': '关联相册', + 'memories.selectAlbum': '选择 Immich 相册', + 'memories.noAlbums': '未找到相册', + 'memories.syncAlbum': '同步相册', + 'memories.unlinkAlbum': '取消关联', + 'memories.photos': '张照片', 'memories.selectPhotos': '从 Immich 选择照片', 'memories.selectHint': '点击照片以选择。', 'memories.selected': '已选择', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index e6f29291..9a0106aa 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -439,6 +439,22 @@ function runMigrations(db: Database.Database): void { () => { try { db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); } catch {} }, + () => { + db.exec(` + CREATE TABLE IF NOT EXISTS trip_album_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_album_id) + ); + CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); + `); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index ef3891b5..8421df13 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -268,8 +268,9 @@ router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + // Use photo owner's Immich credentials if userId is provided (for shared photos) + const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); try { @@ -292,8 +293,9 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: const { assetId } = req.params; if (!isValidAssetId(assetId)) return res.status(400).send('Invalid asset ID'); - // Only allow accessing own Immich credentials — prevent leaking other users' API keys - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + // Use photo owner's Immich credentials if userId is provided (for shared photos) + const targetUserId = req.query.userId ? Number(req.query.userId) : authReq.user.id; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); try { @@ -311,4 +313,110 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: } }); +// ── Album Linking ────────────────────────────────────────────────────────── + +// List user's Immich albums +router.get('/albums', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + + try { + const resp = await fetch(`${user.immich_url}/api/albums`, { + headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' }); + const albums = (await resp.json() as any[]).map((a: any) => ({ + id: a.id, + albumName: a.albumName, + assetCount: a.assetCount || 0, + startDate: a.startDate, + endDate: a.endDate, + albumThumbnailAssetId: a.albumThumbnailAssetId, + })); + res.json({ albums }); + } catch { + res.status(502).json({ error: 'Could not reach Immich' }); + } +}); + +// Get album links for a trip +router.get('/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { + const links = db.prepare(` + SELECT tal.*, u.username + FROM trip_album_links tal + JOIN users u ON tal.user_id = u.id + WHERE tal.trip_id = ? + ORDER BY tal.created_at ASC + `).all(req.params.tripId); + res.json({ links }); +}); + +// Link an album to a trip +router.post('/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const { album_id, album_name } = req.body; + if (!album_id) return res.status(400).json({ error: 'album_id required' }); + + try { + db.prepare( + 'INSERT OR IGNORE INTO trip_album_links (trip_id, user_id, immich_album_id, album_name) VALUES (?, ?, ?, ?)' + ).run(tripId, authReq.user.id, album_id, album_name || ''); + res.json({ success: true }); + } catch (err: any) { + res.status(400).json({ error: 'Album already linked' }); + } +}); + +// Remove album link +router.delete('/trips/:tripId/album-links/:linkId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + db.prepare('DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .run(req.params.linkId, req.params.tripId, authReq.user.id); + res.json({ success: true }); +}); + +// Sync album — fetch all assets from Immich album and add missing ones to trip +router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId, linkId } = req.params; + + const link = db.prepare('SELECT * FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?') + .get(linkId, tripId, authReq.user.id) as any; + if (!link) return res.status(404).json({ error: 'Album link not found' }); + + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + + try { + const resp = await fetch(`${user.immich_url}/api/albums/${link.immich_album_id}`, { + headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' }); + const albumData = await resp.json() as { assets?: any[] }; + const assets = (albumData.assets || []).filter((a: any) => a.type === 'IMAGE'); + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, 1)' + ); + let added = 0; + for (const asset of assets) { + const r = insert.run(tripId, authReq.user.id, asset.id); + if (r.changes > 0) added++; + } + + db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId); + + res.json({ success: true, added, total: assets.length }); + if (added > 0) { + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); + } + } catch { + res.status(502).json({ error: 'Could not reach Immich' }); + } +}); + export default router; From ef5b381f8ea8da125f27eb0eb7cfb3d9edf38ba8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 1 Apr 2026 15:30:59 +0200 Subject: [PATCH 8/9] feat: collapse days hides map markers, Immich test-before-save (#216) Map markers: - Collapsing a day in the sidebar hides its places from the map - Places assigned to multiple days only hide when all days collapsed - Unplanned places always stay visible Immich settings: - New POST /integrations/immich/test endpoint validates credentials without saving them - Save button disabled until test connection passes - Changing URL or API key resets test status - i18n: testFirst key for all 12 languages --- .../src/components/Planner/DayPlanSidebar.tsx | 3 ++ client/src/i18n/translations/ar.ts | 1 + client/src/i18n/translations/br.ts | 1 + client/src/i18n/translations/cs.ts | 1 + client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + client/src/i18n/translations/es.ts | 1 + client/src/i18n/translations/fr.ts | 1 + client/src/i18n/translations/hu.ts | 1 + client/src/i18n/translations/it.ts | 1 + client/src/i18n/translations/nl.ts | 1 + client/src/i18n/translations/ru.ts | 1 + client/src/i18n/translations/zh.ts | 1 + client/src/pages/SettingsPage.tsx | 19 +++++++------ client/src/pages/TripPlannerPage.tsx | 28 +++++++++++++++++-- server/src/routes/immich.ts | 18 ++++++++++++ 16 files changed, 70 insertions(+), 10 deletions(-) diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 2c85a4f5..1b062c70 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -79,6 +79,7 @@ interface DayPlanSidebarProps { reservations?: Reservation[] onAddReservation: () => void onNavigateToFiles?: () => void + onExpandedDaysChange?: (expandedDayIds: Set) => void } const DayPlanSidebar = React.memo(function DayPlanSidebar({ @@ -91,6 +92,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ reservations = [], onAddReservation, onNavigateToFiles, + onExpandedDaysChange, }: DayPlanSidebarProps) { const toast = useToast() const { t, language, locale } = useTranslation() @@ -109,6 +111,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ } catch {} return new Set(days.map(d => d.id)) }) + useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays]) const [editingDayId, setEditingDayId] = useState(null) const [editTitle, setEditTitle] = useState('') const [isCalculating, setIsCalculating] = useState(false) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index e0587a9f..5cac9908 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1341,6 +1341,7 @@ const ar: Record = { 'memories.immichUrl': 'عنوان خادم Immich', 'memories.immichApiKey': 'مفتاح API', 'memories.testConnection': 'اختبار الاتصال', + 'memories.testFirst': 'اختبر الاتصال أولاً', 'memories.connected': 'متصل', 'memories.disconnected': 'غير متصل', 'memories.connectionSuccess': 'تم الاتصال بـ Immich', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 9ea1d247..32bed17d 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1392,6 +1392,7 @@ const br: Record = { 'memories.immichUrl': 'URL do servidor Immich', 'memories.immichApiKey': 'Chave da API', 'memories.testConnection': 'Testar conexão', + 'memories.testFirst': 'Teste a conexão primeiro', 'memories.connected': 'Conectado', 'memories.disconnected': 'Não conectado', 'memories.connectionSuccess': 'Conectado ao Immich', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 09c625ba..326ab007 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1341,6 +1341,7 @@ const cs: Record = { 'memories.immichUrl': 'URL serveru Immich', 'memories.immichApiKey': 'API klíč', 'memories.testConnection': 'Otestovat připojení', + 'memories.testFirst': 'Nejprve otestujte připojení', 'memories.connected': 'Připojeno', 'memories.disconnected': 'Nepřipojeno', 'memories.connectionSuccess': 'Připojeno k Immich', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index f7ba5da7..7010c61d 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1338,6 +1338,7 @@ const de: Record = { 'memories.immichUrl': 'Immich Server URL', 'memories.immichApiKey': 'API-Schlüssel', 'memories.testConnection': 'Verbindung testen', + 'memories.testFirst': 'Verbindung zuerst testen', 'memories.connected': 'Verbunden', 'memories.disconnected': 'Nicht verbunden', 'memories.connectionSuccess': 'Verbindung zu Immich hergestellt', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 3a6cef88..ef12cdad 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1335,6 +1335,7 @@ const en: Record = { 'memories.immichUrl': 'Immich Server URL', 'memories.immichApiKey': 'API Key', 'memories.testConnection': 'Test connection', + 'memories.testFirst': 'Test connection first', 'memories.connected': 'Connected', 'memories.disconnected': 'Not connected', 'memories.connectionSuccess': 'Connected to Immich', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 82d2bd6f..8c29be0e 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1291,6 +1291,7 @@ const es: Record = { 'memories.immichUrl': 'URL del servidor Immich', 'memories.immichApiKey': 'Clave API', 'memories.testConnection': 'Probar conexión', + 'memories.testFirst': 'Probar conexión primero', 'memories.connected': 'Conectado', 'memories.disconnected': 'No conectado', 'memories.connectionSuccess': 'Conectado a Immich', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 2abd8435..fc011a71 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1337,6 +1337,7 @@ const fr: Record = { 'memories.immichUrl': 'URL du serveur Immich', 'memories.immichApiKey': 'Clé API', 'memories.testConnection': 'Tester la connexion', + 'memories.testFirst': 'Testez la connexion avant de sauvegarder', 'memories.connected': 'Connecté', 'memories.disconnected': 'Non connecté', 'memories.connectionSuccess': 'Connecté à Immich', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 9f0c6080..c1c56301 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1408,6 +1408,7 @@ const hu: Record = { 'memories.immichUrl': 'Immich szerver URL', 'memories.immichApiKey': 'API kulcs', 'memories.testConnection': 'Kapcsolat tesztelése', + 'memories.testFirst': 'Először teszteld a kapcsolatot', 'memories.connected': 'Csatlakoztatva', 'memories.disconnected': 'Nincs csatlakoztatva', 'memories.connectionSuccess': 'Csatlakozva az Immichhez', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 5c3ee54d..0cb96ea1 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1338,6 +1338,7 @@ const it: Record = { 'memories.immichUrl': 'URL Server Immich', 'memories.immichApiKey': 'Chiave API', 'memories.testConnection': 'Test connessione', + 'memories.testFirst': 'Testa prima la connessione', 'memories.connected': 'Connesso', 'memories.disconnected': 'Non connesso', 'memories.connectionSuccess': 'Connesso a Immich', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index b47f1980..ee8b6ba7 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1337,6 +1337,7 @@ const nl: Record = { 'memories.immichUrl': 'Immich Server URL', 'memories.immichApiKey': 'API-sleutel', 'memories.testConnection': 'Verbinding testen', + 'memories.testFirst': 'Test eerst de verbinding', 'memories.connected': 'Verbonden', 'memories.disconnected': 'Niet verbonden', 'memories.connectionSuccess': 'Verbonden met Immich', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 9f741023..52647292 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1337,6 +1337,7 @@ const ru: Record = { 'memories.immichUrl': 'URL сервера Immich', 'memories.immichApiKey': 'API-ключ', 'memories.testConnection': 'Проверить подключение', + 'memories.testFirst': 'Сначала проверьте подключение', 'memories.connected': 'Подключено', 'memories.disconnected': 'Не подключено', 'memories.connectionSuccess': 'Подключение к Immich установлено', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index b376baf1..b78b1969 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1337,6 +1337,7 @@ const zh: Record = { 'memories.immichUrl': 'Immich 服务器地址', 'memories.immichApiKey': 'API 密钥', 'memories.testConnection': '测试连接', + 'memories.testFirst': '请先测试连接', 'memories.connected': '已连接', 'memories.disconnected': '未连接', 'memories.connectionSuccess': '已连接到 Immich', diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 36d29a16..94f46d92 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -142,14 +142,16 @@ export default function SettingsPage(): React.ReactElement { } }, [memoriesEnabled]) + const [immichTestPassed, setImmichTestPassed] = useState(false) + const handleSaveImmich = async () => { setSaving(s => ({ ...s, immich: true })) try { await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) toast.success(t('memories.saved')) - // Test connection const res = await apiClient.get('/integrations/immich/status') setImmichConnected(res.data.connected) + setImmichTestPassed(false) } catch { toast.error(t('memories.connectionError')) } finally { @@ -160,13 +162,13 @@ export default function SettingsPage(): React.ReactElement { const handleTestImmich = async () => { setImmichTesting(true) try { - const res = await apiClient.get('/integrations/immich/status') + const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey }) if (res.data.connected) { toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`) - setImmichConnected(true) + setImmichTestPassed(true) } else { toast.error(`${t('memories.connectionError')}: ${res.data.error}`) - setImmichConnected(false) + setImmichTestPassed(false) } } catch { toast.error(t('memories.connectionError')) @@ -676,19 +678,20 @@ export default function SettingsPage(): React.ReactElement {
- setImmichUrl(e.target.value)} + { setImmichUrl(e.target.value); setImmichTestPassed(false) }} placeholder="https://immich.example.com" className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
- setImmichApiKey(e.target.value)} + { setImmichApiKey(e.target.value); setImmichTestPassed(false) }} placeholder={immichConnected ? '••••••••' : 'API Key'} className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300" />
-