From 9f8075171d48ba192cb1fa1b5210dd364c935425 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 29 Mar 2026 20:12:47 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Immich=20photo=20integration=20?= =?UTF-8?q?=E2=80=94=20Photos=20addon=20with=20sharing,=20filters,=20light?= =?UTF-8?q?box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Immich connection per user (Settings → Immich URL + API Key) - Photos addon (admin-toggleable, trip tab) - Manual photo selection from Immich library (date filter + all photos) - Photo sharing with consent popup, per-photo privacy toggle - Lightbox with liquid glass EXIF info panel (camera, lens, location, settings) - Location filter + date sort in gallery - WebSocket live sync when photos are added/removed/shared - Proxy endpoints for thumbnails and originals with token auth --- client/src/components/Admin/AddonManager.tsx | 4 +- .../src/components/Memories/MemoriesPanel.tsx | 692 ++++++++++++++++++ client/src/i18n/translations/ar.ts | 30 +- client/src/i18n/translations/de.ts | 51 +- client/src/i18n/translations/en.ts | 51 +- client/src/i18n/translations/es.ts | 30 +- client/src/i18n/translations/fr.ts | 30 +- client/src/i18n/translations/nl.ts | 30 +- client/src/i18n/translations/ru.ts | 30 +- client/src/i18n/translations/zh.ts | 30 +- client/src/pages/SettingsPage.tsx | 93 +++ client/src/pages/TripPlannerPage.tsx | 10 +- client/src/store/slices/remoteEventHandler.ts | 5 + server/src/db/migrations.ts | 22 + server/src/index.ts | 2 + server/src/routes/immich.ts | 268 +++++++ 16 files changed, 1361 insertions(+), 17 deletions(-) create mode 100644 client/src/components/Memories/MemoriesPanel.tsx create mode 100644 server/src/routes/immich.ts diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 4cfb4bf..ad9e539 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -3,10 +3,10 @@ import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, } interface Addon { diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx new file mode 100644 index 0000000..74a72a0 --- /dev/null +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -0,0 +1,692 @@ +import { useState, useEffect, useCallback } from 'react' +import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react' +import apiClient from '../../api/client' +import { useAuthStore } from '../../store/authStore' +import { useTranslation } from '../../i18n' + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface TripPhoto { + immich_asset_id: string + user_id: number + username: string + shared: number + added_at: string +} + +interface ImmichAsset { + id: string + takenAt: string + city: string | null + country: string | null +} + +interface MemoriesPanelProps { + tripId: number + startDate: string | null + endDate: string | null +} + +// ── Main Component ────────────────────────────────────────────────────────── + +export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) { + const { t } = useTranslation() + const currentUser = useAuthStore(s => s.user) + + const [connected, setConnected] = useState(false) + const [loading, setLoading] = useState(true) + + // Trip photos (saved selections) + const [tripPhotos, setTripPhotos] = useState([]) + + // Photo picker + const [showPicker, setShowPicker] = useState(false) + const [pickerPhotos, setPickerPhotos] = useState([]) + const [pickerLoading, setPickerLoading] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + + // Confirm share popup + const [showConfirmShare, setShowConfirmShare] = useState(false) + + // Filters & sort + const [sortAsc, setSortAsc] = useState(true) + const [locationFilter, setLocationFilter] = useState('') + + // Lightbox + const [lightboxId, setLightboxId] = useState(null) + const [lightboxUserId, setLightboxUserId] = useState(null) + const [lightboxInfo, setLightboxInfo] = useState(null) + const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) + + // ── Init ────────────────────────────────────────────────────────────────── + + useEffect(() => { + loadInitial() + }, [tripId]) + + // WebSocket: reload photos when another user adds/removes/shares + useEffect(() => { + const handler = () => loadPhotos() + window.addEventListener('memories:updated', handler) + return () => window.removeEventListener('memories:updated', handler) + }, [tripId]) + + const loadPhotos = async () => { + try { + const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`) + setTripPhotos(photosRes.data.photos || []) + } catch { + setTripPhotos([]) + } + } + + const loadInitial = async () => { + setLoading(true) + try { + const statusRes = await apiClient.get('/integrations/immich/status') + setConnected(statusRes.data.connected) + } catch { + setConnected(false) + } + await loadPhotos() + setLoading(false) + } + + // ── Photo Picker ────────────────────────────────────────────────────────── + + const [pickerDateFilter, setPickerDateFilter] = useState(true) + + const openPicker = async () => { + setShowPicker(true) + setPickerLoading(true) + setSelectedIds(new Set()) + setPickerDateFilter(!!(startDate && endDate)) + await loadPickerPhotos(!!(startDate && endDate)) + } + + const loadPickerPhotos = async (useDate: boolean) => { + setPickerLoading(true) + try { + const res = await apiClient.post('/integrations/immich/search', { + from: useDate && startDate ? startDate : undefined, + to: useDate && endDate ? endDate : undefined, + }) + setPickerPhotos(res.data.assets || []) + } catch { + setPickerPhotos([]) + } finally { + setPickerLoading(false) + } + } + + const togglePickerSelect = (id: string) => { + setSelectedIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const confirmSelection = () => { + if (selectedIds.size === 0) return + setShowConfirmShare(true) + } + + const executeAddPhotos = async () => { + setShowConfirmShare(false) + try { + await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, { + asset_ids: [...selectedIds], + shared: true, + }) + setShowPicker(false) + loadInitial() + } catch {} + } + + // ── Remove photo ────────────────────────────────────────────────────────── + + const removePhoto = async (assetId: string) => { + try { + await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`) + setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId)) + } catch {} + } + + // ── Toggle sharing ──────────────────────────────────────────────────────── + + const toggleSharing = async (assetId: string, shared: boolean) => { + try { + await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared }) + setTripPhotos(prev => prev.map(p => + p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p + )) + } catch {} + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + const token = useAuthStore(s => s.token) + + const thumbnailUrl = (assetId: string, userId: number) => + `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}` + + const originalUrl = (assetId: string, userId: number) => + `/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}` + + const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) + const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) + const allVisibleRaw = [...ownPhotos, ...othersPhotos] + + // Unique locations for filter + const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort() + + // Apply filter + sort + const allVisible = allVisibleRaw + .filter(p => !locationFilter || p.city === locationFilter) + .sort((a, b) => { + const da = new Date(a.added_at || 0).getTime() + const db = new Date(b.added_at || 0).getTime() + return sortAsc ? da - db : db - da + }) + + const font: React.CSSProperties = { + fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", + } + + // ── Loading ─────────────────────────────────────────────────────────────── + + if (loading) { + return ( +
+
+
+ ) + } + + // ── Not connected ───────────────────────────────────────────────────────── + + if (!connected && allVisible.length === 0) { + return ( +
+ +

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

