import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; import { HelpText } from './HelpText'; import { FieldLabel, FieldInput } from './formUtils'; import type { SectionFormProps } from './types'; type AssetKind = 'logo' | 'favicon'; const ACCEPT: Record = { logo: '.svg,.png,.jpg,.jpeg,.webp,.gif', favicon: '.svg,.png,.ico,.webp', }; const MAX_SIZE: Record = { logo: 2 * 1024 * 1024, favicon: 256 * 1024, }; async function fileToBase64(file: File): Promise { const buf = await file.arrayBuffer(); // btoa does not accept non-ASCII; convert via chunked construction. let binary = ''; const bytes = new Uint8Array(buf); const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode(...bytes.subarray(i, i + chunk)); } return btoa(binary); } function AssetUploader({ kind, currentUrl, onChanged, }: { kind: AssetKind; currentUrl: string | null; /** Called after a successful upload/delete with the new URL (null when cleared). */ onChanged: (newUrl: string | null) => void; }) { const { t } = useTranslation('settings'); const fileRef = useRef(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const handlePick = () => fileRef.current?.click(); const handleFile = async (file: File) => { setError(null); if (file.size > MAX_SIZE[kind]) { setError(t('branding.sizeExceeded', { kb: Math.round(MAX_SIZE[kind] / 1024) })); return; } try { setBusy(true); const contentBase64 = await fileToBase64(file); const res = await fetch('/api/branding/upload', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, filename: file.name, contentBase64 }), }); const body = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(body.error ?? t('branding.uploadFailed', { status: res.status })); } onChanged(typeof body.url === 'string' ? body.url : null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); if (fileRef.current) fileRef.current.value = ''; } }; const handleClear = async () => { setError(null); try { setBusy(true); const res = await fetch(`/api/branding/upload?kind=${kind}`, { method: 'DELETE' }); if (!res.ok) throw new Error(t('branding.deleteFailed', { status: res.status })); onChanged(null); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; return (
{currentUrl ? ( ) : ( {t('branding.notSet')} )}
{ const f = e.target.files?.[0]; if (f) void handleFile(f); }} />
{currentUrl && ( )}
{currentUrl ?? t('branding.accept', { accept: ACCEPT[kind], kb: Math.round(MAX_SIZE[kind] / 1024) })}
{error &&
⚠ {error}
}
); } export function BrandingForm({ config, onChange }: SectionFormProps) { const { t } = useTranslation('settings'); const branding = config.branding ?? {}; const primaryColor = branding.primaryColor ?? ''; const qc = useQueryClient(); // サーバーは upload 時に config.yaml を直接書き換える。 // ローカル draft も同期して、他フィールドの編集中でも整合を保つ。 const handleAssetChange = (field: 'logoUrl' | 'faviconUrl') => (newUrl: string | null) => { onChange(`branding.${field}`, newUrl ?? ''); // Branding API (TopBar の画像など) は別クエリなので個別に再取得 void qc.invalidateQueries({ queryKey: ['branding'] }); }; return (

{t('branding.title')}

{t('branding.intro')}

{t('branding.appName')} onChange('branding.appName', v)} placeholder="MAESTRO" /> {t('branding.appNameHelp')}
{t('branding.primaryColor')}
onChange('branding.primaryColor', e.target.value)} className="h-9 w-12 rounded border border-slate-300 p-0 cursor-pointer" aria-label={t('branding.primaryColor')} /> onChange('branding.primaryColor', e.target.value)} placeholder="#2563eb" className="flex-1 px-3 py-2 text-sm font-mono border border-slate-300 rounded-lg focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none" />
{t('branding.primaryColorHelp')}
{t('branding.loginTitle')} onChange('branding.loginPageTitle', v)} placeholder="MAESTRO" /> {t('branding.loginTitleHelp')}
{t('branding.logo')} {t('branding.logoHelp')}
{t('branding.favicon')} {t('branding.faviconHelp')}
{t('branding.footer')} onChange('branding.footerText', v)} placeholder="© 2026 Your Team" /> {t('branding.footerHelp')}
); }