321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useMemo, useRef, useState } from 'react';
|
||
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||
|
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||
|
|
import { Loader2, Save, Trash2, 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;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface PendingMarker {
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
page: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
const TOKEN_OPTIONS = Array.from(VALID_MERGE_TOKENS).sort();
|
||
|
|
const DEFAULT_MARKER_W = 0.18;
|
||
|
|
const DEFAULT_MARKER_H = 0.04;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Phase 7.1 — page-1 PDF marker editor. Click anywhere on the rendered
|
||
|
|
* PDF to drop a marker, pick which merge token it represents, save.
|
||
|
|
*
|
||
|
|
* Scope intentionally narrow:
|
||
|
|
* - Page 1 only (multi-page page-picker is a 7.2 ticket).
|
||
|
|
* - Add + delete markers; drag-to-move + corner-resize defer to 7.2.
|
||
|
|
* - Coordinates stored as percent of page width/height so a future
|
||
|
|
* page-size swap (A4 ↔ Letter) doesn't shift placements.
|
||
|
|
*/
|
||
|
|
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.
|
||
|
|
// Avoids the in-render setState pattern (React anti-pattern) that
|
||
|
|
// a single component would otherwise need 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 ?? []);
|
||
|
|
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 pageContainerRef = useRef<HTMLDivElement | null>(null);
|
||
|
|
|
||
|
|
const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null;
|
||
|
|
|
||
|
|
function handlePageClick(e: React.MouseEvent<HTMLDivElement>) {
|
||
|
|
const container = pageContainerRef.current;
|
||
|
|
if (!container) 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: 1 });
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
|
||
|
|
async function save() {
|
||
|
|
setSaving(true);
|
||
|
|
setSavedMsg(null);
|
||
|
|
try {
|
||
|
|
await apiFetch(`/api/v1/document-templates/${templateId}`, {
|
||
|
|
method: 'PATCH',
|
||
|
|
body: { overlayPositions: markers },
|
||
|
|
});
|
||
|
|
await qc.invalidateQueries({ queryKey: ['document-template', templateId] });
|
||
|
|
setSavedMsg('Markers saved.');
|
||
|
|
} catch (err) {
|
||
|
|
toastError(err);
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const visibleMarkers = useMemo(() => markers.filter((m) => m.page === 1), [markers]);
|
||
|
|
|
||
|
|
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 from the template list before
|
||
|
|
opening the editor.
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<PageHeader
|
||
|
|
title={`Edit "${template.name}"`}
|
||
|
|
description="Click anywhere on the page to drop a merge-field marker. Phase 1: page 1, click-to-place. Drag/resize/preview lands later."
|
||
|
|
/>
|
||
|
|
|
||
|
|
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-2">
|
||
|
|
<CardTitle className="text-sm">Page 1</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{/* Wrapper carries the click handler. react-pdf renders the
|
||
|
|
actual page inside; we overlay markers as positioned
|
||
|
|
divs using the same percent coordinates the server-side
|
||
|
|
fill path consumes. */}
|
||
|
|
<div
|
||
|
|
ref={pageContainerRef}
|
||
|
|
onClick={handlePageClick}
|
||
|
|
className="relative inline-block cursor-crosshair select-none"
|
||
|
|
>
|
||
|
|
<Document
|
||
|
|
file={pdfUrl}
|
||
|
|
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) => {
|
||
|
|
// Surface load errors via toast rather than blowing up
|
||
|
|
// — a bad source PDF shouldn't crash the editor shell.
|
||
|
|
toastError(err);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Page
|
||
|
|
pageNumber={1}
|
||
|
|
width={680}
|
||
|
|
renderAnnotationLayer={false}
|
||
|
|
renderTextLayer={false}
|
||
|
|
/>
|
||
|
|
</Document>
|
||
|
|
|
||
|
|
{visibleMarkers.map((m, i) => (
|
||
|
|
<div
|
||
|
|
key={i}
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: `${m.x * 100}%`,
|
||
|
|
top: `${m.y * 100}%`,
|
||
|
|
width: `${(m.w ?? DEFAULT_MARKER_W) * 100}%`,
|
||
|
|
height: `${(m.h ?? DEFAULT_MARKER_H) * 100}%`,
|
||
|
|
}}
|
||
|
|
className="pointer-events-none rounded border-2 border-primary/70 bg-primary/15 px-1 py-0.5 text-[10px] font-medium text-primary"
|
||
|
|
>
|
||
|
|
{m.token}
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{pending ? (
|
||
|
|
<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 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}
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader className="pb-2">
|
||
|
|
<CardTitle className="text-sm">Markers ({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((m, i) => (
|
||
|
|
<div
|
||
|
|
key={i}
|
||
|
|
className="flex items-center justify-between gap-2 rounded border px-2 py-1 text-xs"
|
||
|
|
>
|
||
|
|
<span className="font-mono">{m.token}</span>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => removeMarker(markers.indexOf(m))}
|
||
|
|
className="text-muted-foreground hover:text-destructive"
|
||
|
|
aria-label={`Remove ${m.token} marker`}
|
||
|
|
>
|
||
|
|
<Trash2 className="h-3.5 w-3.5" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Button onClick={save} disabled={saving} className="w-full">
|
||
|
|
<Save className="mr-1.5 h-4 w-4" />
|
||
|
|
{saving ? 'Saving…' : 'Save markers'}
|
||
|
|
</Button>
|
||
|
|
{savedMsg ? <p className="text-center text-xs text-emerald-700">{savedMsg}</p> : null}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|