+

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

+
+ ) + } + + // ── Photo Picker Modal ──────────────────────────────────────────────────── + + if (showPicker) { + const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id)) + + return ( + <> +
+ {/* Picker header */} +
+
+

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

+
+ + +
+
+ {/* Filter tabs */} +
+ {startDate && endDate && ( + + )} + +
+ {selectedIds.size > 0 && ( +

+ {selectedIds.size} {t('memories.selected')} +

+ )} +
+ + {/* Picker grid */} +
+ {pickerLoading ? ( +
+
+
+ ) : pickerPhotos.length === 0 ? ( +
+ +

{t('memories.noPhotos')}

+
+ ) : (() => { + // Group photos by month + const byMonth: Record = {} + for (const asset of pickerPhotos) { + const d = asset.takenAt ? new Date(asset.takenAt) : null + const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown' + if (!byMonth[key]) byMonth[key] = [] + byMonth[key].push(asset) + } + const sortedMonths = Object.keys(byMonth).sort().reverse() + + return sortedMonths.map(month => ( +
+
+ {month !== 'unknown' + ? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) + : '—'} +
+
+ {byMonth[month].map(asset => { + const isSelected = selectedIds.has(asset.id) + const isAlready = alreadyAdded.has(asset.id) + return ( +
!isAlready && togglePickerSelect(asset.id)} + style={{ + position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden', + cursor: isAlready ? 'default' : 'pointer', + opacity: isAlready ? 0.3 : 1, + outline: isSelected ? '3px solid var(--text-primary)' : 'none', + outlineOffset: -3, + }}> + + {isSelected && ( +
+ +
+ )} + {isAlready && ( +
+ {t('memories.alreadyAdded')} +
+ )} +
+ ) + })} +
+
+ )) + })()} +
+
+ + {/* Confirm share popup (inside picker) */} + {showConfirmShare && ( +
setShowConfirmShare(false)} + style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}> +
e.stopPropagation()} + style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}> + +

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

+

+ {t('memories.confirmShareHint', { count: selectedIds.size })} +

+
+ + +
+
+
+ )} + + ) + } + + // ── Main Gallery ────────────────────────────────────────────────────────── + + return ( +
+ + {/* Header */} +
+
+
+

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

+

+ {allVisible.length} {t('memories.photosFound')} + {othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`} +

+
+ {connected && ( + + )} +
+
+ + {/* Filter & Sort bar */} + {allVisibleRaw.length > 0 && ( +
+ + {locations.length > 1 && ( + + )} +
+ )} + + {/* Gallery */} +
+ {allVisible.length === 0 ? ( +
+ +

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

+

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

+ +
+ ) : ( +
+ {allVisible.map(photo => { + const isOwn = photo.user_id === currentUser?.id + return ( +
{ + setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) + setLightboxInfoLoading(true) + apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`) + .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) + }}> + + + + {/* Other user's avatar */} + {!isOwn && ( +
+
+ {photo.username[0]} +
+
+ {photo.username} +
+
+ )} + + {/* Own photo actions (hover) */} + {isOwn && ( +
+ + +
+ )} + + {/* Not shared indicator */} + {isOwn && !photo.shared && ( +
+ + {t('memories.private')} +
+ )} +
+ ) + })} +
+ )} +
+ + + + {/* Confirm share popup */} + {showConfirmShare && ( +
setShowConfirmShare(false)} + style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}> +
e.stopPropagation()} + style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}> + +

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

+

+ {t('memories.confirmShareHint', { count: selectedIds.size })} +

