From 2acfc9cb43588d603e42e99caeb0b729087fbe23 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:14:10 +0200 Subject: [PATCH] refactor(memories): share the photo provider connection cards between desktop and phone The provider field model, the seeding and hydration, save and test now live in one usePhotoProviderConnections hook. Two existing differences stay as options: on the phone the status route alone sets the badge, and the desktop keeps its space before the server reason in a failed test toast. --- .../Settings/PhotoProvidersSection.tsx | 224 ++-------------- .../usePhotoProviderConnections.test.ts | 233 +++++++++++++++++ .../Settings/usePhotoProviderConnections.ts | 244 ++++++++++++++++++ .../settings/MPhotoProvidersSection.tsx | 224 ++-------------- 4 files changed, 515 insertions(+), 410 deletions(-) create mode 100644 client/src/components/Settings/usePhotoProviderConnections.test.ts create mode 100644 client/src/components/Settings/usePhotoProviderConnections.ts diff --git a/client/src/components/Settings/PhotoProvidersSection.tsx b/client/src/components/Settings/PhotoProvidersSection.tsx index 0aa8ceaed..4c58bd7e8 100644 --- a/client/src/components/Settings/PhotoProvidersSection.tsx +++ b/client/src/components/Settings/PhotoProvidersSection.tsx @@ -1,219 +1,33 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React from 'react' import { Camera, Loader2, Plug, 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' import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, StatusPill } from './settingsKit' import { EditorField, INPUT } from '../shared/dialogParts' import { fs } from '../shared/DialogShell' import { Tooltip } from '../shared/Tooltip' - -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 - 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) -} +import { + getProviderConfig, + getProviderFields, + usePhotoProviderConnections, + type PhotoProviderAddon, +} from './usePhotoProviderConnections' 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>({}) - const [providerValues, setProviderValues] = useState>>({}) - const [providerConnected, setProviderConnected] = useState>({}) - const [providerTesting, setProviderTesting] = useState>({}) - - const activePhotoProviders = useMemo( - () => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], - [addons], - ) - - const buildProviderPayload = (provider: PhotoProviderAddon): Record => { - const values = providerValues[provider.id] || {} - const payload: Record = {} - 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 = {} - 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 = {} - 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)[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 { - // Only a POST probe carries the form values. A provider that declares just a - // GET (test_get / status_get) is probed against its saved credentials. - const res = cfg.test_post - ? await apiClient.post(testPath, buildProviderPayload(provider)) - : 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 { + memoriesEnabled, + activePhotoProviders, + providerValues, + providerConnected, + providerTesting, + saving, + handleProviderFieldChange, + isProviderSaveDisabled, + handleSaveProvider, + handleTestProvider, + } = usePhotoProviderConnections({ statusRouteOwnsBadge: false, testErrorGap: ' ' }) const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { const fields = getProviderFields(provider) diff --git a/client/src/components/Settings/usePhotoProviderConnections.test.ts b/client/src/components/Settings/usePhotoProviderConnections.test.ts new file mode 100644 index 000000000..1c5fe6e93 --- /dev/null +++ b/client/src/components/Settings/usePhotoProviderConnections.test.ts @@ -0,0 +1,233 @@ +// FE-COMP-PHOTOCONN-001 to -010: the photo provider connection logic both settings shells share. +import { act, renderHook, waitFor } from '@testing-library/react'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import apiClient from '../../api/client'; +import { useAddonStore } from '../../store/addonStore'; +import { + getProviderConfig, + getProviderFields, + usePhotoProviderConnections, + type PhotoProviderAddon, +} from './usePhotoProviderConnections'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ + t: (k: string, p?: Record) => (p ? `${k}(${String(p.provider_name)})` : k), + }), +})); + +const PROVIDER: PhotoProviderAddon = { + id: 'immich', + name: 'Immich', + type: 'photo_provider', + enabled: true, + config: { + settings_get: '/addons/immich/settings', + settings_put: '/addons/immich/settings', + status_get: '/addons/immich/status', + test_post: '/addons/immich/test', + }, + fields: [ + { + key: 'api_key', + label: 'api_key', + input_type: 'text', + required: true, + secret: true, + settings_key: 'api_key', + payload_key: 'api_key', + sort_order: 1, + }, + { + key: 'url', + label: 'url', + input_type: 'text', + required: true, + secret: false, + settings_key: 'url', + payload_key: 'url', + sort_order: 0, + }, + { + key: 'shared', + label: 'shared', + input_type: 'checkbox', + required: false, + secret: false, + settings_key: 'include_shared', + payload_key: null, + sort_order: 2, + }, + ], +}; + +type Resp = { data: Record }; + +function seedProviders(providers: PhotoProviderAddon[], memories = true) { + seedStore(useAddonStore, { + addons: providers, + isEnabled: (id: string) => memories && id === 'memories', + }); +} + +/** Answers GETs by path, so the settings read and the status read can be told apart. */ +function stubGets(byPath: Record Promise)>) { + return vi.spyOn(apiClient, 'get').mockImplementation((url: string) => { + const hit = byPath[url]; + if (!hit) return Promise.reject(new Error(`unexpected GET ${url}`)); + return typeof hit === 'function' ? hit() : Promise.resolve(hit); + }); +} + +function render(statusRouteOwnsBadge: boolean, testErrorGap = '') { + return renderHook(() => usePhotoProviderConnections({ statusRouteOwnsBadge, testErrorGap })); +} + +beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('photo provider helpers', () => { + it('FE-COMP-PHOTOCONN-001: config keeps only string routes and fields come sorted', () => { + expect(getProviderConfig({ ...PROVIDER, config: { settings_get: '/s', status_get: 5 } })).toEqual({ + settings_get: '/s', + settings_put: undefined, + status_get: undefined, + test_get: undefined, + test_post: undefined, + }); + expect(getProviderFields(PROVIDER).map((f) => f.key)).toEqual(['url', 'api_key', 'shared']); + }); +}); + +describe('usePhotoProviderConnections', () => { + it('FE-COMP-PHOTOCONN-002: only enabled photo providers count, and memories gates the section', () => { + seedProviders( + [PROVIDER, { ...PROVIDER, id: 'off', enabled: false }, { ...PROVIDER, id: 'x', type: 'other' }], + false + ); + stubGets({}); + const { result } = render(true); + expect(result.current.memoriesEnabled).toBe(false); + expect(result.current.activePhotoProviders.map((p) => p.id)).toEqual(['immich']); + }); + + it('FE-COMP-PHOTOCONN-003: hydrates the non secret fields, maps checkbox values and leaves the secret blank', async () => { + seedProviders([PROVIDER]); + stubGets({ + '/addons/immich/settings': { data: { url: 'https://p.example', api_key: 'secret', include_shared: true } }, + '/addons/immich/status': { data: { connected: true } }, + }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://p.example')); + expect(result.current.providerValues.immich).toEqual({ url: 'https://p.example', shared: 'true' }); + await waitFor(() => expect(result.current.providerConnected.immich).toBe(true)); + }); + + it('FE-COMP-PHOTOCONN-004: with the status route owning the badge, a late settings read does not override it', async () => { + seedProviders([PROVIDER]); + let resolveSettings!: (r: Resp) => void; + stubGets({ + '/addons/immich/settings': () => new Promise((r) => (resolveSettings = r)), + '/addons/immich/status': { data: { connected: true } }, + }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerConnected.immich).toBe(true)); + await act(async () => resolveSettings({ data: { url: 'u', connected: false } })); + expect(result.current.providerValues.immich?.url).toBe('u'); + expect(result.current.providerConnected.immich).toBe(true); + }); + + it('FE-COMP-PHOTOCONN-005: without that, the later settings read sets the badge', async () => { + seedProviders([PROVIDER]); + let resolveSettings!: (r: Resp) => void; + stubGets({ + '/addons/immich/settings': () => new Promise((r) => (resolveSettings = r)), + '/addons/immich/status': { data: { connected: true } }, + }); + const { result } = render(false); + await waitFor(() => expect(result.current.providerConnected.immich).toBe(true)); + await act(async () => resolveSettings({ data: { url: 'u', connected: false } })); + expect(result.current.providerConnected.immich).toBe(false); + }); + + it('FE-COMP-PHOTOCONN-006: save is blocked while a required field is blank', async () => { + seedProviders([PROVIDER]); + stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('')); + expect(result.current.isProviderSaveDisabled(PROVIDER)).toBe(true); + act(() => result.current.handleProviderFieldChange('immich', 'url', 'https://a')); + act(() => result.current.handleProviderFieldChange('immich', 'api_key', 'k')); + expect(result.current.isProviderSaveDisabled(PROVIDER)).toBe(false); + }); + + it('FE-COMP-PHOTOCONN-007: save puts the payload without a blank secret, rechecks the status and toasts', async () => { + seedProviders([PROVIDER]); + const get = stubGets({ + '/addons/immich/settings': { data: { url: 'https://a' } }, + '/addons/immich/status': { data: { connected: false } }, + }); + const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://a')); + act(() => result.current.handleProviderFieldChange('immich', 'url', ' https://b ')); + get.mockClear(); + await act(() => result.current.handleSaveProvider(PROVIDER)); + expect(put).toHaveBeenCalledWith('/addons/immich/settings', { url: 'https://b', include_shared: false }); + expect(get).toHaveBeenCalledWith('/addons/immich/status'); + expect(toast.success).toHaveBeenCalledWith('memories.saved(Immich)'); + expect(result.current.saving.immich).toBe(false); + }); + + it('FE-COMP-PHOTOCONN-008: a failed save toasts the error and a provider without a save route does nothing', async () => { + seedProviders([PROVIDER]); + stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } }); + const put = vi.spyOn(apiClient, 'put').mockRejectedValue(new Error('x')); + const { result } = render(true); + await act(() => result.current.handleSaveProvider(PROVIDER)); + expect(toast.error).toHaveBeenCalledWith('memories.saveError(Immich)'); + put.mockClear(); + await act(() => result.current.handleSaveProvider({ ...PROVIDER, config: {} })); + expect(put).not.toHaveBeenCalled(); + }); + + it('FE-COMP-PHOTOCONN-009: a POST test sends the form, a GET test only probes the saved credentials', async () => { + seedProviders([PROVIDER]); + const get = stubGets({ + '/addons/immich/settings': { data: { url: 'https://a' } }, + '/addons/immich/status': { data: {} }, + }); + const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: { connected: true } }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://a')); + await act(() => result.current.handleTestProvider(PROVIDER)); + expect(post).toHaveBeenCalledWith('/addons/immich/test', { url: 'https://a', include_shared: false }); + expect(result.current.providerConnected.immich).toBe(true); + expect(toast.success).toHaveBeenCalledWith('memories.connectionSuccess(Immich)'); + get.mockClear(); + const getOnly = { ...PROVIDER, config: { status_get: '/addons/immich/status' } }; + await act(() => result.current.handleTestProvider(getOnly)); + expect(get).toHaveBeenCalledWith('/addons/immich/status'); + expect(result.current.providerTesting.immich).toBe(false); + }); + + it('FE-COMP-PHOTOCONN-010: a failed probe joins the server reason with the gap each shell asks for', async () => { + seedProviders([PROVIDER]); + stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } }); + vi.spyOn(apiClient, 'post').mockResolvedValue({ data: { connected: false, error: 'bad key' } }); + const phone = render(true, ''); + await act(() => phone.result.current.handleTestProvider(PROVIDER)); + expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich): bad key'); + const desktop = render(false, ' '); + await act(() => desktop.result.current.handleTestProvider(PROVIDER)); + expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich) : bad key'); + vi.mocked(apiClient.post).mockRejectedValue(new Error('x')); + await act(() => desktop.result.current.handleTestProvider(PROVIDER)); + expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich)'); + }); +}); diff --git a/client/src/components/Settings/usePhotoProviderConnections.ts b/client/src/components/Settings/usePhotoProviderConnections.ts new file mode 100644 index 000000000..98ff592c9 --- /dev/null +++ b/client/src/components/Settings/usePhotoProviderConnections.ts @@ -0,0 +1,244 @@ +import { useEffect, useMemo, useState } from 'react'; +import apiClient from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { useAddonStore } from '../../store/addonStore'; +import { useToast } from '../shared/Toast'; + +export 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; +} + +export interface PhotoProviderAddon { + id: string; + name: string; + type: string; + enabled: boolean; + config?: Record; + fields?: ProviderField[]; +} + +export interface ProviderConfig { + settings_get?: string; + settings_put?: string; + status_get?: string; + test_get?: string; + test_post?: string; +} + +export 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, + }; +}; + +export const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { + return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order); +}; + +export interface PhotoProviderConnectionOptions { + /** + * When the provider has a status route, let only that route set the badge and + * ignore the `connected` flag of the settings read, so the two responses cannot + * race. The phone does this; the desktop takes whichever answer lands last. + */ + statusRouteOwnsBadge: boolean; + /** What goes between the failed test sentence and the server's ": reason" ('' on the phone, ' ' on the desktop). */ + testErrorGap: string; +} + +/** + * The photo provider connection cards behind both settings shells: every enabled + * photo_provider addon with its declared fields, seeded and hydrated values + * (secrets never prefilled), the connection badge, and save and test. The desktop + * section and its phone twin render their own markup over this. + */ +export function usePhotoProviderConnections({ statusRouteOwnsBadge, testErrorGap }: PhotoProviderConnectionOptions) { + const { t } = useTranslation(); + const toast = useToast(); + const { isEnabled: addonEnabled, addons } = useAddonStore(); + const memoriesEnabled = addonEnabled('memories'); + + const [saving, setSaving] = useState>({}); + const [providerValues, setProviderValues] = useState>>({}); + const [providerConnected, setProviderConnected] = useState>({}); + const [providerTesting, setProviderTesting] = useState>({}); + + const activePhotoProviders = useMemo( + () => addons.filter((a) => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], + [addons] + ); + + const buildProviderPayload = (provider: PhotoProviderAddon): Record => { + const values = providerValues[provider.id] || {}; + const payload: Record = {}; + 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 = {}; + 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 = {}; + 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)[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 (!(statusRouteOwnsBadge && cfg.status_get) && typeof res.data?.connected === 'boolean') { + setProviderConnected((prev) => ({ ...prev, [provider.id]: !!res.data.connected })); + } + }) + .catch(() => {}); + } + + refreshProviderConnection(provider).catch(() => {}); + } + + return () => { + isCancelled = true; + }; + }, [activePhotoProviders, activeProviderSignature, statusRouteOwnsBadge]); + + 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 { + // Only a POST probe carries the form values. A provider that declares just a + // GET (test_get / status_get) is probed against its saved credentials. + const res = cfg.test_post + ? await apiClient.post(testPath, buildProviderPayload(provider)) + : 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 { + const detail = res.data?.error ? `: ${String(res.data.error)}` : ''; + toast.error(`${t('memories.connectionError', { provider_name: provider.name })}${testErrorGap}${detail}`); + } + } catch { + toast.error(t('memories.connectionError', { provider_name: provider.name })); + } finally { + setProviderTesting((prev) => ({ ...prev, [provider.id]: false })); + } + }; + + return { + memoriesEnabled, + activePhotoProviders, + providerValues, + providerConnected, + providerTesting, + saving, + handleProviderFieldChange, + isProviderSaveDisabled, + handleSaveProvider, + handleTestProvider, + }; +} diff --git a/client/src/mobile/screens/settings/MPhotoProvidersSection.tsx b/client/src/mobile/screens/settings/MPhotoProvidersSection.tsx index a494e733c..5d7be597a 100644 --- a/client/src/mobile/screens/settings/MPhotoProvidersSection.tsx +++ b/client/src/mobile/screens/settings/MPhotoProvidersSection.tsx @@ -1,11 +1,14 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React from 'react' import { Camera, Save, RefreshCw } from 'lucide-react' import { useTranslation } from '../../../i18n' -import { useToast } from '../../../components/shared/Toast' -import apiClient from '../../../api/client' -import { useAddonStore } from '../../../store/addonStore' import { MSetCard, MSetEyebrow, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi' import MToggle from '../../components/MToggle' +import { + getProviderConfig, + getProviderFields, + usePhotoProviderConnections, + type PhotoProviderAddon, +} from '../../../components/Settings/usePhotoProviderConnections' /** * Mobile-native twin of components/Settings/PhotoProvidersSection. Same logic @@ -14,209 +17,20 @@ import MToggle from '../../components/MToggle' * system with MToggle switches. Presentation only — the behaviour is identical. */ -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 - 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 MPhotoProvidersSection(): React.ReactElement { const { t } = useTranslation() - const toast = useToast() - const { isEnabled: addonEnabled, addons } = useAddonStore() - const memoriesEnabled = addonEnabled('memories') - - const [saving, setSaving] = useState>({}) - const [providerValues, setProviderValues] = useState>>({}) - const [providerConnected, setProviderConnected] = useState>({}) - const [providerTesting, setProviderTesting] = useState>({}) - - const activePhotoProviders = useMemo( - () => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], - [addons], - ) - - const buildProviderPayload = (provider: PhotoProviderAddon): Record => { - const values = providerValues[provider.id] || {} - const payload: Record = {} - 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 = {} - 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 = {} - 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)[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 }, - })) - // The status route owns the badge when there is one — otherwise the two - // responses would race and the later one would win. - if (!cfg.status_get && 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 { - const detail = res.data?.error ? `: ${String(res.data.error)}` : '' - toast.error(`${t('memories.connectionError', { provider_name: provider.name })}${detail}`) - } - } catch { - toast.error(t('memories.connectionError', { provider_name: provider.name })) - } finally { - setProviderTesting(prev => ({ ...prev, [provider.id]: false })) - } - } + const { + memoriesEnabled, + activePhotoProviders, + providerValues, + providerConnected, + providerTesting, + saving, + handleProviderFieldChange, + isProviderSaveDisabled, + handleSaveProvider, + handleTestProvider, + } = usePhotoProviderConnections({ statusRouteOwnsBadge: true, testErrorGap: '' }) const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { const fields = getProviderFields(provider)