Spaces:
Sleeping
Sleeping
| import React, { useEffect, useMemo, useState } from 'react' | |
| import { Camera, Save } from 'lucide-react' | |
| import { useTranslation } from '../../i18n' | |
| import { useToast } from '../../components/shared/Toast' | |
| import apiClient from '../../api/client' | |
| import { useAddonStore } from '../../store/addonStore' | |
| import Section from './Section' | |
| import ToggleSwitch from './ToggleSwitch' | |
| interface ProviderField { | |
| key: string | |
| label: string | |
| input_type: string | |
| placeholder?: string | null | |
| hint?: string | null | |
| required: boolean | |
| secret: boolean | |
| settings_key?: string | null | |
| payload_key?: string | null | |
| sort_order: number | |
| } | |
| interface PhotoProviderAddon { | |
| id: string | |
| name: string | |
| type: string | |
| enabled: boolean | |
| config?: Record<string, unknown> | |
| fields?: ProviderField[] | |
| } | |
| interface ProviderConfig { | |
| settings_get?: string | |
| settings_put?: string | |
| status_get?: string | |
| test_get?: string | |
| test_post?: string | |
| } | |
| const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => { | |
| const raw = provider.config || {} | |
| return { | |
| settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined, | |
| settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined, | |
| status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined, | |
| test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined, | |
| test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined, | |
| } | |
| } | |
| const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { | |
| return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order) | |
| } | |
| export default function PhotoProvidersSection(): React.ReactElement { | |
| const { t } = useTranslation() | |
| const toast = useToast() | |
| const { isEnabled: addonEnabled, addons } = useAddonStore() | |
| const memoriesEnabled = addonEnabled('memories') | |
| const [saving, setSaving] = useState<Record<string, boolean>>({}) | |
| const [providerValues, setProviderValues] = useState<Record<string, Record<string, string>>>({}) | |
| const [providerConnected, setProviderConnected] = useState<Record<string, boolean>>({}) | |
| const [providerTesting, setProviderTesting] = useState<Record<string, boolean>>({}) | |
| const activePhotoProviders = useMemo( | |
| () => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], | |
| [addons], | |
| ) | |
| const buildProviderPayload = (provider: PhotoProviderAddon): Record<string, unknown> => { | |
| const values = providerValues[provider.id] || {} | |
| const payload: Record<string, unknown> = {} | |
| for (const field of getProviderFields(provider)) { | |
| const payloadKey = field.payload_key || field.settings_key || field.key | |
| if (field.input_type === 'checkbox') { | |
| payload[payloadKey] = values[field.key] === 'true' | |
| continue | |
| } | |
| const value = (values[field.key] || '').trim() | |
| if (field.secret && !value) continue | |
| payload[payloadKey] = value | |
| } | |
| return payload | |
| } | |
| const refreshProviderConnection = async (provider: PhotoProviderAddon) => { | |
| const cfg = getProviderConfig(provider) | |
| const statusPath = cfg.status_get | |
| if (!statusPath) return | |
| try { | |
| const res = await apiClient.get(statusPath) | |
| setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected })) | |
| } catch { | |
| setProviderConnected(prev => ({ ...prev, [provider.id]: false })) | |
| } | |
| } | |
| const activeProviderSignature = useMemo( | |
| () => activePhotoProviders.map(provider => provider.id).join('|'), | |
| [activePhotoProviders], | |
| ) | |
| useEffect(() => { | |
| let isCancelled = false | |
| for (const provider of activePhotoProviders) { | |
| const cfg = getProviderConfig(provider) | |
| const fields = getProviderFields(provider) | |
| // Seed checkbox defaults before the async settings load resolves | |
| const checkboxDefaults: Record<string, string> = {} | |
| for (const field of fields) { | |
| if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false' | |
| } | |
| if (Object.keys(checkboxDefaults).length > 0) { | |
| setProviderValues(prev => ({ | |
| ...prev, | |
| [provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) }, | |
| })) | |
| } | |
| if (cfg.settings_get) { | |
| apiClient.get(cfg.settings_get).then(res => { | |
| if (isCancelled) return | |
| const nextValues: Record<string, string> = {} | |
| for (const field of fields) { | |
| // Do not prefill secret fields; user can overwrite only when needed. | |
| if (field.secret) continue | |
| const sourceKey = field.settings_key || field.payload_key || field.key | |
| const rawValue = (res.data as Record<string, unknown>)[sourceKey] | |
| if (rawValue != null) { | |
| nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue) | |
| } else if (field.input_type === 'checkbox') { | |
| nextValues[field.key] = 'false' | |
| } else { | |
| nextValues[field.key] = '' | |
| } | |
| } | |
| setProviderValues(prev => ({ | |
| ...prev, | |
| [provider.id]: { ...(prev[provider.id] || {}), ...nextValues }, | |
| })) | |
| if (typeof res.data?.connected === 'boolean') { | |
| setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected })) | |
| } | |
| }).catch(() => { }) | |
| } | |
| refreshProviderConnection(provider).catch(() => { }) | |
| } | |
| return () => { | |
| isCancelled = true | |
| } | |
| }, [activePhotoProviders, activeProviderSignature]) | |
| const handleProviderFieldChange = (providerId: string, key: string, value: string) => { | |
| setProviderValues(prev => ({ | |
| ...prev, | |
| [providerId]: { ...(prev[providerId] || {}), [key]: value }, | |
| })) | |
| } | |
| const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => { | |
| const values = providerValues[provider.id] || {} | |
| return getProviderFields(provider).some(field => { | |
| if (!field.required) return false | |
| return !(values[field.key] || '').trim() | |
| }) | |
| } | |
| const handleSaveProvider = async (provider: PhotoProviderAddon) => { | |
| const cfg = getProviderConfig(provider) | |
| if (!cfg.settings_put) return | |
| setSaving(s => ({ ...s, [provider.id]: true })) | |
| try { | |
| await apiClient.put(cfg.settings_put, buildProviderPayload(provider)) | |
| await refreshProviderConnection(provider) | |
| toast.success(t('memories.saved', { provider_name: provider.name })) | |
| } catch { | |
| toast.error(t('memories.saveError', { provider_name: provider.name })) | |
| } finally { | |
| setSaving(s => ({ ...s, [provider.id]: false })) | |
| } | |
| } | |
| const handleTestProvider = async (provider: PhotoProviderAddon) => { | |
| const cfg = getProviderConfig(provider) | |
| const testPath = cfg.test_post || cfg.test_get || cfg.status_get | |
| if (!testPath) return | |
| setProviderTesting(prev => ({ ...prev, [provider.id]: true })) | |
| try { | |
| const payload = buildProviderPayload(provider) | |
| const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath) | |
| const ok = !!res.data?.connected | |
| setProviderConnected(prev => ({ ...prev, [provider.id]: ok })) | |
| if (ok) { | |
| toast.success(t('memories.connectionSuccess', { provider_name: provider.name })) | |
| } else { | |
| toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`) | |
| } | |
| } catch { | |
| toast.error(t('memories.connectionError', { provider_name: provider.name })) | |
| } finally { | |
| setProviderTesting(prev => ({ ...prev, [provider.id]: false })) | |
| } | |
| } | |
| const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { | |
| const fields = getProviderFields(provider) | |
| const cfg = getProviderConfig(provider) | |
| const values = providerValues[provider.id] || {} | |
| const connected = !!providerConnected[provider.id] | |
| const testing = !!providerTesting[provider.id] | |
| const canSave = !!cfg.settings_put | |
| const canTest = !!(cfg.test_post || cfg.test_get || cfg.status_get) | |
| return ( | |
| <Section key={provider.id} title={provider.name || provider.id} icon={Camera}> | |
| <div className="space-y-3"> | |
| {fields.map(field => ( | |
| <div key={`${provider.id}-${field.key}`}> | |
| {field.input_type === 'checkbox' ? ( | |
| <div className="flex items-center gap-3"> | |
| <ToggleSwitch | |
| on={values[field.key] === 'true'} | |
| onToggle={() => handleProviderFieldChange(provider.id, field.key, values[field.key] === 'true' ? 'false' : 'true')} | |
| /> | |
| <span className="text-sm font-medium text-slate-700">{t(`memories.${field.label}`)}</span> | |
| </div> | |
| ) : ( | |
| <> | |
| <label className="block text-sm font-medium text-slate-700 mb-1.5">{t(`memories.${field.label}`)}</label> | |
| <input | |
| type={field.input_type || 'text'} | |
| value={values[field.key] || ''} | |
| onChange={e => handleProviderFieldChange(provider.id, field.key, e.target.value)} | |
| placeholder={field.secret && connected && !(values[field.key] || '') ? '••••••••' : (field.placeholder || '')} | |
| 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" | |
| /> | |
| {field.hint && ( | |
| <p className="mt-1 text-xs text-slate-500">{t(`memories.${field.hint}`)}</p> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| ))} | |
| {/* Wraps on mobile so the connection badge drops to its own row | |
| instead of clipping off the side of the card. */} | |
| <div className="flex flex-wrap items-center gap-3"> | |
| <button | |
| onClick={() => handleSaveProvider(provider)} | |
| disabled={!canSave || !!saving[provider.id] || isProviderSaveDisabled(provider)} | |
| 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={!canSave ? t('memories.saveRouteNotConfigured') : isProviderSaveDisabled(provider) ? t('memories.fillRequiredFields') : ''} | |
| > | |
| <Save className="w-4 h-4" /> {t('common.save')} | |
| </button> | |
| <button | |
| onClick={() => handleTestProvider(provider)} | |
| disabled={!canTest || testing} | |
| className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50" | |
| title={!canTest ? t('memories.testRouteNotConfigured') : ''} | |
| > | |
| {testing | |
| ? <div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" /> | |
| : <Camera className="w-4 h-4" />} | |
| <span className="sm:hidden">{t('memories.testShort')}</span> | |
| <span className="hidden sm:inline">{t('memories.testConnection')}</span> | |
| </button> | |
| {/* On mobile the badge sits on its own row thanks to flex-wrap, so force a line break via basis-full. */} | |
| {connected ? ( | |
| <span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1"> | |
| <span className="w-2 h-2 bg-green-500 rounded-full" /> | |
| {t('memories.connected')} | |
| </span> | |
| ) : ( | |
| <span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1"> | |
| <span className="w-2 h-2 bg-slate-300 rounded-full" /> | |
| {t('memories.disconnected')} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| </Section> | |
| ) | |
| } | |
| if (!memoriesEnabled) { | |
| return <></> | |
| } | |
| return <>{activePhotoProviders.map(provider => renderPhotoProviderSection(provider))}</> | |
| } | |