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>
This commit is contained in:
+30
-14
@@ -7,6 +7,7 @@ import { readableError } from '../errors';
|
||||
import { useFlash } from '../useFlash';
|
||||
import { RichText } from '../RichText';
|
||||
import { MediaField } from '../MediaField';
|
||||
import { OverlayEditor, type Overlay } from '../OverlayEditor';
|
||||
import { useSaveTarget } from '../SaveTarget';
|
||||
|
||||
/**
|
||||
@@ -40,7 +41,14 @@ import { useSaveTarget } from '../SaveTarget';
|
||||
*/
|
||||
|
||||
type About = {
|
||||
header: { url: string | null; crop: string | null; fit: string | null; phrase: string | null };
|
||||
header: {
|
||||
url: string | null;
|
||||
crop: string | null;
|
||||
fit: string | null;
|
||||
phrase: string | null;
|
||||
/** Text laid over the picture — the wireframe's two blocks. */
|
||||
overlays: Overlay[];
|
||||
};
|
||||
about_text: string | null;
|
||||
product_video_url: string | null;
|
||||
product_text: string | null;
|
||||
@@ -56,6 +64,7 @@ export const AboutPanel = () => {
|
||||
const [headerUrl, setHeaderUrl] = useState('');
|
||||
const [phrase, setPhrase] = useState('');
|
||||
const [headerCrop, setHeaderCrop] = useState<string | null>(null);
|
||||
const [overlays, setOverlays] = useState<Overlay[]>([]);
|
||||
const [aboutText, setAboutText] = useState('');
|
||||
const [productVideo, setProductVideo] = useState('');
|
||||
const [productText, setProductText] = useState('');
|
||||
@@ -69,6 +78,7 @@ export const AboutPanel = () => {
|
||||
setAbout(data);
|
||||
setHeaderUrl(data.header.url ?? '');
|
||||
setHeaderCrop(data.header.crop);
|
||||
setOverlays(data.header.overlays ?? []);
|
||||
setPhrase(data.header.phrase ?? '');
|
||||
setAboutText(data.about_text ?? '');
|
||||
setProductVideo(data.product_video_url ?? '');
|
||||
@@ -95,6 +105,7 @@ export const AboutPanel = () => {
|
||||
const edited = {
|
||||
header_url: orNull(headerUrl),
|
||||
header_crop: headerCrop,
|
||||
header_overlays: overlays,
|
||||
header_phrase: orNull(phrase),
|
||||
about_text: orNull(aboutText),
|
||||
product_video_url: orNull(productVideo),
|
||||
@@ -105,6 +116,7 @@ export const AboutPanel = () => {
|
||||
const stored = about && {
|
||||
header_url: about.header.url,
|
||||
header_crop: about.header.crop,
|
||||
header_overlays: about.header.overlays ?? [],
|
||||
header_phrase: about.header.phrase,
|
||||
about_text: about.about_text,
|
||||
product_video_url: about.product_video_url,
|
||||
@@ -148,19 +160,23 @@ export const AboutPanel = () => {
|
||||
{/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two
|
||||
have to agree or the admin shows a shape the site does not have, which is worse than showing
|
||||
no shape at all: it looks decided. */}
|
||||
<MediaField
|
||||
value={headerUrl || null}
|
||||
onChange={(path) => {
|
||||
setHeaderUrl(path ?? '');
|
||||
// A framing belongs to the picture it was made for. Keeping it across a change would apply
|
||||
// one photograph's decision to another one's composition.
|
||||
setHeaderCrop(null);
|
||||
}}
|
||||
crop={headerCrop}
|
||||
onCropChange={setHeaderCrop}
|
||||
aspect="21/9"
|
||||
label="header photograph"
|
||||
/>
|
||||
{/* The picture is the editor's canvas: the overlays are positioned against the same box the
|
||||
page draws, so what is arranged here is what is published. */}
|
||||
<OverlayEditor overlays={overlays} onChange={setOverlays}>
|
||||
<MediaField
|
||||
value={headerUrl || null}
|
||||
onChange={(path) => {
|
||||
setHeaderUrl(path ?? '');
|
||||
// A framing belongs to the picture it was made for. Keeping it across a change would
|
||||
// apply one photograph's decision to another one's composition.
|
||||
setHeaderCrop(null);
|
||||
}}
|
||||
crop={headerCrop}
|
||||
onCropChange={setHeaderCrop}
|
||||
aspect="21/9"
|
||||
label="header photograph"
|
||||
/>
|
||||
</OverlayEditor>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Phrase</label>
|
||||
|
||||
Reference in New Issue
Block a user