Files
fofos-admin/web/OverlayEditor.tsx
T
pastilhasandClaude Opus 5 d1e79d95db text over the header photograph, placed by dragging it
The wireframe puts two blocks of text on the header picture. The page had one `phrase`, drawn under it.

`header_overlays` is a jsonb list — `[{ text, x, y, size, color, align }]` — rather than a pair of
`header_text_1_*` columns. The design asks for two today; a third is then data rather than a migration,
and nothing has to decide what an empty second block means. `headerPhrase` is left alone: folding them
together would silently move somebody's existing copy on top of a photograph.

Everything is relative and has to be. x and y are percentages of the picture, so a position means the
same thing on a phone and a desktop. `size` is a percentage of the picture's WIDTH, because a headline
set at 40px is a headline on a laptop and a caption on a large screen. The preview renders that with
container query units against the picture's own box, which is exactly what the percentage means — the
page will do the same, so the two cannot drift.

The editor is the picture with the text on it, at the shape the page uses, because where a line sits on
a photograph is not a decision anybody can make from two number fields.

The API clamps every field rather than rejecting: a position into the picture, a size into something
legible, the colour to a hex triple. A caller sending nonsense gets a usable overlay instead of a 400,
and the page can never be handed a position that puts text off the edge of its own header.

Pushed to both databases and verified by querying information_schema rather than by reading db:push's
exit code, which is documented to lie.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 00:19:30 +01:00

