Files
fofos-admin/web/MediaField.tsx
T
pastilhasandClaude Opus 5 9d3cd7702f a media slot is the picture, at the shape the page will show it
It was a read-only text box holding a path and a Browse button beside it. A path is not what anybody is
choosing — somebody picking the header photograph is choosing a PHOTOGRAPH, and reading
`ABOUT US/HEADER.jpg` to find out whether you picked the right one is doing the computer's job for it.

MediaField is the slot: empty, it is a dashed box at the page's own aspect ratio that you click or drop
onto; filled, it is the picture, with Change and Remove appearing over it on hover. The panel also stops
changing height as things are chosen, so a page with three slots starts to look like the page rather
than like a form.

── The aspect ratio is a prop and has to be ──

The header is 21:9, the product video 16:9, a gallery slot square. The component cannot know that and
must not guess: the SITE decides what shape a slot is, and a component that guessed would be a second
opinion to keep in step with the stylesheet. Both callers name the ratio their partial draws.

── Dropping ──

It accepts a drag from Officer's own file browser, which carries paths under
`application/x-officer-files` — the same payload that moves files between windows. So dragging a
photograph out of Media onto a slot is one gesture and no upload.

It does not accept a file from the desktop. That is an upload into originals/, a different operation
with its own failure modes, and the Media section already does it properly.

── Also ──

`thumbUrl` was copied into GalleriesPanel and RecordsPanel and was about to be copied a third time. It
is a module now: three copies of a URL shape is three places to be wrong about the rung when the ladder
changes, which it did four times in a day.

A file that has gone since it was chosen falls back to the empty state rather than a browser's
broken-image glyph, and says so.

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

172 lines
6.6 KiB
TypeScript

import { useState } from 'react';
import { ImageOff, Pencil, Trash2, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { MediaPicker } from './MediaPicker';
import { posterUrl, thumbUrl } from './thumbnail';
/**
* One picture or video on the page, shown at the shape the page will show it.
*
* ── Why not a path in a text box ──
*
* It was one, and a path is not what anybody is choosing. Somebody picking the header photograph is
* choosing a PHOTOGRAPH; `ABOUT US/HEADER.jpg` is how the machine refers to it, and reading a string to
* find out whether you picked the right image is doing the computer's job for it.
*
* So the field is the picture, at the aspect ratio the page uses. An empty one is the same box, empty —
* which also means the panel stops changing height as things are chosen, and a page with three slots
* looks like the page rather than like a form.
*
* ── The aspect ratio is a prop, and has to be ──
*
* The header is 21:9, the product video is 16:9, a gallery slot is square. This component cannot know
* that and must not guess: it is the SITE that decides what shape a slot is, and a component that
* guessed would be a second opinion to keep in step with the stylesheet.
*
* ── Dropping ──
*
* It accepts a drag from Officer's own file browser, which carries paths under
* `application/x-officer-files`. That is the same payload the file browser uses to move files between
* windows, so dragging a photograph out of Media and onto a slot is one gesture with no upload.
*
* It does NOT accept a file dragged from the desktop. That would be an upload into `originals/`, which
* is a different operation with its own failure modes, and the Media section already does it properly.
*/
const DRAG_TYPE = 'application/x-officer-files';
export const MediaField = ({
value,
onChange,
aspect,
kind = 'photo',
label,
}: {
/** A path under `originals/`, or null. */
value: string | null;
onChange: (path: string | null) => void;
/** Tailwind's ratio, as the page spells it — `21/9`, `16/9`, `1/1`. */
aspect: string;
/** A video is shown by its poster; there is no width rung to ask for. */
kind?: 'photo' | 'video';
/** What the picker's title says, so it names the thing rather than saying "media". */
label?: string;
}) => {
const [picking, setPicking] = useState(false);
const [over, setOver] = useState(false);
const [broken, setBroken] = useState(false);
const preview = value ? (kind === 'video' ? posterUrl(value) : thumbUrl(value, 640)) : null;
const accept = (event: React.DragEvent): string | null => {
if (!event.dataTransfer.types.includes(DRAG_TYPE)) return null;
try {
const paths = JSON.parse(event.dataTransfer.getData(DRAG_TYPE));
return Array.isArray(paths) && typeof paths[0] === 'string' ? paths[0] : null;
} catch {
return null;
}
};
return (
<>
<div
// `group` so the controls can appear on hover of the whole slot rather than of themselves.
className={`group relative w-full overflow-hidden rounded-md border ${
over ? 'border-primary bg-primary/5' : 'border-dashed border-border'
} ${value ? 'border-solid' : ''}`}
style={{ aspectRatio: aspect.replace('/', ' / ') }}
onDragOver={(event) => {
if (!accept(event)) return;
// Without preventDefault the browser refuses the drop entirely — the one line whose absence
// makes a target look dead with no error anywhere.
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setOver(true);
}}
onDragLeave={() => setOver(false)}
onDrop={(event) => {
const path = accept(event);
setOver(false);
if (!path) return;
event.preventDefault();
setBroken(false);
onChange(path);
}}
>
{preview && !broken ? (
<img
src={preview}
alt=""
className="h-full w-full object-cover"
// A ladder that has not been built yet, or a file removed since it was chosen. Falls back
// to the empty state rather than a browser's broken-image glyph, which says nothing.
onError={() => setBroken(true)}
/>
) : (
<button
type="button"
onClick={() => setPicking(true)}
className="flex h-full w-full flex-col items-center justify-center gap-1.5 text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground"
>
{broken ? <ImageOff className="size-5" /> : <Upload className="size-5" />}
<span className="text-xs">
{broken ? 'That file is not there any more' : 'Click to choose, or drag one here'}
</span>
</button>
)}
{/*
The controls, over the picture, on hover.
Only when there IS one — on an empty slot the whole box is already a button, and a Change
button floating over "click to choose" would be two ways to do one thing.
`focus-within` as well as `group-hover`, so they can be reached by keyboard rather than only
by a pointer.
*/}
{value && !broken && (
<div className="absolute top-2 right-2 flex gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<Button
size="sm"
variant="secondary"
className="h-7 gap-1 px-2 text-xs shadow"
onClick={() => setPicking(true)}
>
<Pencil className="size-3" />
Change
</Button>
<Button
size="sm"
variant="secondary"
className="h-7 px-2 text-xs shadow hover:text-destructive"
onClick={() => {
setBroken(false);
onChange(null);
}}
title="Remove"
>
<Trash2 className="size-3" />
</Button>
</div>
)}
</div>
{/* The path, small and underneath. Not editable — it is what was chosen, not a field — but
somebody comparing two similar photographs needs to be able to tell which is which. */}
{value && <p className="truncate font-mono text-[11px] text-muted-foreground/70">{value}</p>}
<MediaPicker
open={picking}
onClose={() => setPicking(false)}
title={label ? `Choose the ${label}` : 'Choose media'}
onPick={(path) => {
setPicking(false);
setBroken(false);
onChange(path);
}}
/>
</>
);
};