Files
pn-new-crm/src/components/admin/branding/pdf-logo-uploader.tsx
Matt c8ea9ec0a0 fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:

- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/

The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.

Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.

Test suite stays at 1315/1315 vitest. typescript clean.

Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import 'react-image-crop/dist/ReactCrop.css';
import { useEffect, useRef, useState } from 'react';
import ReactCrop, { centerCrop, makeAspectCrop, type Crop, type PixelCrop } from 'react-image-crop';
import { Loader2, RefreshCw, Trash2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { toastError } from '@/lib/api/toast-error';
import { toast } from 'sonner';
import { useConfirmation } from '@/hooks/use-confirmation';
const ACCEPT = 'image/png,image/jpeg,image/webp,image/svg+xml,image/heic,image/heif,image/avif';
type AspectMode = 'wide' | 'square' | 'freeform';
const ASPECT_RATIOS: Record<AspectMode, number | undefined> = {
wide: 3,
square: 1,
freeform: undefined,
};
interface CurrentLogo {
fileId: string;
previewUrl: string;
sizeBytes: string | null;
mimeType: string | null;
}
interface UploadResult {
fileId: string;
previewUrl: string;
warnings: string[];
finalDimensions: { width: number; height: number };
finalBytes: number;
originalDimensions: { width: number; height: number };
originalFormat: string;
}
const WARNING_LABELS: Record<string, string> = {
trimmed: 'Auto-trimmed whitespace borders',
resized: 'Downscaled for size',
'no-alpha': 'No transparent background — will show a white box on dark headers',
'jpeg-source': 'JPEG source — prefer PNG with alpha for crisp rendering',
'svg-rasterized': 'SVG rasterized to PNG at 300 DPI',
'heic-converted': 'HEIC/HEIF converted to PNG',
'webp-converted': 'WebP converted to PNG',
};
function centeredCrop(width: number, height: number, aspect: number): Crop {
return centerCrop(makeAspectCrop({ unit: '%', width: 90 }, aspect, width, height), width, height);
}
export function PdfLogoUploader() {
const { confirm, dialog: confirmDialog } = useConfirmation();
const [current, setCurrent] = useState<CurrentLogo | null>(null);
const [loading, setLoading] = useState(true);
const [working, setWorking] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [crop, setCrop] = useState<Crop>();
const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null);
const [aspectMode, setAspectMode] = useState<AspectMode>('wide');
const [warnings, setWarnings] = useState<string[]>([]);
const [uploadInfo, setUploadInfo] = useState<UploadResult | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
void refresh();
}, []);
useEffect(() => {
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [objectUrl]);
async function refresh() {
setLoading(true);
try {
const res = await fetch('/api/v1/admin/branding/logo');
if (!res.ok) throw new Error(`GET ${res.status}`);
const body = (await res.json()) as { data: CurrentLogo | null };
setCurrent(body.data ?? null);
} catch (err) {
toastError(err);
} finally {
setLoading(false);
}
}
function pick(f: File | null) {
if (!f) return;
if (objectUrl) URL.revokeObjectURL(objectUrl);
const url = URL.createObjectURL(f);
setFile(f);
setObjectUrl(url);
setCrop(undefined);
setCompletedCrop(null);
setWarnings([]);
setUploadInfo(null);
}
function onImageLoad(e: React.SyntheticEvent<HTMLImageElement>) {
const { naturalWidth: w, naturalHeight: h } = e.currentTarget;
const aspect = ASPECT_RATIOS[aspectMode];
if (aspect) setCrop(centeredCrop(w, h, aspect));
}
function changeAspect(mode: AspectMode) {
setAspectMode(mode);
const img = imgRef.current;
const aspect = ASPECT_RATIOS[mode];
if (img && aspect) {
setCrop(centeredCrop(img.naturalWidth, img.naturalHeight, aspect));
} else {
setCrop(undefined);
}
}
async function upload() {
if (!file) return;
setWorking(true);
try {
const fd = new FormData();
fd.append('file', file);
if (completedCrop && completedCrop.width > 0 && completedCrop.height > 0) {
fd.append(
'crop',
JSON.stringify({
x: completedCrop.x,
y: completedCrop.y,
width: completedCrop.width,
height: completedCrop.height,
}),
);
}
const res = await fetch('/api/v1/admin/branding/logo', { method: 'POST', body: fd });
const body = (await res.json()) as { data: UploadResult } | { error?: { message?: string } };
if (!res.ok) {
const message = ('error' in body && body.error?.message) || `Upload failed (${res.status})`;
throw new Error(message);
}
const data = (body as { data: UploadResult }).data;
setUploadInfo(data);
setWarnings(data.warnings ?? []);
toast.success('Logo saved');
await refresh();
// Clear the local crop staging so the preview now shows the stored logo.
setFile(null);
if (objectUrl) URL.revokeObjectURL(objectUrl);
setObjectUrl(null);
} catch (err) {
toastError(err);
} finally {
setWorking(false);
}
}
async function clear() {
const ok = await confirm({
title: 'Remove PDF logo',
description: 'Remove the PDF logo? Future reports will fall back to the port name.',
confirmLabel: 'Remove',
});
if (!ok) return;
setWorking(true);
try {
const res = await fetch('/api/v1/admin/branding/logo', { method: 'DELETE' });
if (!res.ok && res.status !== 204) throw new Error(`DELETE ${res.status}`);
toast.success('Logo removed');
setCurrent(null);
setUploadInfo(null);
setWarnings([]);
} catch (err) {
toastError(err);
} finally {
setWorking(false);
}
}
function openSamplePdf() {
window.open('/api/v1/admin/branding/logo/sample-pdf', '_blank');
}
return (
<Card>
<CardHeader>
<CardTitle>PDF logo</CardTitle>
<CardDescription>
Used as the header logo on every internal PDF the CRM generates (reports, expense sheets,
record exports). Separate from the email/UI logo so PDFs can have a higher-resolution,
alpha-channel version optimized for print.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-md bg-muted/40 p-4 text-sm text-muted-foreground space-y-1">
<p>Use PNG or SVG with a transparent background.</p>
<p>Minimum 200×200px; recommended 600×200px (wide) or 400×400px (square).</p>
<p>Max 5MB; we&apos;ll auto-trim, downscale, and convert to PNG.</p>
<p>Avoid JPEGs unless the background is solid white.</p>
</div>
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden /> Loading current logo
</div>
) : current ? (
<div className="flex items-start gap-4 rounded-md border p-4">
<div className="flex h-20 w-44 items-center justify-center rounded bg-[#0f172a] p-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.previewUrl}
alt="Current PDF logo on dark header"
className="max-h-full max-w-full object-contain"
/>
</div>
<div className="flex-1 space-y-1">
<div className="text-sm font-medium">Current logo</div>
<div className="text-xs text-muted-foreground">
{current.sizeBytes
? `${(Number(current.sizeBytes) / 1024).toFixed(1)} KB`
: 'size unknown'}{' '}
· {current.mimeType ?? 'image'}
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" onClick={openSamplePdf}>
Test with sample PDF
</Button>
<Button variant="outline" size="sm" onClick={clear} disabled={working}>
<Trash2 className="mr-1 h-3 w-3" aria-hidden /> Remove
</Button>
</div>
</div>
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No PDF logo configured yet. Reports use the port name as a text header.
</div>
)}
<div className="space-y-2">
<Label>Upload a new logo</Label>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT}
onChange={(e) => pick(e.target.files?.[0] ?? null)}
className="block w-full text-sm file:mr-3 file:rounded file:border-0 file:bg-primary file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-primary-foreground"
/>
</div>
{objectUrl ? (
<div className="space-y-4 rounded-md border p-4">
<div className="flex items-center gap-2 text-sm">
<span>Crop:</span>
{(['wide', 'square', 'freeform'] as const).map((m) => (
<button
type="button"
key={m}
onClick={() => changeAspect(m)}
className={`rounded border px-2 py-0.5 text-xs capitalize ${
aspectMode === m
? 'border-primary bg-primary text-primary-foreground'
: 'border-input hover:bg-accent'
}`}
>
{m === 'wide' ? 'Wide 3:1' : m === 'square' ? 'Square 1:1' : 'Freeform'}
</button>
))}
</div>
<ReactCrop
crop={crop}
onChange={(_c, percent) => setCrop(percent)}
onComplete={(c) => setCompletedCrop(c)}
aspect={ASPECT_RATIOS[aspectMode]}
className="max-h-[400px]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imgRef}
src={objectUrl}
alt="Logo to crop"
onLoad={onImageLoad}
className="max-h-[400px]"
/>
</ReactCrop>
<div className="flex gap-2">
<Button onClick={upload} disabled={working}>
{working ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" aria-hidden />
) : (
<Upload className="mr-1 h-3 w-3" aria-hidden />
)}
Save logo
</Button>
<Button
variant="outline"
onClick={() => {
setFile(null);
if (objectUrl) URL.revokeObjectURL(objectUrl);
setObjectUrl(null);
setCrop(undefined);
setCompletedCrop(null);
}}
disabled={working}
>
Cancel
</Button>
<Button
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={working}
>
<RefreshCw className="mr-1 h-3 w-3" aria-hidden /> Choose a different file
</Button>
</div>
</div>
) : null}
{uploadInfo ? (
<div className="rounded-md border p-4 text-sm space-y-2">
<div className="font-medium">Processed</div>
<div className="text-xs text-muted-foreground">
From {uploadInfo.originalFormat.toUpperCase()} {uploadInfo.originalDimensions.width}×
{uploadInfo.originalDimensions.height}
{' → '}
PNG {uploadInfo.finalDimensions.width}×{uploadInfo.finalDimensions.height} (
{(uploadInfo.finalBytes / 1024).toFixed(1)} KB)
</div>
{warnings.length > 0 ? (
<ul className="list-disc space-y-0.5 pl-5 text-xs">
{warnings.map((w) => (
<li key={w}>{WARNING_LABELS[w] ?? w}</li>
))}
</ul>
) : null}
</div>
) : null}
{confirmDialog}
</CardContent>
</Card>
);
}