feat: collapse days hides map markers, Immich test-before-save (#216)
Map markers: - Collapsing a day in the sidebar hides its places from the map - Places assigned to multiple days only hide when all days collapsed - Unplanned places always stay visible Immich settings: - New POST /integrations/immich/test endpoint validates credentials without saving them - Save button disabled until test connection passes - Changing URL or API key resets test status - i18n: testFirst key for all 12 languages
This commit is contained in:
@@ -79,6 +79,7 @@ interface DayPlanSidebarProps {
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
onNavigateToFiles?: () => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
}
|
||||
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
@@ -91,6 +92,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
onNavigateToFiles,
|
||||
onExpandedDaysChange,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -109,6 +111,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
} catch {}
|
||||
return new Set(days.map(d => d.id))
|
||||
})
|
||||
useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays])
|
||||
const [editingDayId, setEditingDayId] = useState(null)
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
|
||||
@@ -1341,6 +1341,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'memories.immichUrl': 'عنوان خادم Immich',
|
||||
'memories.immichApiKey': 'مفتاح API',
|
||||
'memories.testConnection': 'اختبار الاتصال',
|
||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||
'memories.connected': 'متصل',
|
||||
'memories.disconnected': 'غير متصل',
|
||||
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
|
||||
|
||||
@@ -1392,6 +1392,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1341,6 +1341,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1338,6 +1338,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1335,6 +1335,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1291,6 +1291,7 @@ const es: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -1337,6 +1337,7 @@ const fr: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -1408,6 +1408,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1338,6 +1338,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1337,6 +1337,7 @@ const nl: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -1337,6 +1337,7 @@ const ru: Record<string, string> = {
|
||||
'memories.immichUrl': 'URL сервера Immich',
|
||||
'memories.immichApiKey': 'API-ключ',
|
||||
'memories.testConnection': 'Проверить подключение',
|
||||
'memories.testFirst': 'Сначала проверьте подключение',
|
||||
'memories.connected': 'Подключено',
|
||||
'memories.disconnected': 'Не подключено',
|
||||
'memories.connectionSuccess': 'Подключение к Immich установлено',
|
||||
|
||||
@@ -1337,6 +1337,7 @@ const zh: Record<string, string> = {
|
||||
'memories.immichUrl': 'Immich 服务器地址',
|
||||
'memories.immichApiKey': 'API 密钥',
|
||||
'memories.testConnection': '测试连接',
|
||||
'memories.testFirst': '请先测试连接',
|
||||
'memories.connected': '已连接',
|
||||
'memories.disconnected': '未连接',
|
||||
'memories.connectionSuccess': '已连接到 Immich',
|
||||
|
||||
@@ -142,14 +142,16 @@ export default function SettingsPage(): React.ReactElement {
|
||||
}
|
||||
}, [memoriesEnabled])
|
||||
|
||||
const [immichTestPassed, setImmichTestPassed] = useState(false)
|
||||
|
||||
const handleSaveImmich = async () => {
|
||||
setSaving(s => ({ ...s, immich: true }))
|
||||
try {
|
||||
await apiClient.put('/integrations/immich/settings', { immich_url: immichUrl, immich_api_key: immichApiKey || undefined })
|
||||
toast.success(t('memories.saved'))
|
||||
// Test connection
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
setImmichConnected(res.data.connected)
|
||||
setImmichTestPassed(false)
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
} finally {
|
||||
@@ -160,13 +162,13 @@ export default function SettingsPage(): React.ReactElement {
|
||||
const handleTestImmich = async () => {
|
||||
setImmichTesting(true)
|
||||
try {
|
||||
const res = await apiClient.get('/integrations/immich/status')
|
||||
const res = await apiClient.post('/integrations/immich/test', { immich_url: immichUrl, immich_api_key: immichApiKey })
|
||||
if (res.data.connected) {
|
||||
toast.success(`${t('memories.connectionSuccess')} — ${res.data.user?.name || ''}`)
|
||||
setImmichConnected(true)
|
||||
setImmichTestPassed(true)
|
||||
} else {
|
||||
toast.error(`${t('memories.connectionError')}: ${res.data.error}`)
|
||||
setImmichConnected(false)
|
||||
setImmichTestPassed(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('memories.connectionError'))
|
||||
@@ -676,19 +678,20 @@ export default function SettingsPage(): React.ReactElement {
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichUrl')}</label>
|
||||
<input type="url" value={immichUrl} onChange={e => setImmichUrl(e.target.value)}
|
||||
<input type="url" value={immichUrl} onChange={e => { 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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('memories.immichApiKey')}</label>
|
||||
<input type="password" value={immichApiKey} onChange={e => setImmichApiKey(e.target.value)}
|
||||
<input type="password" value={immichApiKey} onChange={e => { 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" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={handleSaveImmich} disabled={saving.immich}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400">
|
||||
<button onClick={handleSaveImmich} disabled={saving.immich || !immichTestPassed}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
||||
title={!immichTestPassed ? t('memories.testFirst') : ''}>
|
||||
<Save className="w-4 h-4" /> {t('common.save')}
|
||||
</button>
|
||||
<button onClick={handleTestImmich} disabled={immichTesting}
|
||||
|
||||
@@ -157,13 +157,36 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<string>('')
|
||||
|
||||
const [expandedDayIds, setExpandedDayIds] = useState<Set<number> | null>(null)
|
||||
|
||||
const mapPlaces = useMemo(() => {
|
||||
// Build set of place IDs assigned to collapsed days
|
||||
const hiddenPlaceIds = new Set<number>()
|
||||
if (expandedDayIds) {
|
||||
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
|
||||
if (!expandedDayIds.has(Number(dayId))) {
|
||||
for (const a of dayAssignments) {
|
||||
if (a.place?.id) hiddenPlaceIds.add(a.place.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Don't hide places that are also assigned to an expanded day
|
||||
for (const [dayId, dayAssignments] of Object.entries(assignments)) {
|
||||
if (expandedDayIds.has(Number(dayId))) {
|
||||
for (const a of dayAssignments) {
|
||||
hiddenPlaceIds.delete(a.place?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return places.filter(p => {
|
||||
if (!p.lat || !p.lng) return false
|
||||
if (mapCategoryFilter && String(p.category_id) !== String(mapCategoryFilter)) return false
|
||||
if (hiddenPlaceIds.has(p.id)) return false
|
||||
return true
|
||||
})
|
||||
}, [places, mapCategoryFilter])
|
||||
}, [places, mapCategoryFilter, assignments, expandedDayIds])
|
||||
|
||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId)
|
||||
|
||||
@@ -526,6 +549,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
accommodations={tripAccommodations}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
onExpandedDaysChange={setExpandedDayIds}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
@@ -738,7 +762,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -73,6 +73,24 @@ router.get('/status', authenticate, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Test connection with provided credentials (without saving)
|
||||
router.post('/test', authenticate, async (req: Request, res: Response) => {
|
||||
const { immich_url, immich_api_key } = req.body;
|
||||
if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' });
|
||||
if (!isValidImmichUrl(immich_url)) return res.json({ connected: false, error: 'Invalid Immich URL' });
|
||||
try {
|
||||
const resp = await fetch(`${immich_url}/api/users/me`, {
|
||||
headers: { 'x-api-key': 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) => {
|
||||
|
||||
Reference in New Issue
Block a user