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" />
}
- {/*
- 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)}`;
+};