import { useEffect, useState } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react' const ICON_MAP = { ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, } function ImmichIcon({ size = 14 }: { size?: number }) { return ( ) } function SynologyIcon({ size = 14 }: { size?: number }) { return ( ) } const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, } interface Addon { id: string 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 { name: string size?: number } function AddonIcon({ name, size = 20 }: AddonIconProps) { const Icon = ICON_MAP[name] || Puzzle return } interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean } const COLLAB_SUB_FEATURES = [ { key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' }, { key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' }, { key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' }, { key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' }, ] as const export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) { const { t } = useTranslation() const dm = useSettingsStore(s => s.settings.dark_mode) 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 [loading, setLoading] = useState(true) useEffect(() => { loadAddons() }, []) const loadAddons = async () => { setLoading(true) try { const data = await adminApi.addons() setAddons(data.addons) } catch (err: unknown) { toast.error(t('admin.addons.toast.error')) } finally { setLoading(false) } } const handleToggle = async (addon: Addon) => { const newEnabled = !addon.enabled // Optimistic update setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) try { await adminApi.updateAddon(addon.id, { enabled: newEnabled }) refreshGlobalAddons() toast.success(t('admin.addons.toast.updated')) } catch (err: unknown) { // Rollback setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) toast.error(t('admin.addons.toast.error')) } } 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 photoProviderAddons = addons.filter(isPhotoProviderAddon) const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon) const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') 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 ( ) } return ( {/* Header */} {t('admin.addons.title')} {t('admin.addons.subtitleBefore')}{t('admin.addons.subtitleAfter')} {addons.length === 0 ? ( {t('admin.addons.noAddons')} ) : ( {/* Trip Addons */} {tripAddons.length > 0 && ( {t('admin.addons.type.trip')} — {t('admin.addons.tripHint')} {tripAddons.map(addon => ( {addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( {t('admin.bagTracking.title')} {t('admin.bagTracking.subtitle')} {bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} )} {addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && ( {COLLAB_SUB_FEATURES.map(feat => { const enabled = collabFeatures[feat.key] const Icon = feat.icon return ( {t(feat.titleKey)} {t(feat.subtitleKey)} {enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} onToggleCollabFeature(feat.key)} className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors" style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}> ) })} )} ))} )} {/* Global Addons */} {globalAddons.length > 0 && ( {t('admin.addons.type.global')} — {t('admin.addons.globalHint')} {globalAddons.map(addon => ( {/* Memories providers as sub-items under Journey addon */} {addon.id === 'journey' && providerOptions.length > 0 && ( {providerOptions.map(provider => { const ProviderIcon = PROVIDER_ICONS[provider.key] return ( {ProviderIcon && } {provider.label} {provider.description} {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} ) })} )} ))} )} {/* Integration Addons */} {integrationAddons.length > 0 && ( {t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')} {integrationAddons.map(addon => ( ))} )} )} ) } interface AddonRowProps { addon: Addon onToggle: (addon: Addon) => void t: (key: string) => string statusOverride?: boolean hideToggle?: boolean } function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { const nameKey = `admin.addons.catalog.${addon.id}.name` const descKey = `admin.addons.catalog.${addon.id}.description` const translatedName = t(nameKey) const translatedDescription = t(descKey) return { name: translatedName !== nameKey ? translatedName : addon.name, description: translatedDescription !== descKey ? translatedDescription : addon.description, } } 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 */} {/* Info */} {displayName} {isComingSoon && ( Coming Soon )} {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')} {displayDescription} {/* Toggle */} {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} {!hideToggle && ( !isComingSoon && onToggle(addon)} disabled={isComingSoon} className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors" style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }} > )} ) }
{t('admin.addons.subtitleBefore')}{t('admin.addons.subtitleAfter')}
{displayDescription}