'use client'; /** * Brochures admin panel (Phase 7 §5.8). * * Lists every brochure for the port (including archived). Lets a * `manage_settings` admin: * - Create new brochures. * - Upload a new version (direct-to-storage presigned PUT, see §11.1). * - Mark default / archive. */ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Archive, FileText, Loader2, Plus, Star, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { apiFetch } from '@/lib/api/client'; interface BrochureRow { id: string; label: string; description: string | null; isDefault: boolean; archivedAt: string | null; versionCount: number; currentVersion: { id: string; fileName: string; fileSizeBytes: number; uploadedAt: string; } | null; } interface BrochuresResponse { data: BrochureRow[]; } interface UploadGrantResponse { data: { storageKey: string; uploadUrl: string; method: 'PUT'; maxBytes: number }; } export function BrochuresAdminPanel() { const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); const brochuresQuery = useQuery({ queryKey: ['brochures', 'admin'], queryFn: () => apiFetch('/api/v1/admin/brochures'), }); const rows = brochuresQuery.data?.data ?? []; return (
{brochuresQuery.isLoading && (
Loading…
)} {!brochuresQuery.isLoading && rows.length === 0 && ( No brochures yet. Click “New brochure” to add one. )}
{rows.map((b) => ( { void queryClient.invalidateQueries({ queryKey: ['brochures', 'admin'] }); void queryClient.invalidateQueries({ queryKey: ['brochures', 'list'] }); }} /> ))}
{ void queryClient.invalidateQueries({ queryKey: ['brochures', 'admin'] }); }} />
); } function BrochureCard({ brochure, onChange }: { brochure: BrochureRow; onChange: () => void }) { const [uploading, setUploading] = useState(false); const setDefaultMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/admin/brochures/${brochure.id}`, { method: 'PATCH', body: { isDefault: true }, }), onSuccess: () => { toast.success('Default brochure updated'); onChange(); }, }); const archiveMutation = useMutation({ mutationFn: () => apiFetch(`/api/v1/admin/brochures/${brochure.id}`, { method: 'DELETE' }), onSuccess: () => { toast.success('Brochure archived'); onChange(); }, }); async function handleUpload(file: File) { setUploading(true); try { const grant: UploadGrantResponse = await apiFetch( `/api/v1/admin/brochures/${brochure.id}/versions`, ); if (file.size > grant.data.maxBytes) { throw new Error( `File is too large. Max is ${(grant.data.maxBytes / 1024 / 1024).toFixed(0)}MB.`, ); } // Direct-to-storage PUT (§11.1). const putRes = await fetch(grant.data.uploadUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/pdf' }, }); if (!putRes.ok) throw new Error(`Upload failed: ${putRes.status}`); const sha = await sha256Hex(file); await apiFetch(`/api/v1/admin/brochures/${brochure.id}/versions`, { method: 'POST', body: { storageKey: grant.data.storageKey, fileName: file.name, fileSizeBytes: file.size, contentSha256: sha, }, }); toast.success('New version uploaded'); onChange(); } catch (err) { toast.error(err instanceof Error ? err.message : 'Upload failed'); } finally { setUploading(false); } } return ( {brochure.label} {brochure.isDefault && ( default )} {brochure.archivedAt && ( archived )} {brochure.versionCount} versions {brochure.description && (

{brochure.description}

)} {brochure.currentVersion && (

Latest: {brochure.currentVersion.fileName} ( {(brochure.currentVersion.fileSizeBytes / 1024 / 1024).toFixed(2)} MB,{' '} {new Date(brochure.currentVersion.uploadedAt).toLocaleDateString()})

)}
{!brochure.archivedAt && ( <> {!brochure.isDefault && ( )} )}
); } function CreateBrochureDialog({ open, onOpenChange, onCreated, }: { open: boolean; onOpenChange: (o: boolean) => void; onCreated: () => void; }) { const [label, setLabel] = useState(''); const [description, setDescription] = useState(''); const [isDefault, setIsDefault] = useState(false); const createMutation = useMutation({ mutationFn: () => apiFetch('/api/v1/admin/brochures', { method: 'POST', body: { label, description: description || null, isDefault, }, }), onSuccess: () => { toast.success('Brochure created. Upload a version next.'); setLabel(''); setDescription(''); setIsDefault(false); onCreated(); onOpenChange(false); }, }); return ( New brochure Create the brochure container, then upload a PDF version on the card that appears.
setLabel(e.target.value)} placeholder="General overview" />