From 5be2e9b26811013484a81e77797e4acb9790f220 Mon Sep 17 00:00:00 2001
From: Maurice
Date: Thu, 2 Apr 2026 17:19:24 +0200
Subject: [PATCH 01/17] add Discord community badge to README
---
README.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/README.md b/README.md
index 572d849..8248ee6 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@
+
From 8e9f8784dc67f6f5a795b26fe139dc1e96e8470c Mon Sep 17 00:00:00 2001
From: Marek Maslowski
Date: Fri, 3 Apr 2026 02:54:35 +0200
Subject: [PATCH 02/17] refactor(memories): generalize photo providers and
decouple from immich
---
client/src/api/authUrl.ts | 2 +-
client/src/components/Admin/AddonManager.tsx | 133 +++++++--
.../src/components/Memories/MemoriesPanel.tsx | 195 +++++++++---
client/src/pages/SettingsPage.tsx | 279 +++++++++++++-----
client/src/pages/TripPlannerPage.tsx | 4 +-
client/src/store/addonStore.ts | 16 +
server/src/db/migrations.ts | 114 +++++++
server/src/db/schema.ts | 25 ++
server/src/db/seeds.ts | 28 ++
server/src/index.ts | 69 ++++-
server/src/routes/admin.ts | 87 +++++-
server/src/routes/immich.ts | 168 ++++++-----
server/src/routes/memories.ts | 182 ++++++++++++
13 files changed, 1076 insertions(+), 226 deletions(-)
create mode 100644 server/src/routes/memories.ts
diff --git a/client/src/api/authUrl.ts b/client/src/api/authUrl.ts
index 203ceb3..ed92729 100644
--- a/client/src/api/authUrl.ts
+++ b/client/src/api/authUrl.ts
@@ -1,4 +1,4 @@
-export async function getAuthUrl(url: string, purpose: 'download' | 'immich'): Promise {
+export async function getAuthUrl(url: string, purpose: string): Promise {
if (!url) return url
try {
const resp = await fetch('/api/auth/resource-token', {
diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx
index 3050258..b45f9f0 100644
--- a/client/src/components/Admin/AddonManager.tsx
+++ b/client/src/components/Admin/AddonManager.tsx
@@ -15,7 +15,17 @@ interface Addon {
name: string
description: string
icon: string
+ type: string
enabled: boolean
+ config?: Record
+}
+
+interface ProviderOption {
+ key: string
+ label: string
+ description: string
+ enabled: boolean
+ toggle: () => Promise
}
interface AddonIconProps {
@@ -34,7 +44,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
const toast = useToast()
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
- const [addons, setAddons] = useState([])
+ const [addons, setAddons] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -53,7 +63,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
}
}
- const handleToggle = async (addon) => {
+ const handleToggle = async (addon: Addon) => {
const newEnabled = !addon.enabled
// Optimistic update
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
@@ -68,9 +78,44 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
}
}
+ const isPhotoProviderAddon = (addon: Addon) => {
+ return addon.type === 'photo_provider'
+ }
+
+ const isPhotosAddon = (addon: Addon) => {
+ const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
+ return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
+ }
+
+ const handleTogglePhotoProvider = async (providerAddon: Addon) => {
+ const enableProvider = !providerAddon.enabled
+ const prev = addons
+
+ setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
+
+ try {
+ await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
+ refreshGlobalAddons()
+ toast.success(t('admin.addons.toast.updated'))
+ } catch {
+ setAddons(prev)
+ toast.error(t('admin.addons.toast.error'))
+ }
+ }
+
const tripAddons = addons.filter(a => a.type === 'trip')
const globalAddons = addons.filter(a => a.type === 'global')
+ const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const integrationAddons = addons.filter(a => a.type === 'integration')
+ const photosAddon = tripAddons.find(isPhotosAddon)
+ const providerOptions: ProviderOption[] = photoProviderAddons.map((provider) => ({
+ key: provider.id,
+ label: provider.name,
+ description: provider.description,
+ enabled: provider.enabled,
+ toggle: () => handleTogglePhotoProvider(provider),
+ }))
+ const photosDerivedEnabled = providerOptions.some(p => p.enabled)
if (loading) {
return (
@@ -108,7 +153,42 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
{tripAddons.map(addon => (
-
+
+ {photosAddon && addon.id === photosAddon.id && providerOptions.length > 0 && (
+
+
+ {providerOptions.map(provider => (
+
+
+
{provider.label}
+
{provider.description}
+
+
+
+ {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
+
+
+
+
+ ))}
+
+
+ )}
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
@@ -171,8 +251,10 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking }
interface AddonRowProps {
addon: Addon
- onToggle: (addonId: string) => void
+ onToggle: (addon: Addon) => void
t: (key: string) => string
+ statusOverride?: boolean
+ hideToggle?: boolean
}
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
@@ -187,9 +269,12 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
}
}
-function AddonRow({ addon, onToggle, t }: AddonRowProps) {
+function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
const isComingSoon = false
const label = getAddonLabel(t, addon)
+ const displayName = nameOverride || label.name
+ const displayDescription = descriptionOverride || label.description
+ const enabledState = statusOverride ?? addon.enabled
return (
{/* Icon */}
@@ -200,7 +285,7 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{/* Info */}
- {label.name}
+ {displayName}
{isComingSoon && (
Coming Soon
@@ -210,28 +295,30 @@ function AddonRow({ addon, onToggle, t }: AddonRowProps) {
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
-
{label.description}
+
{displayDescription}
{/* Toggle */}
-
- {isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
+
+ {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
-
+ {!hideToggle && (
+
+ )}
)
diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx
index 9dd1ed4..b288487 100644
--- a/client/src/components/Memories/MemoriesPanel.tsx
+++ b/client/src/components/Memories/MemoriesPanel.tsx
@@ -1,27 +1,36 @@
import { useState, useEffect, useCallback } from '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 apiClient, { addonsApi } from '../../api/client'
import { useAuthStore } from '../../store/authStore'
import { useTranslation } from '../../i18n'
import { getAuthUrl } from '../../api/authUrl'
import { useToast } from '../shared/Toast'
-function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
+interface PhotoProvider {
+ id: string
+ name: string
+ icon?: string
+ config?: Record
+}
+
+function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
const [src, setSrc] = useState('')
useEffect(() => {
- getAuthUrl(baseUrl, 'immich').then(setSrc)
- }, [baseUrl])
+ getAuthUrl(baseUrl, provider).then(setSrc).catch(() => {})
+ }, [baseUrl, provider])
return src ?
: null
}
// ── Types ───────────────────────────────────────────────────────────────────
interface TripPhoto {
- immich_asset_id: string
+ asset_id: string
+ provider: string
user_id: number
username: string
shared: number
added_at: string
+ city?: string | null
}
interface ImmichAsset {
@@ -45,6 +54,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const currentUser = useAuthStore(s => s.user)
const [connected, setConnected] = useState(false)
+ const [availableProviders, setAvailableProviders] = useState([])
+ const [selectedProvider, setSelectedProvider] = useState('')
const [loading, setLoading] = useState(true)
// Trip photos (saved selections)
@@ -67,49 +78,61 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
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 [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; 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 pickerIntegrationBase = selectedProvider ? `/integrations/${selectedProvider}` : ''
const loadAlbumLinks = async () => {
try {
- const res = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
+ const res = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`)
setAlbumLinks(res.data.links || [])
} catch { setAlbumLinks([]) }
}
- const openAlbumPicker = async () => {
- setShowAlbumPicker(true)
+ const loadAlbums = async (provider: string = selectedProvider) => {
+ if (!provider) return
setAlbumsLoading(true)
try {
- const res = await apiClient.get('/integrations/immich/albums')
+ const res = await apiClient.get(`/integrations/${provider}/albums`)
setAlbums(res.data.albums || [])
- } catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) }
- finally { setAlbumsLoading(false) }
+ } catch {
+ setAlbums([])
+ toast.error(t('memories.error.loadAlbums'))
+ } finally {
+ setAlbumsLoading(false)
+ }
+ }
+
+ const openAlbumPicker = async () => {
+ setShowAlbumPicker(true)
+ await loadAlbums(selectedProvider)
}
const linkAlbum = async (albumId: string, albumName: string) => {
try {
- await apiClient.post(`/integrations/immich/trips/${tripId}/album-links`, { album_id: albumId, album_name: albumName })
+ await apiClient.post(`${pickerIntegrationBase}/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)
+ const linksRes = await apiClient.get(`/integrations/memories/trips/${tripId}/album-links`)
+ const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider)
if (newLink) await syncAlbum(newLink.id)
} catch { toast.error(t('memories.error.linkAlbum')) }
}
const unlinkAlbum = async (linkId: number) => {
try {
- await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
+ await apiClient.delete(`/integrations/memories/trips/${tripId}/album-links/${linkId}`)
loadAlbumLinks()
} catch { toast.error(t('memories.error.unlinkAlbum')) }
}
- const syncAlbum = async (linkId: number) => {
+ const syncAlbum = async (linkId: number, provider?: string) => {
+ const targetProvider = provider || selectedProvider
+ if (!targetProvider) return
setSyncing(linkId)
try {
- await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
+ await apiClient.post(`/integrations/${targetProvider}/trips/${tripId}/album-links/${linkId}/sync`)
await loadAlbumLinks()
await loadPhotos()
} catch { toast.error(t('memories.error.syncAlbum')) }
@@ -138,7 +161,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const loadPhotos = async () => {
try {
- const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
+ const photosRes = await apiClient.get(`/integrations/memories/trips/${tripId}/photos`)
setTripPhotos(photosRes.data.photos || [])
} catch {
setTripPhotos([])
@@ -148,9 +171,35 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const loadInitial = async () => {
setLoading(true)
try {
- const statusRes = await apiClient.get('/integrations/immich/status')
- setConnected(statusRes.data.connected)
+ const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] }))
+ const enabledAddons = addonsRes?.addons || []
+ const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled)
+
+ // Test connection status for each enabled provider
+ const statusResults = await Promise.all(
+ photoProviders.map(async (provider: any) => {
+ const statusUrl = (provider.config as Record)?.status_get as string
+ if (!statusUrl) return { provider, connected: false }
+ try {
+ const res = await apiClient.get(statusUrl)
+ return { provider, connected: !!res.data?.connected }
+ } catch {
+ return { provider, connected: false }
+ }
+ })
+ )
+
+ const connectedProviders = statusResults
+ .filter(r => r.connected)
+ .map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config }))
+
+ setAvailableProviders(connectedProviders)
+ setConnected(connectedProviders.length > 0)
+ if (connectedProviders.length > 0 && !selectedProvider) {
+ setSelectedProvider(connectedProviders[0].id)
+ }
} catch {
+ setAvailableProviders([])
setConnected(false)
}
await loadPhotos()
@@ -170,10 +219,26 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
await loadPickerPhotos(!!(startDate && endDate))
}
+ useEffect(() => {
+ if (showPicker) {
+ loadPickerPhotos(pickerDateFilter)
+ }
+ }, [selectedProvider])
+
+ useEffect(() => {
+ loadAlbumLinks()
+ }, [tripId])
+
+ useEffect(() => {
+ if (showAlbumPicker) {
+ loadAlbums(selectedProvider)
+ }
+ }, [showAlbumPicker, selectedProvider, tripId])
+
const loadPickerPhotos = async (useDate: boolean) => {
setPickerLoading(true)
try {
- const res = await apiClient.post('/integrations/immich/search', {
+ const res = await apiClient.post(`${pickerIntegrationBase}/search`, {
from: useDate && startDate ? startDate : undefined,
to: useDate && endDate ? endDate : undefined,
})
@@ -203,7 +268,8 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
const executeAddPhotos = async () => {
setShowConfirmShare(false)
try {
- await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
+ await apiClient.post(`/integrations/memories/trips/${tripId}/photos`, {
+ provider: selectedProvider,
asset_ids: [...selectedIds],
shared: true,
})
@@ -214,28 +280,37 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── Remove photo ──────────────────────────────────────────────────────────
- const removePhoto = async (assetId: string) => {
+ const removePhoto = async (photo: TripPhoto) => {
try {
- await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
- setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
+ await apiClient.delete(`/integrations/memories/trips/${tripId}/photos`, {
+ data: {
+ asset_id: photo.asset_id,
+ provider: photo.provider,
+ },
+ })
+ setTripPhotos(prev => prev.filter(p => !(p.provider === photo.provider && p.asset_id === photo.asset_id)))
} catch { toast.error(t('memories.error.removePhoto')) }
}
// ── Toggle sharing ────────────────────────────────────────────────────────
- const toggleSharing = async (assetId: string, shared: boolean) => {
+ const toggleSharing = async (photo: TripPhoto, shared: boolean) => {
try {
- await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
+ await apiClient.put(`/integrations/memories/trips/${tripId}/photos/sharing`, {
+ shared,
+ asset_id: photo.asset_id,
+ provider: photo.provider,
+ })
setTripPhotos(prev => prev.map(p =>
- p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
+ p.provider === photo.provider && p.asset_id === photo.asset_id ? { ...p, shared: shared ? 1 : 0 } : p
))
} catch { toast.error(t('memories.error.toggleSharing')) }
}
// ── Helpers ───────────────────────────────────────────────────────────────
- const thumbnailBaseUrl = (assetId: string, userId: number) =>
- `/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}`
+ const thumbnailBaseUrl = (photo: TripPhoto) =>
+ `/api/integrations/${photo.provider}/assets/${photo.asset_id}/thumbnail?userId=${photo.user_id}`
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
@@ -286,10 +361,40 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
// ── Photo Picker Modal ────────────────────────────────────────────────────
+ const ProviderTabs = () => {
+ if (availableProviders.length < 2) return null
+ return (
+
+ {availableProviders.map(provider => (
+
+ ))}
+
+ )
+ }
+
// ── Album Picker Modal ──────────────────────────────────────────────────
if (showAlbumPicker) {
- const linkedIds = new Set(albumLinks.map(l => l.immich_album_id))
+ const linkedIds = new Set(albumLinks.map(l => l.album_id))
return (
@@ -297,6 +402,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
{t('memories.selectAlbum')}
+