import { LogoVariant, MAX_LOGO_EDGE, PondBranding } from '@dorfteich/shared'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { CropField } from '../branding/CropField'; import { canvasToPngFile, drawCrop } from '../branding/crop'; import { logoUrl } from '../branding/use-branding'; import { FormError, FormSuccess } from '../components/forms'; import { apiDelete, apiGet, apiPostForm } from '../lib/api'; const FAVICON_SIZES = [32, 180] as const; /** * A pond's own logo and favicon (issue #307). * * Reuses the instance screen's crop control rather than growing a second, * drag-only one: the uploader here is an ordinary Pond Admin, and the * keyboard operability is not theirs to lose. */ export function PondBrandingSection({ pondId }: { pondId: string }): React.JSX.Element { const { t } = useTranslation('branding'); const queryClient = useQueryClient(); const branding = useQuery({ queryKey: ['pond', pondId, 'branding'], queryFn: () => apiGet(`/ponds/${pondId}/branding`), }); const view = branding.data; const refresh = async (): Promise => { await queryClient.invalidateQueries({ queryKey: ['pond', pondId, 'branding'] }); }; return (

{t('pond.intro')}

{t('pond.quotaNote')}

{view?.logo && !view.logoDark && ( // Advisory, exactly as on the instance screen: a pond that uploads // only a light logo shows THAT logo in dark mode — it does not borrow // the instance's dark one. Nothing is blocked.

{t('pond.darkMissing')}

)}
); } function PondLogoSlot({ pondId, variant, asset, onChanged, }: { pondId: string; variant: LogoVariant; asset: { hash: string; width: number; height: number } | null; onChanged: () => Promise; }): React.JSX.Element { const { t } = useTranslation('branding'); const canvasRef = useRef(null); const [done, setDone] = useState(false); const upload = useMutation({ mutationFn: async () => { const canvas = canvasRef.current; if (!canvas) throw new Error('no image'); const form = new FormData(); form.append('file', await canvasToPngFile(canvas, `logo-${variant}.png`)); return apiPostForm(`/ponds/${pondId}/branding/logo?variant=${variant}`, form); }, onSuccess: async () => { setDone(true); await onChanged(); }, }); const reset = useMutation({ mutationFn: () => apiDelete(`/ponds/${pondId}/branding/logo?variant=${variant}`), onSuccess: async () => { setDone(false); await onChanged(); }, }); return (

{t(`admin.logo.${variant}`)}

{asset ? (
{t('admin.logo.currentAlt')}

{t('admin.logo.current', { width: asset.width, height: asset.height })}

{/* A real, labelled control — not an empty file field standing in for "remove". */}
) : (

{t('pond.inherited')}

)} { canvasRef.current = canvas; setDone(false); }} />

{upload.isPending ? t('admin.uploading') : ''}

); } function PondFaviconSlot({ pondId, present, onChanged, }: { pondId: string; present: boolean; onChanged: () => Promise; }): React.JSX.Element { const { t } = useTranslation('branding'); const canvasRef = useRef(null); const [done, setDone] = useState(false); const upload = useMutation({ mutationFn: async () => { const source = canvasRef.current; if (!source) throw new Error('no image'); const form = new FormData(); for (const size of FAVICON_SIZES) { const scratch = document.createElement('canvas'); drawCrop( source, { x: 0, y: 0, width: source.width, height: source.height }, { width: size, height: size }, scratch, ); form.append(`png-${size}`, await canvasToPngFile(scratch, `favicon-${size}.png`)); } return apiPostForm(`/ponds/${pondId}/branding/favicon`, form); }, onSuccess: async () => { setDone(true); await onChanged(); }, }); const reset = useMutation({ mutationFn: () => apiDelete(`/ponds/${pondId}/branding/favicon`), onSuccess: async () => { setDone(false); await onChanged(); }, }); return (

{t('admin.favicon.title')}

{t('pond.faviconHint')}

{present ? t('admin.favicon.present') : t('pond.inherited')}

{present && ( )} { canvasRef.current = canvas; setDone(false); }} />

{upload.isPending ? t('admin.uploading') : ''}

); }