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>
821 lines
30 KiB
TypeScript
821 lines
30 KiB
TypeScript
'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>
|
||
);
|
||
}
|