@@ -564,14 +610,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')} |
|
@@ -623,6 +670,15 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro
{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 (
-
+
 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" />
- |