Files
pn-new-crm/src/components/admin/templates/template-editor.tsx
Matt e9509dc45c chore(audit-drain): rip out next-intl, RTL lint, sweeps, polish
Drain the long-tail audit queue captured in alpha-uat-master.md.

- next-intl ripped out (zero useTranslations callers ever existed):
  package.json, next.config.ts plugin wrap, src/i18n/, messages/, and
  the layout NextIntlClientProvider all gone; <html lang="en"> hardcoded.
- RTL lint nudge added: warn-only no-restricted-syntax on physical
  Tailwind utilities (ml-/mr-/pl-/pr-/text-left/text-right/border-l/
  border-r/rounded-l-/rounded-r-) inside JSX className literals.
  Existing ~1,000 sites grandfathered; new code trends toward logical.
- Icon-only button accessibility lint: jsx-a11y/control-has-associated-
  label enabled at warn; 4 empty <th>/<td> action placeholders gain
  sr-only labels.
- Currency: SUPPORTED_CURRENCIES drops the hardcoded English labels;
  new currencyLabel(code, locale?) helper resolves via Intl.DisplayNames.
  CurrencySelect + settings-manager migrated.
- Date locale sweep: 7 surfaces flip from toLocaleString('en-GB'|'en-US')
  to toLocaleString(undefined, ...) so dates honour runtime locale.
- Dialog/Sheet width: 10 document/EOI/entity-form dialogs gain a
  lg:max-w-4xl or lg:max-w-5xl step so wide desktops get breathing room.
- PaymentsSection collapsed-bar: slim one-line bar showing
  "Payments - Not received yet" or "Payments - \$X received - N payments
  - Expand"; per-interest collapse state persists in localStorage; the
  RecordPayment flow auto-expands.
- muted-foreground opacity sweep: 10 text-bearing
  text-muted-foreground/{60,70,80} hits dropped to plain
  text-muted-foreground for AA contrast on muted bg. Icon-only
  (aria-hidden) opacity hits left as-is.
- Micro-type bump: text-[10px] and text-[11px] -> text-xs (12px)
  across 87 files in src/components + src/app. Pure mechanical sweep.
- Audit-doc cleanup: alpha-uat-master.md stale 2026-05-25 summary
  rewritten with cumulative state through today. Items genuinely still
  open are now a short long-tail list.
- New docs/marketing-site-followups.md: Umami Phase 4a/3/5, email
  pixel E2E verification, and website-cutover work parked here so
  they don't get lost in the CRM audit doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:48:46 +02:00

