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>
This commit is contained in:
@@ -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 (
|
||||||
|
<>
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+14
-48
@@ -1,12 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { FolderOpen, Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { useFlash } from '../useFlash';
|
import { useFlash } from '../useFlash';
|
||||||
import { RichText } from '../RichText';
|
import { RichText } from '../RichText';
|
||||||
import { MediaPicker } from '../MediaPicker';
|
import { MediaField } from '../MediaField';
|
||||||
import { useSaveTarget } from '../SaveTarget';
|
import { useSaveTarget } from '../SaveTarget';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,9 +47,6 @@ type About = {
|
|||||||
manifesto_text: string | null;
|
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 = () => {
|
export const AboutPanel = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const clientRef = useRef(client);
|
const clientRef = useRef(client);
|
||||||
@@ -65,7 +62,6 @@ export const AboutPanel = () => {
|
|||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [saved, flashSaved] = useFlash();
|
const [saved, flashSaved] = useFlash();
|
||||||
const [picking, setPicking] = useState<Picking>(null);
|
|
||||||
|
|
||||||
/** Every write answers with the whole payload, so this is the only place state is set. */
|
/** Every write answers with the whole payload, so this is the only place state is set. */
|
||||||
const apply = useCallback((data: About) => {
|
const apply = useCallback((data: About) => {
|
||||||
@@ -154,20 +150,10 @@ export const AboutPanel = () => {
|
|||||||
<p className="text-xs text-muted-foreground">The photograph at the top, and the line over it.</p>
|
<p className="text-xs text-muted-foreground">The photograph at the top, and the line over it.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chosen from originals/, never typed. The same picker Home's hero uses — a path somebody
|
{/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two
|
||||||
types is a path that is wrong by one character and renders as a broken image. */}
|
have to agree or the admin shows a shape the site does not have, which is worse than showing
|
||||||
<div className="flex items-center gap-2">
|
no shape at all: it looks decided. */}
|
||||||
<Input
|
<MediaField value={headerUrl || null} onChange={(path) => setHeaderUrl(path ?? '')} aspect="21/9" label="header photograph" />
|
||||||
value={headerUrl}
|
|
||||||
readOnly
|
|
||||||
placeholder="No photograph chosen"
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setPicking('header')}>
|
|
||||||
<FolderOpen className="size-3.5" />
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs text-muted-foreground">Phrase</label>
|
<label className="text-xs text-muted-foreground">Phrase</label>
|
||||||
@@ -191,18 +177,14 @@ export const AboutPanel = () => {
|
|||||||
<p className="text-xs text-muted-foreground">What you make, and a video of it being made.</p>
|
<p className="text-xs text-muted-foreground">What you make, and a video of it being made.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* 16/9, matching the `aspect-video` the product block draws. */}
|
||||||
<Input
|
<MediaField
|
||||||
value={productVideo}
|
value={productVideo || null}
|
||||||
readOnly
|
onChange={(path) => setProductVideo(path ?? '')}
|
||||||
placeholder="No video chosen"
|
aspect="16/9"
|
||||||
className="font-mono text-xs"
|
kind="video"
|
||||||
/>
|
label="product video"
|
||||||
<Button variant="outline" size="sm" className="shrink-0" onClick={() => setPicking('product')}>
|
/>
|
||||||
<FolderOpen className="size-3.5" />
|
|
||||||
Browse
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RichText value={productText} onChange={setProductText} placeholder="What the product is." />
|
<RichText value={productText} onChange={setProductText} placeholder="What the product is." />
|
||||||
</section>
|
</section>
|
||||||
@@ -218,22 +200,6 @@ export const AboutPanel = () => {
|
|||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
{/*
|
|
||||||
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/.
|
|
||||||
*/}
|
|
||||||
<MediaPicker
|
|
||||||
open={picking !== null}
|
|
||||||
onClose={() => 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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 `<img>` 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)}`;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user