diff --git a/web/MediaField.tsx b/web/MediaField.tsx new file mode 100644 index 0000000..fa9e50f --- /dev/null +++ b/web/MediaField.tsx @@ -0,0 +1,171 @@ +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 ( + <> +
{ + 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 ? ( + setBroken(true)} + /> + ) : ( + + )} + + {/* + 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 && ( +
+ + +
+ )} +
+ + {/* 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 &&

{value}

} + + setPicking(false)} + title={label ? `Choose the ${label}` : 'Choose media'} + onPick={(path) => { + setPicking(false); + setBroken(false); + onChange(path); + }} + /> + + ); +}; diff --git a/web/sections/AboutPanel.tsx b/web/sections/AboutPanel.tsx index 6faa565..6aa48a1 100644 --- a/web/sections/AboutPanel.tsx +++ b/web/sections/AboutPanel.tsx @@ -1,12 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { FolderOpen, Loader2 } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { readableError } from '../errors'; import { useFlash } from '../useFlash'; import { RichText } from '../RichText'; -import { MediaPicker } from '../MediaPicker'; +import { MediaField } from '../MediaField'; import { useSaveTarget } from '../SaveTarget'; /** @@ -47,9 +47,6 @@ type About = { manifesto_text: string | null; }; -/** Which media slot the picker is open for, or null. The two cannot be open at once. */ -type Picking = 'header' | 'product' | null; - export const AboutPanel = () => { const client = useClient(); const clientRef = useRef(client); @@ -65,7 +62,6 @@ export const AboutPanel = () => { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [saved, flashSaved] = useFlash(); - const [picking, setPicking] = useState(null); /** Every write answers with the whole payload, so this is the only place state is set. */ const apply = useCallback((data: About) => { @@ -154,20 +150,10 @@ export const AboutPanel = () => {

The photograph at the top, and the line over it.

- {/* Chosen from originals/, never typed. The same picker Home's hero uses — a path somebody - types is a path that is wrong by one character and renders as a broken image. */} -
- - -
+ {/* 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. */} + setHeaderUrl(path ?? '')} aspect="21/9" label="header photograph" />
@@ -191,18 +177,14 @@ export const AboutPanel = () => {

What you make, and a video of it being made.

-
- - -
+ {/* 16/9, matching the `aspect-video` the product block draws. */} + setProductVideo(path ?? '')} + aspect="16/9" + kind="video" + label="product video" + /> @@ -218,22 +200,6 @@ export const AboutPanel = () => { {error &&

{error}

} - {/* - One picker, two slots. - - `picking` names which one asked, so the chosen path lands in the right field. Two pickers would - be two copies of a modal that already knows how to browse originals/. - */} - setPicking(null)} - title={picking === 'product' ? 'Choose the product video' : 'Choose the header photograph'} - onPick={(path) => { - if (picking === 'product') setProductVideo(path); - else setHeaderUrl(path); - setPicking(null); - }} - /> ); }; diff --git a/web/thumbnail.ts b/web/thumbnail.ts new file mode 100644 index 0000000..9dd3dd2 --- /dev/null +++ b/web/thumbnail.ts @@ -0,0 +1,34 @@ +import { getHeaders } from 'hooks/useClient'; + +/** + * A thumbnail URL for something under `originals/`, authenticated by `?token=`. + * + * ── Why the token is in the query ── + * + * An `` cannot send an Authorization header. `user-middleware` and the permission gate both accept + * the token in the query string for exactly this, and the platform's own file browser does the same. + * Built without it first, and every picture 401s while the layout looks perfectly correct. + * + * ── Why this is a module ── + * + * It was copied into GalleriesPanel and again into RecordsPanel, and a third copy was about to go into + * MediaField. 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. + */ +export const thumbUrl = (path: string, width: 256 | 320 | 480 | 640 = 320): string => { + const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? ''; + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return `/api/fofos-admin/derived/${encoded}/${width}.webp?token=${encodeURIComponent(token)}`; +}; + +/** + * A video's poster, for the same reason. + * + * A video has no width rungs — `poster-448.webp` and friends are cut from its still — so a slot showing + * one asks for the poster rather than a width that does not exist. + */ +export const posterUrl = (path: string): string => { + const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? ''; + const encoded = path.split('/').map(encodeURIComponent).join('/'); + return `/api/fofos-admin/derived/${encoded}/poster-512.webp?token=${encodeURIComponent(token)}`; +};