182 lines
7.5 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.
import { useRef } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
/**
* Text laid over a picture, positioned by dragging it.
*
* ── Why it is drawn on the picture rather than listed beside it ──
*
* The thing being decided is where a line of text sits on a photograph, and that is not a decision
* anybody can make from two number fields. The editor is the picture with the text on it, at the shape
* the page uses, so what is arranged is what is published.
*
* ── Everything is relative, and has to be ──
*
* `x` and `y` are percentages of the picture, so a position means the same thing on a phone and a
* desktop. `size` is a percentage of the picture's WIDTH rather than pixels, for the same reason: a
* headline set at 40px is a headline on a laptop and a caption on a large screen.
*
* The preview renders that with container query units — `cqw` — against the box, which is exactly what
* the percentage means. The page will do the same, so the two cannot drift.
*/
export type Overlay = {
text: string;
/** Percent of the picture. The text's own anchor corner, per `align`. */
x: number;
y: number;
/** Percent of the picture's width. */
size: number;
color: string;
align: 'left' | 'center' | 'right';
};
export const emptyOverlay = (): Overlay => ({ text: '', x: 10, y: 70, size: 4, color: '#ffffff', align: 'left' });
export const OverlayEditor = ({
overlays,
onChange,
children,
}: {
overlays: Overlay[];
onChange: (overlays: Overlay[]) => void;
/** The picture. Rendered underneath, at whatever shape its own slot decides. */
children: React.ReactNode;
}) => {
const box = useRef<HTMLDivElement | null>(null);
const dragging = useRef<{ index: number; dx: number; dy: number } | null>(null);
const update = (index: number, patch: Partial<Overlay>) =>
onChange(overlays.map((overlay, i) => (i === index ? { ...overlay, ...patch } : overlay)));
const onPointerDown = (event: React.PointerEvent, index: number) => {
const rect = box.current?.getBoundingClientRect();
if (!rect) return;
event.preventDefault();
event.stopPropagation();
(event.target as Element).setPointerCapture?.(event.pointerId);
// The grab offset, so the text does not jump its own corner under the cursor on the first move.
dragging.current = {
index,
dx: ((event.clientX - rect.left) / rect.width) * 100 - overlays[index]!.x,
dy: ((event.clientY - rect.top) / rect.height) * 100 - overlays[index]!.y,
};
};
const onPointerMove = (event: React.PointerEvent) => {
const drag = dragging.current;
const rect = box.current?.getBoundingClientRect();
if (!drag || !rect) return;
update(drag.index, {
x: Math.min(100, Math.max(0, ((event.clientX - rect.left) / rect.width) * 100 - drag.dx)),
y: Math.min(100, Math.max(0, ((event.clientY - rect.top) / rect.height) * 100 - drag.dy)),
});
};
return (
<div className="flex flex-col gap-3">
{/*
`@container` so the overlays can be sized in `cqw` — a percentage of THIS box rather than of the
viewport. The picture is the thing text should scale with, and the panel is not the page width.
*/}
<div ref={box} className="@container relative" onPointerMove={onPointerMove} onPointerUp={() => (dragging.current = null)}>
{children}
{overlays.map((overlay, index) => (
<div
key={index}
onPointerDown={(event) => onPointerDown(event, index)}
className="absolute cursor-grab touch-none select-none whitespace-pre-wrap active:cursor-grabbing"
style={{
left: `${overlay.x}%`,
top: `${overlay.y}%`,
// The anchor follows the alignment, so a right-aligned block grows leftwards from where
// it was put rather than sliding out of the picture.
transform: overlay.align === 'right' ? 'translateX(-100%)' : overlay.align === 'center' ? 'translateX(-50%)' : undefined,
fontSize: `${overlay.size}cqw`,
color: overlay.color,
textAlign: overlay.align,
lineHeight: 1.15,
// Only so the text stays readable while it is being placed over a busy photograph. The
// page draws it without one — this is scaffolding, not styling.
textShadow: '0 1px 3px rgba(0,0,0,.45)',
}}
>
{overlay.text || 'Text'}
</div>
))}
</div>
{/* The controls, under the picture rather than floating over it. Two blocks means two rows, and a
popover per block would hide the thing being compared. */}
<div className="flex flex-col gap-2">
{overlays.map((overlay, index) => (
<div key={index} className="flex items-center gap-2 rounded-md border border-border p-2">
<Input
value={overlay.text}
placeholder="Text"
className="min-w-0 flex-1"
onChange={(event) => update(index, { text: event.currentTarget.value })}
/>
{/* Size as a number the person can reason about — 4 means "a twenty-fifth of the picture
wide" is not a thought anybody has, so it is presented as a plain step. */}
<div className="flex shrink-0 items-center gap-1">
<Button size="sm" variant="outline" className="size-7 p-0" onClick={() => update(index, { size: Math.max(0.5, Math.round((overlay.size - 0.5) * 10) / 10) })}>
−
</Button>
<span className="w-8 text-center text-xs tabular-nums text-muted-foreground">{overlay.size}</span>
<Button size="sm" variant="outline" className="size-7 p-0" onClick={() => update(index, { size: Math.min(20, Math.round((overlay.size + 0.5) * 10) / 10) })}>
+
</Button>
</div>
<select
value={overlay.align}
onChange={(event) => update(index, { align: event.currentTarget.value as Overlay['align'] })}
className="h-8 shrink-0 rounded-md border border-input bg-transparent px-2 text-xs"
>
<option value="left">Left</option>
<option value="center">Centre</option>
<option value="right">Right</option>
</select>
{/* A native colour input: it is the one control every platform already knows how to draw,
and a hand-rolled picker here would be worse on every one of them. */}
<input
type="color"
value={overlay.color}
onChange={(event) => update(index, { color: event.currentTarget.value })}
className="size-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5"
title="Colour"
/>
<Button
size="sm"
variant="ghost"
className="size-7 shrink-0 p-0 text-muted-foreground hover:text-destructive"
onClick={() => onChange(overlays.filter((_, i) => i !== index))}
title="Remove"
>
<Trash2 className="size-3.5" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
className="self-start"
onClick={() => onChange([...overlays, emptyOverlay()])}
disabled={overlays.length >= 8}
>
<Plus className="size-3.5" />
Add text
</Button>
</div>
</div>
);
};