'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 (
Loading template…
);
}
// 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 ;
}
function TemplateEditorBody({
templateId,
template,
}: {
templateId: string;
template: TemplateData;
}) {
const qc = useQueryClient();
const [markers, setMarkers] = useState(template.overlayPositions ?? []);
const [pending, setPending] = useState(null);
const [pendingToken, setPendingToken] = useState('');
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState(null);
const pageContainerRef = useRef(null);
const pdfUrl = template.sourceFileId ? `/api/v1/files/${template.sourceFileId}/preview` : null;
function handlePageClick(e: React.MouseEvent) {
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 (
This template has no source PDF attached. Upload one from the template list before
opening the editor.
);
}
return (
Page 1
{/* 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. */}
Loading PDF…
}
onLoadError={(err) => {
// Surface load errors via toast rather than blowing up
// — a bad source PDF shouldn't crash the editor shell.
toastError(err);
}}
>
{visibleMarkers.map((m, i) => (