diff --git a/web/MediaPicker.tsx b/web/MediaPicker.tsx new file mode 100644 index 0000000..77fe9e0 --- /dev/null +++ b/web/MediaPicker.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from 'react'; +import { FolderOpen, X } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import { Button } from '@/components/ui/button'; +import { FileBrowserApp, type PickedFile } from 'officerdev'; + +/** + * Choose a piece of media by browsing `originals/`. + * + * ── The browser is the platform's, not a copy of it ── + * + * `FileBrowserApp` in pick mode: folders navigate, opening a file hands it back. Building a picker + * instead would mean reimplementing a listing, a breadcrumb, sorting, thumbnails, upload and keyboard + * handling — all of it free to drift from the real browser, and none of it better. + * + * ── It browses ORIGINALS and the site renders DERIVED ── + * + * What somebody picks is the file they uploaded: a `.mov`, a 12-megapixel HEIC, whatever came off the + * camera. What a browser can display is the ladder av-club built beside it. So the value stored is the + * path of the ORIGINAL, and everything that renders resolves it into `derived//`. + * + * That indirection is the whole point of the media pipeline. Storing a derived file directly would pin + * the site to one rendition — and the day the ladder changes, or a better encoder arrives, every stored + * reference would name a file that is no longer the right one to serve. + * + * ── The path is relative to `originals/` ── + * + * Not absolute. The root moves between machines — it is a setting — so an absolute path stored in the + * site's database would be a fact about one installation written into content that outlives it. + */ +export const MediaPicker = ({ + open, + onClose, + onPick, + title = 'Choose media', +}: { + open: boolean; + onClose: () => void; + /** The path relative to `originals/`, e.g. `GALLERY/CALAVERA 1/piece.jpg`. */ + onPick: (relativePath: string) => void; + title?: string; +}) => { + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + + const [base, setBase] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + void (async () => { + try { + /* + The originals folder, home-relative. + + `/_config/root` reports `rootRelative` — the root expressed against the owner's home, which is + what the file browser addresses everything by. Null means the root is outside home, which is a + legitimate configuration the browser simply cannot reach. + */ + const settings = await clientRef.current.get<{ rootRelative: string | null }>('/fofos-admin/_config/root'); + if (!settings.rootRelative) { + setError('The site root is outside your home directory, so the file browser cannot reach it.'); + return; + } + setBase(`${settings.rootRelative.replace(/\/$/, '')}/media/originals`); + } catch { + setError('Could not work out where the media is. Only the owner can read that setting.'); + } + })(); + }, [open]); + + // Escape closes, because a modal that only closes by a button is a modal somebody gets stuck in. + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > +
+
+ +

{title}

+ {/* Said here rather than discovered: a person picking a .mov should know the site will not + serve that file itself. */} + + pick the original — the site serves the converted copy + +
+ +
+ +
+ {error ? ( +

{error}

+ ) : base ? ( + { + // `file.path` is absolute-ish within the browser's root; the stored value is relative + // to originals, so the base is stripped here rather than by every caller. + const relative = file.path.startsWith(base) ? file.path.slice(base.length) : file.path; + onPick(relative.replace(/^\/+/, '')); + onClose(); + }} + /> + ) : ( +

Loading…

+ )} +
+
+
+ ); +}; diff --git a/web/sections/HomePanel.tsx b/web/sections/HomePanel.tsx index 8bcdd90..24acbbf 100644 --- a/web/sections/HomePanel.tsx +++ b/web/sections/HomePanel.tsx @@ -1,11 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { ArrowDown, ArrowUp, Check, ImagePlus, Loader2, Trash2 } from 'lucide-react'; +import { ArrowDown, ArrowUp, Check, FolderOpen, ImagePlus, Loader2, Trash2 } 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'; /** * The Home page: a hero, an intro, and an ordered gallery. @@ -58,6 +59,7 @@ export const HomePanel = () => { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [saved, flashSaved] = useFlash(); + const [picking, setPicking] = useState(false); /** Every write answers with the whole payload, so this is the only place state is set. */ const apply = useCallback((data: Home) => { @@ -185,13 +187,28 @@ export const HomePanel = () => {

The video or image across the top of the page.

- setHeroUrl(event.target.value)} - placeholder="/media/…" - className="font-mono text-xs" - autoComplete="off" - spellCheck={false} + {/* + A Browse button, not a field. + + Nobody knows the path of a file they uploaded from a phone, and a text box invites a typo that + fails silently as a broken image on the live site. Browsing `originals/` means the only values + that can be stored are ones that exist. + */} +
+

+ {heroUrl || Nothing chosen} +

+ +
+ + setPicking(false)} + onPick={setHeroUrl} + title="Choose the hero" /> {/* The server clears the crop when the hero changes, because a rectangle describing a region of a different picture would crop the new one somewhere arbitrary. Said here so it is not a