821 lines
30 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Document, Page, pdfjs } from 'react-pdf';
import {
ChevronLeft,
ChevronRight,
Eye,
Loader2,
Save,
Sparkles,
Trash2,
Upload,
X,
} from 'lucide-react';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { PageHeader } from '@/components/shared/page-header';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
import { VALID_MERGE_TOKENS } from '@/lib/templates/merge-fields';
import type { FieldMap, FieldMapEntry } from '@/lib/templates/field-map';
// Worker setup mirrors src/components/files/pdf-viewer.tsx so we don't
// pay for a second bundle. Calling GlobalWorkerOptions.workerSrc twice
// with the same URL is a no-op in pdfjs.
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
interface TemplateData {
id: string;
name: string;
templateType: string;
templateFormat: string;
sourceFileId: string | null;
overlayPositions: FieldMap | null;
/** Tokens marked as required for the EOI flow - see
* STANDARD_EOI_MERGE_FIELDS in lib/templates/merge-fields. The editor
* surfaces a checklist of which required tokens are still unplaced. */
mergeFields?: string[] | null;
}
interface PendingMarker {
x: number;
y: number;
page: number;
}
type DragKind = 'move' | 'resize-nw' | 'resize-ne' | 'resize-sw' | 'resize-se';
interface DragState {
index: number;
kind: DragKind;
startMarkerX: number;
startMarkerY: number;
startMarkerW: number;
startMarkerH: number;
startClientX: number;
startClientY: number;
containerW: number;
containerH: number;
}
const TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort();
const DEFAULT_MARKER_W = 0.18;
const DEFAULT_MARKER_H = 0.04;
const MIN_MARKER_DIM = 0.02;
/**
* Phase 7.1 + 7.2 - PDF marker editor.
*
* - Click anywhere to drop a marker (page-aware).
* - Drag markers to move; corner handles to resize.
* - Right-click for context-menu delete.
* - Multi-page navigation via page picker.
* - "Required tokens unplaced" checklist surfaces missing fields.
* - Unsaved-changes guard (beforeunload + visual diff indicator).
* - Responsive PDF width tracks the container via ResizeObserver.
* - Live preview pane renders the AcroForm fill against a chosen
* interest via POST /api/v1/document-templates/[id]/preview.
* - "Replace PDF" reuses the existing template-templates source-file
* upload route while preserving the field map (warn on page-count
* change).
*/
export function TemplateEditor({ templateId }: { templateId: string }) {
const { data: template, isLoading } = useQuery<{ data: TemplateData }>({
queryKey: ['document-template', templateId],
queryFn: () => apiFetch<{ data: TemplateData }>(`/api/v1/document-templates/${templateId}`),
});
if (isLoading || !template) {
return (
<div className="flex h-64 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading template
</div>
);
}
// Inner body keyed by templateId so a route change re-mounts and the
// markers useState initializer re-reads the server payload, avoiding
// an in-render setState pattern to seed from the query.
return <TemplateEditorBody key={templateId} templateId={templateId} template={template.data} />;
}
function TemplateEditorBody({
templateId,
template,
}: {
templateId: string;
template: TemplateData;
}) {
const qc = useQueryClient();
const [markers, setMarkers] = useState<FieldMap>(template.overlayPositions ?? []);
// Track the "last saved" baseline as state (not a ref) so the dirty
// check re-runs on save. React Compiler forbids reading refs during
// render, and the isDirty memo needs a stable baseline that updates
// exactly when we commit a save.
const [savedMarkers, setSavedMarkers] = useState<FieldMap>(template.overlayPositions ?? []);
const [pending, setPending] = useState<PendingMarker | null>(null);
const [pendingToken, setPendingToken] = useState<string>('');
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [numPages, setNumPages] = useState<number | null>(null);
const [pageWidth, setPageWidth] = useState(680);
const [dragState, setDragState] = useState<DragState | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewInterestId, setPreviewInterestId] = useState<string>('');
const [autoDetectLoading, setAutoDetectLoading] = useState(false);
const [autoDetectMsg, setAutoDetectMsg] = useState<string | null>(null);
const pageContainerRef = useRef<HTMLDivElement | null>(null);
const outerColumnRef = useRef<HTMLDivElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null;
// ─── Unsaved changes detection ────────────────────────────────────────────
const isDirty = useMemo(
() => JSON.stringify(markers) !== JSON.stringify(savedMarkers),
[markers, savedMarkers],
);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
// Modern browsers ignore the returnValue string and show their own
// generic "you have unsaved changes" prompt - setting it still
// triggers the prompt, just without our wording.
e.returnValue = '';
};
window.addEventListener('beforeunload', handler);
return () => window.removeEventListener('beforeunload', handler);
}, [isDirty]);
// ─── Responsive PDF width ─────────────────────────────────────────────────
useEffect(() => {
const el = outerColumnRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width ?? 680;
// Subtract Card padding/border so the PDF doesn't overshoot the
// container. 32 = px-4 (16) × 2.
setPageWidth(Math.max(360, Math.floor(w - 32)));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// ─── Click-to-place (only when not dragging) ──────────────────────────────
function handlePageClick(e: React.MouseEvent<HTMLDivElement>) {
if (dragState) return; // drag in progress; ignore click
const container = pageContainerRef.current;
if (!container) return;
// Ignore clicks bubbling from a marker (handled by its own onClick).
const target = e.target as HTMLElement;
if (target.dataset.markerHandle === 'true') return;
const rect = container.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
setPending({ x, y, page: pageNumber });
setPendingToken(TOKEN_OPTIONS[0] ?? '');
}
function commitPending() {
if (!pending || !pendingToken) return;
const entry: FieldMapEntry = {
token: pendingToken,
page: pending.page,
x: pending.x,
y: pending.y,
w: DEFAULT_MARKER_W,
h: DEFAULT_MARKER_H,
};
setMarkers((m) => [...m, entry]);
setPending(null);
setPendingToken('');
}
function cancelPending() {
setPending(null);
setPendingToken('');
}
function removeMarker(index: number) {
setMarkers((m) => m.filter((_, i) => i !== index));
}
// ─── Drag + resize ────────────────────────────────────────────────────────
const onMarkerMouseDown = useCallback(
(index: number, kind: DragKind) => (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
e.preventDefault();
const container = pageContainerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const m = markers[index];
if (!m) return;
setDragState({
index,
kind,
startMarkerX: m.x,
startMarkerY: m.y,
startMarkerW: m.w ?? DEFAULT_MARKER_W,
startMarkerH: m.h ?? DEFAULT_MARKER_H,
startClientX: e.clientX,
startClientY: e.clientY,
containerW: rect.width,
containerH: rect.height,
});
},
[markers],
);
useEffect(() => {
if (!dragState) return;
function onMove(e: MouseEvent) {
if (!dragState) return;
const dxPct = (e.clientX - dragState.startClientX) / dragState.containerW;
const dyPct = (e.clientY - dragState.startClientY) / dragState.containerH;
setMarkers((current) => {
const next = current.slice();
const m = next[dragState.index];
if (!m) return current;
const w = m.w ?? DEFAULT_MARKER_W;
const h = m.h ?? DEFAULT_MARKER_H;
if (dragState.kind === 'move') {
// Clamp so the box stays fully on-page.
next[dragState.index] = {
...m,
x: clamp(dragState.startMarkerX + dxPct, 0, 1 - w),
y: clamp(dragState.startMarkerY + dyPct, 0, 1 - h),
};
} else {
// Resize from a specific corner; the opposing corner stays
// pinned, so x/y/w/h all change for NW (drag the top-left
// corner: x and y increase by the delta, w and h decrease by it).
let nx = dragState.startMarkerX;
let ny = dragState.startMarkerY;
let nw = dragState.startMarkerW;
let nh = dragState.startMarkerH;
if (dragState.kind === 'resize-nw') {
nx = dragState.startMarkerX + dxPct;
ny = dragState.startMarkerY + dyPct;
nw = dragState.startMarkerW - dxPct;
nh = dragState.startMarkerH - dyPct;
} else if (dragState.kind === 'resize-ne') {
ny = dragState.startMarkerY + dyPct;
nw = dragState.startMarkerW + dxPct;
nh = dragState.startMarkerH - dyPct;
} else if (dragState.kind === 'resize-sw') {
nx = dragState.startMarkerX + dxPct;
nw = dragState.startMarkerW - dxPct;
nh = dragState.startMarkerH + dyPct;
} else if (dragState.kind === 'resize-se') {
nw = dragState.startMarkerW + dxPct;
nh = dragState.startMarkerH + dyPct;
}
// Enforce min size + clamp so the box stays on-page.
nw = Math.max(MIN_MARKER_DIM, Math.min(1, nw));
nh = Math.max(MIN_MARKER_DIM, Math.min(1, nh));
nx = clamp(nx, 0, 1 - nw);
ny = clamp(ny, 0, 1 - nh);
next[dragState.index] = { ...m, x: nx, y: ny, w: nw, h: nh };
}
return next;
});
}
function onUp() {
setDragState(null);
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, [dragState]);
// ─── Save ────────────────────────────────────────────────────────────────
async function save() {
setSaving(true);
setSavedMsg(null);
try {
await apiFetch(`/api/v1/document-templates/${templateId}`, {
method: 'PATCH',
body: { overlayPositions: markers },
});
setSavedMarkers(markers);
await qc.invalidateQueries({ queryKey: ['document-template', templateId] });
setSavedMsg('Markers saved.');
} catch (err) {
toastError(err);
} finally {
setSaving(false);
}
}
// ─── Preview ─────────────────────────────────────────────────────────────
async function generatePreview() {
if (!previewInterestId) {
toastError(new Error('Pick an interest to preview against.'));
return;
}
setPreviewLoading(true);
try {
const res = await apiFetch<{ data: { previewUrl: string } }>(
`/api/v1/document-templates/${templateId}/preview`,
{ method: 'POST', body: { interestId: previewInterestId } },
);
setPreviewUrl(res.data.previewUrl);
} catch (err) {
toastError(err);
} finally {
setPreviewLoading(false);
}
}
// ─── Auto-detect anchors (Phase 4) ───────────────────────────────────────
// Calls the field-detector service via POST /detect-fields, which scans
// the current source PDF's text content for SIGNATURE / DATE / INITIALS /
// NAME / EMAIL anchors. Converts the returned DetectedField[] (percent
// 0..100) into FieldMapEntry markers (percent 0..1) and appends them to
// the existing field map. Users retag tokens via the per-marker dropdown.
async function autoDetect() {
setAutoDetectLoading(true);
setAutoDetectMsg(null);
try {
const res = await apiFetch<{
data: {
fields: Array<{
type: string;
pageNumber: number;
pageX: number;
pageY: number;
pageWidth: number;
pageHeight: number;
confidence: number;
anchorText: string;
inferredRecipientLabel?: string | null;
}>;
totalAnchors: number;
};
}>(`/api/v1/document-templates/${templateId}/detect-fields`, { method: 'POST' });
if (res.data.fields.length === 0) {
setAutoDetectMsg('No anchors detected. Place markers manually.');
return;
}
// Map detected type → best-guess merge token. Falls back to first
// sorted token when the detector finds a Documenso-only field
// (SIGNATURE / INITIALS) that has no direct merge-token equivalent
// in the in-app fill pipeline - the user retags from the dropdown.
const fallbackToken = TOKEN_OPTIONS[0] ?? '';
const pick = (candidates: string[]): string => {
for (const c of candidates) {
if (TOKEN_OPTIONS.includes(c)) return c;
}
return fallbackToken;
};
const tokenForType = (type: string): string => {
switch (type.toUpperCase()) {
case 'DATE':
return pick(['{{eoi.dateGenerated}}', '{{eoi.todayDate}}', '{{date}}']);
case 'NAME':
return pick(['{{client.fullName}}', '{{client.firstName}}']);
case 'EMAIL':
return pick(['{{client.email}}']);
default:
return fallbackToken;
}
};
const newMarkers: FieldMap = res.data.fields.map((f) => ({
token: tokenForType(f.type),
page: f.pageNumber,
x: f.pageX / 100,
y: f.pageY / 100,
w: Math.max(MIN_MARKER_DIM, f.pageWidth / 100),
h: Math.max(MIN_MARKER_DIM, f.pageHeight / 100),
}));
setMarkers((current) => [...current, ...newMarkers]);
setAutoDetectMsg(
`Added ${newMarkers.length} marker${newMarkers.length === 1 ? '' : 's'} from auto-detect. Review the assigned tokens (defaults are best-guess) and save.`,
);
} catch (err) {
toastError(err);
} finally {
setAutoDetectLoading(false);
}
}
// ─── New-PDF upload (replace source) ─────────────────────────────────────
async function onReplacePdf(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`/api/v1/document-templates/${templateId}/source-pdf`, {
method: 'POST',
body: fd,
});
if (!res.ok) throw new Error((await res.text()) || 'Upload failed');
const json = (await res.json()) as { data?: { pageCount?: number } };
const newPageCount = json.data?.pageCount;
if (newPageCount && numPages && newPageCount < numPages) {
toastError(
new Error(
`New PDF has ${newPageCount} pages; the previous source had ${numPages}. Markers on pages ${newPageCount + 1}${numPages} are now orphaned and won't render.`,
),
);
}
await qc.invalidateQueries({ queryKey: ['document-template', templateId] });
} catch (err) {
toastError(err);
} finally {
if (fileInputRef.current) fileInputRef.current.value = '';
}
}
// ─── Derived state ────────────────────────────────────────────────────────
const visibleMarkers = useMemo(
() =>
markers
.map((m, i) => ({ marker: m, index: i }))
.filter((row) => row.marker.page === pageNumber),
[markers, pageNumber],
);
const placedTokens = useMemo(() => new Set(markers.map((m) => m.token)), [markers]);
const requiredTokens = template.mergeFields ?? [];
const unplacedRequired = requiredTokens.filter((t) => !placedTokens.has(t));
if (!pdfUrl) {
return (
<div className="space-y-4">
<PageHeader
title={`Edit "${template.name}"`}
description="Place merge-field markers on the source PDF."
/>
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
This template has no source PDF attached. Upload one to get started.
<div className="mt-4">
<Button onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-1.5 h-4 w-4" /> Upload PDF
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={onReplacePdf}
/>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4">
<PageHeader
title={`Edit "${template.name}"`}
description="Click to drop markers · drag to move · corner handles to resize · right-click to delete."
actions={
<div className="flex items-center gap-2">
{isDirty ? (
<span className="text-xs text-amber-700 font-medium">Unsaved changes</span>
) : null}
<Button
variant="outline"
size="sm"
onClick={autoDetect}
disabled={autoDetectLoading || !pdfUrl}
>
{autoDetectLoading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
)}
Auto-detect
</Button>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
<Upload className="mr-1.5 h-3.5 w-3.5" /> Replace PDF
</Button>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
className="hidden"
onChange={onReplacePdf}
/>
<Button onClick={save} disabled={saving || !isDirty}>
<Save className="mr-1.5 h-4 w-4" />
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
}
/>
{savedMsg ? <p className="text-xs text-emerald-700">{savedMsg}</p> : null}
{autoDetectMsg ? <p className="text-xs text-sky-700">{autoDetectMsg}</p> : null}
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<div ref={outerColumnRef}>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm">
Page {pageNumber}
{numPages ? ` / ${numPages}` : ''}
</CardTitle>
{numPages && numPages > 1 ? (
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
disabled={pageNumber <= 1}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
disabled={pageNumber >= numPages}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
) : null}
</div>
</CardHeader>
<CardContent>
<div
ref={pageContainerRef}
onClick={handlePageClick}
className="relative inline-block cursor-crosshair select-none"
>
<Document
file={pdfUrl}
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
loading={
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading PDF
</div>
}
onLoadError={(err) => toastError(err)}
>
<Page
pageNumber={pageNumber}
width={pageWidth}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
</Document>
{visibleMarkers.map(({ marker, index }) => (
<MarkerOverlay
key={index}
marker={marker}
onMouseDown={onMarkerMouseDown(index, 'move')}
onResize={(kind) => onMarkerMouseDown(index, kind)}
onContextMenu={(e) => {
e.preventDefault();
removeMarker(index);
}}
/>
))}
{pending && pending.page === pageNumber ? (
<div
style={{
position: 'absolute',
left: `${pending.x * 100}%`,
top: `${pending.y * 100}%`,
width: `${DEFAULT_MARKER_W * 100}%`,
height: `${DEFAULT_MARKER_H * 100}%`,
}}
className="pointer-events-none rounded border-2 border-amber-500 bg-amber-500/15"
/>
) : null}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
{pending ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">New marker</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">Merge token</Label>
<Select value={pendingToken} onValueChange={setPendingToken}>
<SelectTrigger>
<SelectValue placeholder="Pick a token…" />
</SelectTrigger>
<SelectContent className="max-h-72">
{TOKEN_OPTIONS.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-between gap-2">
<Button variant="outline" size="sm" onClick={cancelPending}>
<X className="mr-1.5 h-3.5 w-3.5" /> Cancel
</Button>
<Button size="sm" disabled={!pendingToken} onClick={commitPending}>
Add marker
</Button>
</div>
</CardContent>
</Card>
) : null}
{unplacedRequired.length > 0 ? (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-amber-700">
Required tokens unplaced ({unplacedRequired.length})
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-xs">
{unplacedRequired.map((t) => (
<li key={t} className="font-mono text-amber-900">
{t}
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
Markers on page {pageNumber} ({visibleMarkers.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{visibleMarkers.length === 0 ? (
<p className="text-xs text-muted-foreground">
Click on the PDF to drop your first marker.
</p>
) : (
visibleMarkers.map(({ marker, index }) => (
<div
key={index}
className="flex items-center justify-between gap-2 rounded border px-2 py-1 text-xs"
>
<span className="font-mono">{marker.token}</span>
<button
type="button"
onClick={() => removeMarker(index)}
className="text-muted-foreground hover:text-destructive"
aria-label={`Remove ${marker.token} marker`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-1">
<Eye className="h-3.5 w-3.5" /> Live preview
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Label className="text-xs">Interest ID</Label>
<input
type="text"
value={previewInterestId}
onChange={(e) => setPreviewInterestId(e.target.value)}
placeholder="Paste an interest UUID"
className="w-full rounded-md border bg-background px-2 py-1 text-xs"
/>
<Button
size="sm"
variant="outline"
onClick={generatePreview}
disabled={previewLoading || !previewInterestId}
className="w-full"
>
{previewLoading ? 'Rendering…' : 'Render preview'}
</Button>
{previewUrl ? (
<a
href={previewUrl}
target="_blank"
rel="noreferrer"
className="block text-center text-xs text-primary hover:underline"
>
Open preview PDF
</a>
) : null}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function clamp(v: number, lo: number, hi: number): number {
return Math.min(hi, Math.max(lo, v));
}
function MarkerOverlay({
marker,
onMouseDown,
onResize,
onContextMenu,
}: {
marker: FieldMapEntry;
onMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
onResize: (kind: DragKind) => (e: React.MouseEvent<HTMLDivElement>) => void;
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => void;
}) {
const corners: Array<{ kind: DragKind; pos: string; cursor: string }> = [
{
kind: 'resize-nw',
pos: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2',
cursor: 'nwse-resize',
},
{
kind: 'resize-ne',
pos: 'top-0 right-0 translate-x-1/2 -translate-y-1/2',
cursor: 'nesw-resize',
},
{
kind: 'resize-sw',
pos: 'bottom-0 left-0 -translate-x-1/2 translate-y-1/2',
cursor: 'nesw-resize',
},
{
kind: 'resize-se',
pos: 'bottom-0 right-0 translate-x-1/2 translate-y-1/2',
cursor: 'nwse-resize',
},
];
return (
<div
data-marker-handle="true"
style={{
position: 'absolute',
left: `${marker.x * 100}%`,
top: `${marker.y * 100}%`,
width: `${(marker.w ?? DEFAULT_MARKER_W) * 100}%`,
height: `${(marker.h ?? DEFAULT_MARKER_H) * 100}%`,
}}
className="cursor-move rounded border-2 border-primary/70 bg-primary/15 px-1 py-0.5 text-xs font-medium text-primary"
onMouseDown={onMouseDown}
onContextMenu={onContextMenu}
>
<span className="pointer-events-none select-none">{marker.token}</span>
{corners.map((c) => (
<div
key={c.kind}
data-marker-handle="true"
onMouseDown={onResize(c.kind)}
className={`absolute h-2 w-2 rounded-sm bg-primary ${c.pos}`}
style={{ cursor: c.cursor }}
/>
))}
</div>
);
}