+
+ + +
+
+
+ )} + + {/* Lightbox */} + {lightboxId && lightboxUserId && ( +
{ setLightboxId(null); setLightboxUserId(null) }} + style={{ + position: 'absolute', inset: 0, zIndex: 100, + background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', + }}> + +
e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> + + + {/* Info panel — liquid glass */} + {lightboxInfo && ( +
+ {/* Date */} + {lightboxInfo.takenAt && ( +
+
Date
+
{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}
+
{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
+
+ )} + + {/* Location */} + {(lightboxInfo.city || lightboxInfo.country) && ( +
+
+ Location +
+
+ {[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')} +
+
+ )} + + {/* Camera */} + {lightboxInfo.camera && ( +
+
Camera
+
{lightboxInfo.camera}
+ {lightboxInfo.lens &&
{lightboxInfo.lens}
} +
+ )} + + {/* Settings */} + {(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && ( +
+ {lightboxInfo.focalLength && ( +
+
Focal
+
{lightboxInfo.focalLength}
+
+ )} + {lightboxInfo.aperture && ( +
+
Aperture
+
{lightboxInfo.aperture}
+
+ )} + {lightboxInfo.shutter && ( +
+
Shutter
+
{lightboxInfo.shutter}
+
+ )} + {lightboxInfo.iso && ( +
+
ISO
+
{lightboxInfo.iso}
+
+ )} +
+ )} + + {/* Resolution & File */} + {(lightboxInfo.width || lightboxInfo.fileName) && ( +
+ {lightboxInfo.width && lightboxInfo.height && ( +
{lightboxInfo.width} × {lightboxInfo.height}
+ )} + {lightboxInfo.fileSize && ( +
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
+ )} +
+ )} +
+ )} + + {lightboxInfoLoading && ( +
+
+
+ )} +
+
+ )} +
+ ) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index d8da414..c216f35 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -380,8 +380,8 @@ const ar: Record = { // Addons 'admin.addons.title': 'الإضافات', 'admin.addons.subtitle': 'فعّل أو عطّل الميزات لتخصيص تجربة TREK.', - 'admin.addons.catalog.memories.name': 'الذكريات', - 'admin.addons.catalog.memories.description': 'ألبومات صور مشتركة لكل رحلة', + 'admin.addons.catalog.memories.name': 'صور (Immich)', + 'admin.addons.catalog.memories.description': 'شارك صور رحلتك عبر Immich', 'admin.addons.catalog.packing.name': 'التعبئة', 'admin.addons.catalog.packing.description': 'قوائم تحقق لإعداد أمتعتك لكل رحلة', 'admin.addons.catalog.budget.name': 'الميزانية', @@ -1118,6 +1118,32 @@ const ar: Record = { 'day.editAccommodation': 'تعديل الإقامة', 'day.reservations': 'الحجوزات', + // Memories / Immich + 'memories.title': 'صور', + 'memories.notConnected': 'Immich غير متصل', + 'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.', + 'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.', + 'memories.noPhotos': 'لم يتم العثور على صور', + 'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.', + 'memories.photosFound': 'صور', + 'memories.fromOthers': 'من آخرين', + 'memories.sharePhotos': 'مشاركة الصور', + 'memories.sharing': 'مشترك', + 'memories.reviewTitle': 'مراجعة صورك', + 'memories.reviewHint': 'انقر على الصور لاستبعادها من المشاركة.', + 'memories.shareCount': 'مشاركة {count} صور', + 'memories.immichUrl': 'عنوان خادم Immich', + 'memories.immichApiKey': 'مفتاح API', + 'memories.testConnection': 'اختبار الاتصال', + 'memories.connected': 'متصل', + 'memories.disconnected': 'غير متصل', + 'memories.connectionSuccess': 'تم الاتصال بـ Immich', + 'memories.connectionError': 'تعذر الاتصال بـ Immich', + 'memories.saved': 'تم حفظ إعدادات Immich', + 'memories.oldest': 'الأقدم أولاً', + 'memories.newest': 'الأحدث أولاً', + 'memories.allLocations': 'جميع المواقع', + // Collab Addon 'collab.tabs.chat': 'الدردشة', 'collab.tabs.notes': 'الملاحظات', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 00d164f..fdc437d 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -388,6 +388,8 @@ const de: Record = { 'admin.addons.catalog.atlas.description': 'Weltkarte mit besuchten Ländern und Reisestatistiken', 'admin.addons.catalog.collab.name': 'Collab', 'admin.addons.catalog.collab.description': 'Echtzeit-Notizen, Umfragen und Chat für die Reiseplanung', + 'admin.addons.catalog.memories.name': 'Fotos (Immich)', + 'admin.addons.catalog.memories.description': 'Reisefotos über deine Immich-Instanz teilen', 'admin.addons.subtitleBefore': 'Aktiviere oder deaktiviere Funktionen, um ', 'admin.addons.subtitleAfter': ' nach deinen Wünschen anzupassen.', 'admin.addons.enabled': 'Aktiviert', @@ -475,7 +477,15 @@ const de: Record = { 'vacay.remaining': 'Rest', 'vacay.carriedOver': 'aus {year}', 'vacay.blockWeekends': 'Wochenenden sperren', - 'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Samstagen und Sonntagen', + 'vacay.blockWeekendsHint': 'Verhindert Urlaubseinträge an Wochenendtagen', + 'vacay.weekendDays': 'Wochenendtage', + 'vacay.mon': 'Mo', + 'vacay.tue': 'Di', + 'vacay.wed': 'Mi', + 'vacay.thu': 'Do', + 'vacay.fri': 'Fr', + 'vacay.sat': 'Sa', + 'vacay.sun': 'So', 'vacay.publicHolidays': 'Feiertage', 'vacay.publicHolidaysHint': 'Feiertage im Kalender markieren', 'vacay.selectCountry': 'Land wählen', @@ -1112,6 +1122,45 @@ const de: Record = { 'day.editAccommodation': 'Unterkunft bearbeiten', 'day.reservations': 'Reservierungen', + // Photos / Immich + 'memories.title': 'Fotos', + 'memories.notConnected': 'Immich nicht verbunden', + 'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.', + 'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.', + 'memories.noPhotos': 'Keine Fotos gefunden', + 'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.', + 'memories.photosFound': 'Fotos', + 'memories.fromOthers': 'von anderen', + 'memories.sharePhotos': 'Fotos teilen', + 'memories.sharing': 'Wird geteilt', + 'memories.reviewTitle': 'Deine Fotos prüfen', + 'memories.reviewHint': 'Klicke auf Fotos, um sie vom Teilen auszuschließen.', + 'memories.shareCount': '{count} Fotos teilen', + 'memories.immichUrl': 'Immich Server URL', + 'memories.immichApiKey': 'API-Schlüssel', + 'memories.testConnection': 'Verbindung 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.selectPhotos': 'Fotos aus Immich auswählen', + 'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.', + 'memories.selected': 'ausgewählt', + 'memories.addSelected': '{count} Fotos hinzufügen', + 'memories.alreadyAdded': 'Hinzugefügt', + 'memories.private': 'Privat', + 'memories.stopSharing': 'Nicht mehr teilen', + 'memories.oldest': 'Älteste zuerst', + 'memories.newest': 'Neueste zuerst', + 'memories.allLocations': 'Alle Orte', + 'memories.tripDates': 'Trip-Zeitraum', + 'memories.allPhotos': 'Alle Fotos', + 'memories.confirmShareTitle': 'Mit Reisebegleitern teilen?', + 'memories.confirmShareHint': '{count} Fotos werden für alle Mitglieder dieses Trips sichtbar. Du kannst einzelne Fotos nachträglich auf privat setzen.', + 'memories.confirmShareButton': 'Fotos teilen', + // Collab Addon 'collab.tabs.chat': 'Chat', 'collab.tabs.notes': 'Notizen', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 5f4a977..4a89978 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -388,6 +388,8 @@ const en: Record = { 'admin.addons.catalog.atlas.description': 'World map with visited countries and travel stats', 'admin.addons.catalog.collab.name': 'Collab', 'admin.addons.catalog.collab.description': 'Real-time notes, polls, and chat for trip planning', + 'admin.addons.catalog.memories.name': 'Photos (Immich)', + 'admin.addons.catalog.memories.description': 'Share trip photos via your Immich instance', 'admin.addons.subtitleBefore': 'Enable or disable features to customize your ', 'admin.addons.subtitleAfter': ' experience.', 'admin.addons.enabled': 'Enabled', @@ -475,7 +477,15 @@ const en: Record = { 'vacay.remaining': 'Left', 'vacay.carriedOver': 'from {year}', 'vacay.blockWeekends': 'Block Weekends', - 'vacay.blockWeekendsHint': 'Prevent vacation entries on Saturdays and Sundays', + 'vacay.blockWeekendsHint': 'Prevent vacation entries on weekend days', + 'vacay.weekendDays': 'Weekend days', + 'vacay.mon': 'Mon', + 'vacay.tue': 'Tue', + 'vacay.wed': 'Wed', + 'vacay.thu': 'Thu', + 'vacay.fri': 'Fri', + 'vacay.sat': 'Sat', + 'vacay.sun': 'Sun', 'vacay.publicHolidays': 'Public Holidays', 'vacay.publicHolidaysHint': 'Mark public holidays in the calendar', 'vacay.selectCountry': 'Select country', @@ -1112,6 +1122,45 @@ const en: Record = { 'day.editAccommodation': 'Edit accommodation', 'day.reservations': 'Reservations', + // Photos / Immich + 'memories.title': 'Photos', + 'memories.notConnected': 'Immich not connected', + 'memories.notConnectedHint': 'Connect your Immich instance in Settings to see your trip photos here.', + 'memories.noDates': 'Add dates to your trip to load photos.', + 'memories.noPhotos': 'No photos found', + 'memories.noPhotosHint': 'No photos found in Immich for this trip\'s date range.', + 'memories.photosFound': 'photos', + 'memories.fromOthers': 'from others', + 'memories.sharePhotos': 'Share photos', + 'memories.sharing': 'Sharing', + 'memories.reviewTitle': 'Review your photos', + 'memories.reviewHint': 'Click photos to exclude them from sharing.', + 'memories.shareCount': 'Share {count} photos', + 'memories.immichUrl': 'Immich Server URL', + 'memories.immichApiKey': 'API Key', + 'memories.testConnection': 'Test connection', + '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.selectPhotos': 'Select photos from Immich', + 'memories.selectHint': 'Tap photos to select them.', + 'memories.selected': 'selected', + 'memories.addSelected': 'Add {count} photos', + 'memories.alreadyAdded': 'Added', + 'memories.private': 'Private', + 'memories.stopSharing': 'Stop sharing', + 'memories.oldest': 'Oldest first', + 'memories.newest': 'Newest first', + 'memories.allLocations': 'All locations', + 'memories.tripDates': 'Trip dates', + 'memories.allPhotos': 'All photos', + 'memories.confirmShareTitle': 'Share with trip members?', + 'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.', + 'memories.confirmShareButton': 'Share photos', + // Collab Addon 'collab.tabs.chat': 'Chat', 'collab.tabs.notes': 'Notes', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index cf913d5..e8448fc 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -945,8 +945,8 @@ const es: Record = { 'photos.linkPlace': 'Vincular lugar', 'photos.noPlace': 'Sin lugar', 'photos.uploadN': 'Subida de {n} foto(s)', - 'admin.addons.catalog.memories.name': 'Recuerdos', - 'admin.addons.catalog.memories.description': 'Álbumes de fotos compartidos para cada viaje', + 'admin.addons.catalog.memories.name': 'Fotos (Immich)', + 'admin.addons.catalog.memories.description': 'Comparte fotos de viaje a través de tu instancia de Immich', 'admin.addons.catalog.packing.name': 'Equipaje', 'admin.addons.catalog.packing.description': 'Prepara tu equipaje con listas de comprobación para cada viaje', 'admin.addons.catalog.budget.name': 'Presupuesto', @@ -1065,6 +1065,32 @@ const es: Record = { 'day.editAccommodation': 'Editar alojamiento', 'day.reservations': 'Reservas', + // Memories / Immich + 'memories.title': 'Fotos', + 'memories.notConnected': 'Immich no conectado', + 'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.', + 'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.', + 'memories.noPhotos': 'No se encontraron fotos', + 'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.', + 'memories.photosFound': 'fotos', + 'memories.fromOthers': 'de otros', + 'memories.sharePhotos': 'Compartir fotos', + 'memories.sharing': 'Compartiendo', + 'memories.reviewTitle': 'Revisar tus fotos', + 'memories.reviewHint': 'Haz clic en las fotos para excluirlas de compartir.', + 'memories.shareCount': 'Compartir {count} fotos', + 'memories.immichUrl': 'URL del servidor Immich', + 'memories.immichApiKey': 'Clave API', + 'memories.testConnection': 'Probar conexión', + 'memories.connected': 'Conectado', + 'memories.disconnected': 'No conectado', + 'memories.connectionSuccess': 'Conectado a Immich', + 'memories.connectionError': 'No se pudo conectar a Immich', + 'memories.saved': 'Configuración de Immich guardada', + 'memories.oldest': 'Más antiguas', + 'memories.newest': 'Más recientes', + 'memories.allLocations': 'Todas las ubicaciones', + // Collab Addon 'collab.tabs.chat': 'Mensajes', 'collab.tabs.notes': 'Notas', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 6b695c8..51e95cd 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -373,8 +373,8 @@ const fr: Record = { 'admin.tabs.addons': 'Extensions', 'admin.addons.title': 'Extensions', 'admin.addons.subtitle': 'Activez ou désactivez des fonctionnalités pour personnaliser votre expérience TREK.', - 'admin.addons.catalog.memories.name': 'Souvenirs', - 'admin.addons.catalog.memories.description': 'Albums photo partagés pour chaque voyage', + 'admin.addons.catalog.memories.name': 'Photos (Immich)', + 'admin.addons.catalog.memories.description': 'Partagez vos photos de voyage via votre instance Immich', 'admin.addons.catalog.packing.name': 'Bagages', 'admin.addons.catalog.packing.description': 'Listes de contrôle pour préparer vos bagages pour chaque voyage', 'admin.addons.catalog.budget.name': 'Budget', @@ -1111,6 +1111,32 @@ const fr: Record = { 'day.editAccommodation': 'Modifier l\'hébergement', 'day.reservations': 'Réservations', + // Memories / Immich + 'memories.title': 'Photos', + 'memories.notConnected': 'Immich non connecté', + 'memories.notConnectedHint': 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.', + 'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.', + 'memories.noPhotos': 'Aucune photo trouvée', + 'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.', + 'memories.photosFound': 'photos', + 'memories.fromOthers': 'd\'autres', + 'memories.sharePhotos': 'Partager les photos', + 'memories.sharing': 'Partagé', + 'memories.reviewTitle': 'Vérifier vos photos', + 'memories.reviewHint': 'Cliquez sur les photos pour les exclure du partage.', + 'memories.shareCount': 'Partager {count} photos', + 'memories.immichUrl': 'URL du serveur Immich', + 'memories.immichApiKey': 'Clé API', + 'memories.testConnection': 'Tester la connexion', + 'memories.connected': 'Connecté', + 'memories.disconnected': 'Non connecté', + 'memories.connectionSuccess': 'Connecté à Immich', + 'memories.connectionError': 'Impossible de se connecter à Immich', + 'memories.saved': 'Paramètres Immich enregistrés', + 'memories.oldest': 'Plus anciennes', + 'memories.newest': 'Plus récentes', + 'memories.allLocations': 'Tous les lieux', + // Collab Addon 'collab.tabs.chat': 'Chat', 'collab.tabs.notes': 'Notes', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index cc4042d..89407f7 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -373,8 +373,8 @@ const nl: Record = { 'admin.tabs.addons': 'Add-ons', 'admin.addons.title': 'Add-ons', 'admin.addons.subtitle': 'Schakel functies in of uit om je TREK-ervaring aan te passen.', - 'admin.addons.catalog.memories.name': 'Herinneringen', - 'admin.addons.catalog.memories.description': 'Gedeelde fotoalbums voor elke reis', + 'admin.addons.catalog.memories.name': 'Foto\'s (Immich)', + 'admin.addons.catalog.memories.description': 'Deel reisfoto\'s via je Immich-instantie', 'admin.addons.catalog.packing.name': 'Inpakken', 'admin.addons.catalog.packing.description': 'Checklists om je bagage voor elke reis voor te bereiden', 'admin.addons.catalog.budget.name': 'Budget', @@ -1111,6 +1111,32 @@ const nl: Record = { 'day.editAccommodation': 'Accommodatie bewerken', 'day.reservations': 'Reserveringen', + // Memories / Immich + 'memories.title': 'Foto\'s', + 'memories.notConnected': 'Immich niet verbonden', + 'memories.notConnectedHint': 'Verbind je Immich-instantie in Instellingen om je reisfoto\'s hier te zien.', + 'memories.noDates': 'Voeg data toe aan je reis om foto\'s te laden.', + 'memories.noPhotos': 'Geen foto\'s gevonden', + 'memories.noPhotosHint': 'Geen foto\'s gevonden in Immich voor de datumreeks van deze reis.', + 'memories.photosFound': 'foto\'s', + 'memories.fromOthers': 'van anderen', + 'memories.sharePhotos': 'Foto\'s delen', + 'memories.sharing': 'Wordt gedeeld', + 'memories.reviewTitle': 'Je foto\'s bekijken', + 'memories.reviewHint': 'Klik op foto\'s om ze uit te sluiten van delen.', + 'memories.shareCount': '{count} foto\'s delen', + 'memories.immichUrl': 'Immich Server URL', + 'memories.immichApiKey': 'API-sleutel', + 'memories.testConnection': 'Verbinding testen', + 'memories.connected': 'Verbonden', + 'memories.disconnected': 'Niet verbonden', + 'memories.connectionSuccess': 'Verbonden met Immich', + 'memories.connectionError': 'Kon niet verbinden met Immich', + 'memories.saved': 'Immich-instellingen opgeslagen', + 'memories.oldest': 'Oudste eerst', + 'memories.newest': 'Nieuwste eerst', + 'memories.allLocations': 'Alle locaties', + // Collab Addon 'collab.tabs.chat': 'Chat', 'collab.tabs.notes': 'Notities', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index ce89810..75d29ba 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -373,8 +373,8 @@ const ru: Record = { 'admin.tabs.addons': 'Дополнения', 'admin.addons.title': 'Дополнения', 'admin.addons.subtitle': 'Включайте или отключайте функции для настройки TREK под себя.', - 'admin.addons.catalog.memories.name': 'Воспоминания', - 'admin.addons.catalog.memories.description': 'Общие фотоальбомы для каждой поездки', + 'admin.addons.catalog.memories.name': 'Фото (Immich)', + 'admin.addons.catalog.memories.description': 'Делитесь фотографиями из поездок через Immich', 'admin.addons.catalog.packing.name': 'Сборы', 'admin.addons.catalog.packing.description': 'Чек-листы для подготовки багажа к каждой поездке', 'admin.addons.catalog.budget.name': 'Бюджет', @@ -1111,6 +1111,32 @@ const ru: Record = { 'day.editAccommodation': 'Редактировать жильё', 'day.reservations': 'Бронирования', + // Memories / Immich + 'memories.title': 'Фото', + 'memories.notConnected': 'Immich не подключён', + 'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.', + 'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.', + 'memories.noPhotos': 'Фотографии не найдены', + 'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.', + 'memories.photosFound': 'фото', + 'memories.fromOthers': 'от других', + 'memories.sharePhotos': 'Поделиться фото', + 'memories.sharing': 'Общий доступ', + 'memories.reviewTitle': 'Проверьте ваши фото', + 'memories.reviewHint': 'Нажмите на фото, чтобы исключить его из общего доступа.', + 'memories.shareCount': 'Поделиться ({count} фото)', + 'memories.immichUrl': 'URL сервера Immich', + 'memories.immichApiKey': 'API-ключ', + 'memories.testConnection': 'Проверить подключение', + 'memories.connected': 'Подключено', + 'memories.disconnected': 'Не подключено', + 'memories.connectionSuccess': 'Подключение к Immich установлено', + 'memories.connectionError': 'Не удалось подключиться к Immich', + 'memories.saved': 'Настройки Immich сохранены', + 'memories.oldest': 'Сначала старые', + 'memories.newest': 'Сначала новые', + 'memories.allLocations': 'Все места', + // Collab Addon 'collab.tabs.chat': 'Чат', 'collab.tabs.notes': 'Заметки', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index d5b006c..fbe1121 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -373,8 +373,8 @@ const zh: Record = { 'admin.tabs.addons': '扩展', 'admin.addons.title': '扩展', 'admin.addons.subtitle': '启用或禁用功能以自定义你的 TREK 体验。', - 'admin.addons.catalog.memories.name': '回忆', - 'admin.addons.catalog.memories.description': '每次旅行的共享相册', + 'admin.addons.catalog.memories.name': '照片 (Immich)', + 'admin.addons.catalog.memories.description': '通过 Immich 实例分享旅行照片', 'admin.addons.catalog.packing.name': '行李', 'admin.addons.catalog.packing.description': '每次旅行的行李准备清单', 'admin.addons.catalog.budget.name': '预算', @@ -1111,6 +1111,32 @@ const zh: Record = { 'day.editAccommodation': '编辑住宿', 'day.reservations': '预订', + // Memories / Immich + 'memories.title': '照片', + 'memories.notConnected': 'Immich 未连接', + 'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。', + 'memories.noDates': '为旅行添加日期以加载照片。', + 'memories.noPhotos': '未找到照片', + 'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。', + 'memories.photosFound': '张照片', + 'memories.fromOthers': '来自他人', + 'memories.sharePhotos': '分享照片', + 'memories.sharing': '分享中', + 'memories.reviewTitle': '审查您的照片', + 'memories.reviewHint': '点击照片以将其从分享中排除。', + 'memories.shareCount': '分享 {count} 张照片', + 'memories.immichUrl': 'Immich 服务器地址', + 'memories.immichApiKey': 'API 密钥', + 'memories.testConnection': '测试连接', + 'memories.connected': '已连接', + 'memories.disconnected': '未连接', + 'memories.connectionSuccess': '已连接到 Immich', + 'memories.connectionError': '无法连接到 Immich', + 'memories.saved': 'Immich 设置已保存', + 'memories.oldest': '最早优先', + 'memories.newest': '最新优先', + 'memories.allLocations': '所有地点', + // Collab Addon 'collab.tabs.chat': '聊天', 'collab.tabs.notes': '笔记', diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index cd6147c..7846f5e 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -8,6 +8,7 @@ import CustomSelect from '../components/shared/CustomSelect' import { useToast } from '../components/shared/Toast' import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound } from 'lucide-react' import { authApi, adminApi } from '../api/client' +import apiClient from '../api/client' import type { LucideIcon } from 'lucide-react' import type { UserWithOidc } from '../types' import { getApiErrorMessage } from '../types' @@ -56,6 +57,59 @@ export default function SettingsPage(): React.ReactElement { const [saving, setSaving] = useState>({}) + // Immich + const [memoriesEnabled, setMemoriesEnabled] = useState(false) + const [immichUrl, setImmichUrl] = useState('') + const [immichApiKey, setImmichApiKey] = useState('') + const [immichConnected, setImmichConnected] = useState(false) + const [immichTesting, setImmichTesting] = useState(false) + + useEffect(() => { + apiClient.get('/addons').then(r => { + const mem = r.data.addons?.find((a: any) => a.id === 'memories' && a.enabled) + setMemoriesEnabled(!!mem) + if (mem) { + apiClient.get('/integrations/immich/settings').then(r2 => { + setImmichUrl(r2.data.immich_url || '') + setImmichConnected(r2.data.connected) + }).catch(() => {}) + } + }).catch(() => {}) + }, []) + + const handleSaveImmich = async () => { + setSaving(s => ({ ...s, immich: true })) + try { + await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined }) + toast.success(t('memories.saved')) + // Test connection + const res = await apiClient.get('/integrations/immich/status') + setImmichConnected(res.data.connected) + } catch { + toast.error(t('memories.connectionError')) + } finally { + setSaving(s => ({ ...s, immich: false })) + } + } + + const handleTestImmich = async () => { + setImmichTesting(true) + try { + const res = await apiClient.get('/integrations/immich/status') + if (res.data.connected) { + toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`) + setImmichConnected(true) + } else { + toast.error(`${t('memories.connectionError')}: ${res.data.error}`) + setImmichConnected(false) + } + } catch { + toast.error(t('memories.connectionError')) + } finally { + setImmichTesting(false) + } + } + // Map settings const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '') const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566) @@ -387,6 +441,45 @@ export default function SettingsPage(): React.ReactElement {
+ {/* Immich — only when Memories addon is enabled */} + {memoriesEnabled && ( +
+
+
+ + setImmichUrl(e.target.value)} + 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)} + 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" /> +
+
+ + + {immichConnected && ( + + + {t('memories.connected')} + + )} +
+
+
+ )} + {/* Account */}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 778c7da..de659f8 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -12,6 +12,7 @@ import PlaceFormModal from '../components/Planner/PlaceFormModal' import TripFormModal from '../components/Trips/TripFormModal' import TripMembersModal from '../components/Trips/TripMembersModal' import { ReservationModal } from '../components/Planner/ReservationModal' +import MemoriesPanel from '../components/Memories/MemoriesPanel' import ReservationsPanel from '../components/Planner/ReservationsPanel' import PackingListPanel from '../components/Packing/PackingListPanel' import FileManager from '../components/Files/FileManager' @@ -54,7 +55,7 @@ export default function TripPlannerPage(): React.ReactElement | null { addonsApi.enabled().then(data => { const map = {} data.addons.forEach(a => { map[a.id] = true }) - setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab }) + setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, memories: !!map.memories }) }).catch(() => {}) authApi.getAppConfig().then(config => { if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) @@ -67,6 +68,7 @@ export default function TripPlannerPage(): React.ReactElement | null { ...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []), + ...(enabledAddons.memories ? [{ id: 'memories', label: t('memories.title') }] : []), ...(enabledAddons.collab ? [{ id: 'collab', label: t('admin.addons.catalog.collab.name') }] : []), ] @@ -656,6 +658,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
)} + {activeTab === 'memories' && ( +
+ +
+ )} + {activeTab === 'collab' && (
diff --git a/client/src/store/slices/remoteEventHandler.ts b/client/src/store/slices/remoteEventHandler.ts index 89e5e27..806b418 100644 --- a/client/src/store/slices/remoteEventHandler.ts +++ b/client/src/store/slices/remoteEventHandler.ts @@ -232,6 +232,11 @@ export function handleRemoteEvent(set: SetState, event: WebSocketEvent): void { files: state.files.filter(f => f.id !== payload.fileId), } + // Memories / Photos + case 'memories:updated': + window.dispatchEvent(new CustomEvent('memories:updated', { detail: payload })) + return {} + default: return {} } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 2ce013c..816288b 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -285,6 +285,28 @@ function runMigrations(db: Database.Database): void { created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`); }, + () => { + // Configurable weekend days + try { db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); } catch {} + }, + () => { + // Immich integration + try { db.exec("ALTER TABLE users ADD COLUMN immich_url TEXT"); } catch {} + try { db.exec("ALTER TABLE users ADD COLUMN immich_api_key TEXT"); } catch {} + db.exec(`CREATE TABLE IF NOT EXISTS trip_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_asset_id TEXT NOT NULL, + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_asset_id) + )`); + // Add memories addon + try { + db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)").run('memories', 'Photos', 'trip', 'Image', 0, 7); + } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/index.ts b/server/src/index.ts index db72af2..c53977f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -152,6 +152,8 @@ import vacayRoutes from './routes/vacay'; app.use('/api/addons/vacay', vacayRoutes); import atlasRoutes from './routes/atlas'; app.use('/api/addons/atlas', atlasRoutes); +import immichRoutes from './routes/immich'; +app.use('/api/integrations/immich', immichRoutes); app.use('/api/maps', mapsRoutes); app.use('/api/weather', weatherRoutes); diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts new file mode 100644 index 0000000..4efc5ce --- /dev/null +++ b/server/src/routes/immich.ts @@ -0,0 +1,268 @@ +import express, { Request, Response } from 'express'; +import { db } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { broadcast } from '../websocket'; +import { AuthRequest } from '../types'; + +const router = express.Router(); + +// ── Immich Connection Settings ────────────────────────────────────────────── + +router.get('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + res.json({ + immich_url: user?.immich_url || '', + connected: !!(user?.immich_url && user?.immich_api_key), + }); +}); + +router.put('/settings', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { immich_url, immich_api_key } = req.body; + db.prepare('UPDATE users SET immich_url = ?, immich_api_key = ? WHERE id = ?').run( + immich_url?.trim() || null, + immich_api_key?.trim() || null, + authReq.user.id + ); + res.json({ success: true }); +}); + +router.get('/status', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + if (!user?.immich_url || !user?.immich_api_key) { + return res.json({ connected: false, error: 'Not configured' }); + } + try { + const resp = await fetch(`${user.immich_url}/api/users/me`, { + headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return res.json({ connected: false, error: `HTTP ${resp.status}` }); + const data = await resp.json() as { name?: string; email?: string }; + res.json({ connected: true, user: { name: data.name, email: data.email } }); + } catch (err: unknown) { + res.json({ connected: false, error: err instanceof Error ? err.message : 'Connection failed' }); + } +}); + +// ── Browse Immich Library (for photo picker) ──────────────────────────────── + +router.get('/browse', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { page = '1', size = '50' } = req.query; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + + try { + const resp = await fetch(`${user.immich_url}/api/timeline/buckets`, { + method: 'GET', + headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch from Immich' }); + const buckets = await resp.json(); + res.json({ buckets }); + } catch (err: unknown) { + res.status(502).json({ error: 'Could not reach Immich' }); + } +}); + +// Search photos by date range (for the date-filter in picker) +router.post('/search', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { from, to } = req.body; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + + try { + const resp = await fetch(`${user.immich_url}/api/search/metadata`, { + method: 'POST', + headers: { 'x-api-key': user.immich_api_key, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + takenAfter: from ? `${from}T00:00:00.000Z` : undefined, + takenBefore: to ? `${to}T23:59:59.999Z` : undefined, + type: 'IMAGE', + size: 200, + }), + signal: AbortSignal.timeout(15000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Search failed' }); + const data = await resp.json() as { assets?: { items?: any[] } }; + const assets = (data.assets?.items || []).map((a: any) => ({ + id: a.id, + takenAt: a.fileCreatedAt || a.createdAt, + city: a.exifInfo?.city || null, + country: a.exifInfo?.country || null, + })); + res.json({ assets }); + } catch { + res.status(502).json({ error: 'Could not reach Immich' }); + } +}); + +// ── Trip Photos (selected by user) ────────────────────────────────────────── + +// Get all photos for a trip (own + shared by others) +router.get('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + + const photos = db.prepare(` + SELECT tp.immich_asset_id, tp.user_id, tp.shared, tp.added_at, + u.username, u.avatar, u.immich_url + FROM trip_photos tp + JOIN users u ON tp.user_id = u.id + WHERE tp.trip_id = ? + AND (tp.user_id = ? OR tp.shared = 1) + ORDER BY tp.added_at ASC + `).all(tripId, authReq.user.id); + + res.json({ photos }); +}); + +// Add photos to a trip +router.post('/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + const { asset_ids, shared = true } = req.body; + + if (!Array.isArray(asset_ids) || asset_ids.length === 0) { + return res.status(400).json({ error: 'asset_ids required' }); + } + + const insert = db.prepare( + 'INSERT OR IGNORE INTO trip_photos (trip_id, user_id, immich_asset_id, shared) VALUES (?, ?, ?, ?)' + ); + let added = 0; + for (const assetId of asset_ids) { + const result = insert.run(tripId, authReq.user.id, assetId, shared ? 1 : 0); + if (result.changes > 0) added++; + } + + res.json({ success: true, added }); + broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +// Remove a photo from a trip (own photos only) +router.delete('/trips/:tripId/photos/:assetId', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + db.prepare('DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') + .run(req.params.tripId, authReq.user.id, req.params.assetId); + res.json({ success: true }); + broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +// Toggle sharing for a specific photo +router.put('/trips/:tripId/photos/:assetId/sharing', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { shared } = req.body; + db.prepare('UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND immich_asset_id = ?') + .run(shared ? 1 : 0, req.params.tripId, authReq.user.id, req.params.assetId); + res.json({ success: true }); + broadcast(req.params.tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); +}); + +// ── Asset Details ─────────────────────────────────────────────────────────── + +router.get('/assets/:assetId/info', authenticate, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { assetId } = req.params; + const { userId } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(404).json({ error: 'Not found' }); + + try { + const resp = await fetch(`${user.immich_url}/api/assets/${assetId}`, { + headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return res.status(resp.status).json({ error: 'Failed' }); + const asset = await resp.json() as any; + res.json({ + id: asset.id, + takenAt: asset.fileCreatedAt || asset.createdAt, + width: asset.exifInfo?.exifImageWidth || null, + height: asset.exifInfo?.exifImageHeight || null, + camera: asset.exifInfo?.make && asset.exifInfo?.model ? `${asset.exifInfo.make} ${asset.exifInfo.model}` : null, + lens: asset.exifInfo?.lensModel || null, + focalLength: asset.exifInfo?.focalLength ? `${asset.exifInfo.focalLength}mm` : null, + aperture: asset.exifInfo?.fNumber ? `f/${asset.exifInfo.fNumber}` : null, + shutter: asset.exifInfo?.exposureTime || null, + iso: asset.exifInfo?.iso || null, + city: asset.exifInfo?.city || null, + state: asset.exifInfo?.state || null, + country: asset.exifInfo?.country || null, + lat: asset.exifInfo?.latitude || null, + lng: asset.exifInfo?.longitude || null, + fileSize: asset.exifInfo?.fileSizeInByte || null, + fileName: asset.originalFileName || null, + }); + } catch { + res.status(502).json({ error: 'Proxy error' }); + } +}); + +// ── Proxy Immich Assets ───────────────────────────────────────────────────── + +// Asset proxy routes accept token via query param (for src usage) +function authFromQuery(req: Request, res: Response, next: Function) { + const token = req.query.token as string; + if (token && !req.headers.authorization) { + req.headers.authorization = `Bearer ${token}`; + } + return (authenticate as any)(req, res, next); +} + +router.get('/assets/:assetId/thumbnail', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { assetId } = req.params; + const { userId } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); + + try { + const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/thumbnail`, { + headers: { 'x-api-key': user.immich_api_key }, + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) return res.status(resp.status).send('Failed'); + res.set('Content-Type', resp.headers.get('content-type') || 'image/webp'); + res.set('Cache-Control', 'public, max-age=86400'); + const buffer = Buffer.from(await resp.arrayBuffer()); + res.send(buffer); + } catch { + res.status(502).send('Proxy error'); + } +}); + +router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { assetId } = req.params; + const { userId } = req.query; + + const targetUserId = userId ? Number(userId) : authReq.user.id; + const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(targetUserId) as any; + if (!user?.immich_url || !user?.immich_api_key) return res.status(404).send('Not found'); + + try { + const resp = await fetch(`${user.immich_url}/api/assets/${assetId}/original`, { + headers: { 'x-api-key': user.immich_api_key }, + signal: AbortSignal.timeout(30000), + }); + if (!resp.ok) return res.status(resp.status).send('Failed'); + res.set('Content-Type', resp.headers.get('content-type') || 'image/jpeg'); + res.set('Cache-Control', 'public, max-age=86400'); + const buffer = Buffer.from(await resp.arrayBuffer()); + res.send(buffer); + } catch { + res.status(502).send('Proxy error'); + } +}); + +export default router;