diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx index 3b3af47..85a6f2a 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/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index e95d990..b5dba5a 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/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 2a89f6a..26b1117 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 ( @@ -496,12 +513,14 @@ export const MapView = memo(function MapView({ {markers} diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index 1db08e9..b45c82e 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' @@ -61,6 +61,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) @@ -99,6 +152,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setConnected(false) } await loadPhotos() + await loadAlbumLinks() setLoading(false) } @@ -229,6 +283,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)) @@ -409,16 +529,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/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index c1317ce..4e1840c 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,12 +92,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ reservations = [], onAddReservation, onNavigateToFiles, + onExpandedDaysChange, }: DayPlanSidebarProps) { const toast = useToast() 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) @@ -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) @@ -425,7 +428,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 +521,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 +656,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 +914,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 +932,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 +1031,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 +1039,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 +1124,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 +1218,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 +1291,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 +1299,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 +1364,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 +1619,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/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 17cbbe8..ef7db09 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/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx index ccbf089..e764d21 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/components/shared/PlaceAvatar.tsx b/client/src/components/shared/PlaceAvatar.tsx index ba682cf..43d8a4f 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 2cd5c0e..e78c3fb 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 8850c05..dde5c0d 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': 'تم تحديث المكان', @@ -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': 'الكل', @@ -931,6 +932,7 @@ const ar: Record = { // Budget 'budget.title': 'الميزانية', + 'budget.exportCsv': 'تصدير CSV', 'budget.emptyTitle': 'لم يتم إنشاء ميزانية بعد', 'budget.emptyText': 'أنشئ فئات وإدخالات لتخطيط ميزانية سفرك', 'budget.emptyPlaceholder': 'أدخل اسم الفئة...', @@ -945,6 +947,7 @@ const ar: Record = { 'budget.table.perDay': 'لكل يوم', 'budget.table.perPersonDay': 'لكل شخص / يوم', 'budget.table.note': 'ملاحظة', + 'budget.table.date': 'التاريخ', 'budget.newEntry': 'إدخال جديد', 'budget.defaultEntry': 'إدخال جديد', 'budget.defaultCategory': 'فئة جديدة', @@ -1338,6 +1341,7 @@ const ar: Record = { 'memories.immichUrl': 'عنوان خادم Immich', 'memories.immichApiKey': 'مفتاح API', 'memories.testConnection': 'اختبار الاتصال', + 'memories.testFirst': 'اختبر الاتصال أولاً', 'memories.connected': 'متصل', 'memories.disconnected': 'غير متصل', 'memories.connectionSuccess': 'تم الاتصال بـ Immich', @@ -1347,6 +1351,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 7494993..97c1c69 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', @@ -771,6 +773,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', @@ -910,6 +913,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 +928,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', @@ -1387,12 +1392,19 @@ 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', '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', @@ -1411,6 +1423,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 6c88b6d..e3d4cc6 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', @@ -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é', @@ -931,6 +932,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 +947,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', @@ -1338,12 +1341,19 @@ 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', '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 4706f50..8e6b449 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', @@ -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', @@ -928,6 +929,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 +944,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', @@ -1335,12 +1338,19 @@ 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', '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 e2d9f9e..632a8c3 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', @@ -786,6 +787,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', @@ -924,6 +926,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 +941,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', @@ -1331,12 +1335,19 @@ 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', '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 d68a70c..408fc3d 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', @@ -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', @@ -888,6 +889,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 +904,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', @@ -1288,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', @@ -1297,6 +1301,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 5b90879..5e31399 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', @@ -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', @@ -927,6 +928,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 +943,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', @@ -1334,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', @@ -1343,6 +1347,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 975db93..fc516d4 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', @@ -788,6 +790,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', @@ -926,6 +929,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 +944,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', @@ -1403,12 +1408,19 @@ 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', '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 94ef899..7309550 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', @@ -788,6 +790,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', @@ -926,6 +929,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 +944,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', @@ -1333,12 +1338,19 @@ 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', '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 cd88163..f7d2bb0 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', @@ -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', @@ -927,6 +928,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 +943,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', @@ -1334,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', @@ -1343,6 +1347,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 3e45fb4..876e9d1 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': 'Место обновлено', @@ -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': 'Все', @@ -927,6 +928,7 @@ const ru: Record = { // Budget 'budget.title': 'Бюджет', + 'budget.exportCsv': 'Экспорт CSV', 'budget.emptyTitle': 'Бюджет ещё не создан', 'budget.emptyText': 'Создайте категории и записи для планирования бюджета поездки', 'budget.emptyPlaceholder': 'Введите название категории...', @@ -941,6 +943,7 @@ const ru: Record = { 'budget.table.perDay': 'В день', 'budget.table.perPersonDay': 'Чел. / день', 'budget.table.note': 'Заметка', + 'budget.table.date': 'Дата', 'budget.newEntry': 'Новая запись', 'budget.defaultEntry': 'Новая запись', 'budget.defaultCategory': 'Новая категория', @@ -1334,6 +1337,7 @@ const ru: Record = { 'memories.immichUrl': 'URL сервера Immich', 'memories.immichApiKey': 'API-ключ', 'memories.testConnection': 'Проверить подключение', + 'memories.testFirst': 'Сначала проверьте подключение', 'memories.connected': 'Подключено', 'memories.disconnected': 'Не подключено', 'memories.connectionSuccess': 'Подключение к Immich установлено', @@ -1343,6 +1347,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 38d737c..92ac821 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': '地点已更新', @@ -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': '全部', @@ -927,6 +928,7 @@ const zh: Record = { // Budget 'budget.title': '预算', + 'budget.exportCsv': '导出 CSV', 'budget.emptyTitle': '尚未创建预算', 'budget.emptyText': '创建分类和条目来规划旅行预算', 'budget.emptyPlaceholder': '输入分类名称...', @@ -941,6 +943,7 @@ const zh: Record = { 'budget.table.perDay': '日均', 'budget.table.perPersonDay': '人日均', 'budget.table.note': '备注', + 'budget.table.date': '日期', 'budget.newEntry': '新建条目', 'budget.defaultEntry': '新建条目', 'budget.defaultCategory': '新分类', @@ -1334,6 +1337,7 @@ const zh: Record = { 'memories.immichUrl': 'Immich 服务器地址', 'memories.immichApiKey': 'API 密钥', 'memories.testConnection': '测试连接', + 'memories.testFirst': '请先测试连接', 'memories.connected': '已连接', 'memories.disconnected': '未连接', 'memories.connectionSuccess': '已连接到 Immich', @@ -1343,6 +1347,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/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index d5aad0e..54f7acf 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' @@ -59,15 +60,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') }, ] @@ -618,6 +619,8 @@ export default function AdminPage(): React.ReactElement {
)} + {activeTab === 'users' &&
} + {/* Create Invite Modal */} setShowCreateInvite(false)} title={t('admin.invite.create')} size="sm">
@@ -1112,7 +1115,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() @@ -1173,8 +1176,6 @@ export default function AdminPage(): React.ReactElement {
)} - {activeTab === 'permissions' && } - {activeTab === 'backup' && } {activeTab === 'audit' && } diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 70410d5..853b04f 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -142,15 +142,17 @@ export default function SettingsPage(): React.ReactElement { } }, [memoriesEnabled]) + const [immichTestPassed, setImmichTestPassed] = useState(false) + const handleSaveImmich = async () => { setSaving(s => ({ ...s, immich: true })) try { const saveRes = await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) if (saveRes.data.warning) toast.warn(saveRes.data.warning) 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 { @@ -161,13 +163,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')) @@ -677,19 +679,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" />
-