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.
This commit is contained in:
Maurice
2026-10-09 10:14:10 +02:00
parent 82070c72ec
commit 2acfc9cb43
4 changed files with 515 additions and 410 deletions
@@ -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<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)
}
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<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 {
// 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)
@@ -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<string, unknown>) => (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<string, unknown> };
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<string, Resp | (() => Promise<Resp>)>) {
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<Resp>((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<Resp>((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)');
});
});
@@ -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<string, unknown>;
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<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 (!(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,
};
}
@@ -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<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 MPhotoProvidersSection(): 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 },
}))
// 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)