a media slot can be framed by dragging and zooming
A photograph is rarely the shape of the slot it goes in, so something decides which part survives. It was a centred crop and no way to change it. Dragging the picture slides the window; the wheel zooms, anchored to the pointer so the thing under the cursor stays under it. Both write the site's own format — `x,y,w,h` in percentages of the ORIGINAL — which is resolution-independent and survives the file being re-encoded. Zooming in is the window getting smaller: w and h shrink together, keeping the slot's shape. Fit is the floor, because you cannot show more of a photograph than there is, and at exactly fit the crop is cleared rather than written as the centred value — a null crop and a crop that happens to be centred should not be two states. ── The preview is not object-cover ── It draws the rectangle: the picture scaled to `100/w` of the box and offset so the chosen corner lands at the origin. object-cover shows the largest window of the slot's shape that fits and nothing else — it can be panned and it cannot be zoomed, so a preview built on it would silently ignore half of what this control produces. ── Also here ── buildSite now hands the site an av-club URL and user. That is the first piece of publishing images cropped rather than cropping them in the browser with CSS: the plugin knows the sidecar registry, the site knows a URL. Inert until the site uses it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ import { stat, readdir, writeFile } from 'node:fs/promises';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { db } from 'officerdb/db';
|
import { db } from 'officerdb/db';
|
||||||
import { fofosAdminSettings } from '../db/schema';
|
import { fofosAdminSettings } from '../db/schema';
|
||||||
|
import { sidecarPort } from '@@/sidecar-registry';
|
||||||
|
import { getOwnerUser } from 'officerdb';
|
||||||
import { readDatabases } from './databases';
|
import { readDatabases } from './databases';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,6 +141,12 @@ export async function buildSite(secrets: {
|
|||||||
if (!row?.rootPath) throw new Error('No root location is set.');
|
if (!row?.rootPath) throw new Error('No root location is set.');
|
||||||
|
|
||||||
const websiteDir = join(row.rootPath, 'website');
|
const websiteDir = join(row.rootPath, 'website');
|
||||||
|
// Absent when av-club is not installed. The build is told, rather than left to fail on a fetch to a
|
||||||
|
// port that is not there — and it can then say so plainly instead of timing out.
|
||||||
|
const avClubPort = sidecarPort('av-club');
|
||||||
|
// av-club resolves every path against a user's own media directory, so the build has to say who it
|
||||||
|
// is working for. The owner, because that is whose root location this plugin administers.
|
||||||
|
const owner = await getOwnerUser();
|
||||||
const databases = await readDatabases();
|
const databases = await readDatabases();
|
||||||
if (!databases.production.name || !databases.production.exists) {
|
if (!databases.production.name || !databases.production.exists) {
|
||||||
throw new Error('The production database does not exist yet.');
|
throw new Error('The production database does not exist yet.');
|
||||||
@@ -160,6 +168,18 @@ export async function buildSite(secrets: {
|
|||||||
FOFOS_API_TOKEN: secrets.apiToken,
|
FOFOS_API_TOKEN: secrets.apiToken,
|
||||||
ADMIN_SESSION_SECRET: secrets.sessionSecret,
|
ADMIN_SESSION_SECRET: secrets.sessionSecret,
|
||||||
FOFOS_MEDIA_DIR: join(row.rootPath, 'media'),
|
FOFOS_MEDIA_DIR: join(row.rootPath, 'media'),
|
||||||
|
/*
|
||||||
|
How the build reaches av-club.
|
||||||
|
|
||||||
|
The build crops each placement to the exact shape and size its slot renders at, rather than
|
||||||
|
shipping a whole photograph and asking CSS to hide most of it. That needs an encoder, and the
|
||||||
|
rule is that every encoder is av-club — so the plugin, which knows the sidecar registry, hands
|
||||||
|
the site a URL and the site knows nothing else about the platform.
|
||||||
|
|
||||||
|
`x-officer-user` is av-club's whole authentication, and the build has to send it: the sidecar
|
||||||
|
resolves paths against that user's own media directory.
|
||||||
|
*/
|
||||||
|
...(avClubPort && owner?.email ? { AV_CLUB_URL: `http://127.0.0.1:${avClubPort}`, AV_CLUB_USER: owner?.email ?? '' } : {}),
|
||||||
// Only used to build absolute URLs in the output — og:image, canonical links. The real domain
|
// Only used to build absolute URLs in the output — og:image, canonical links. The real domain
|
||||||
// belongs in the checkout's own env, where it is a deployment fact rather than one this invented.
|
// belongs in the checkout's own env, where it is a deployment fact rather than one this invented.
|
||||||
PUBLIC_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',
|
PUBLIC_URL: process.env.FOFOS_PUBLIC_URL ?? 'http://127.0.0.1',
|
||||||
|
|||||||
+197
-2
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ImageOff, Pencil, Trash2, Upload } from 'lucide-react';
|
import { ImageOff, Pencil, Trash2, Upload } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MediaPicker } from './MediaPicker';
|
import { MediaPicker } from './MediaPicker';
|
||||||
@@ -31,13 +31,69 @@ import { posterUrl, thumbUrl } from './thumbnail';
|
|||||||
*
|
*
|
||||||
* It does NOT accept a file dragged from the desktop. That would be an upload into `originals/`, which
|
* 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.
|
* is a different operation with its own failure modes, and the Media section already does it properly.
|
||||||
|
*
|
||||||
|
* ── Dragging the picture itself frames it ──
|
||||||
|
*
|
||||||
|
* A photograph is rarely the shape of the slot it goes in, so something has to decide which part
|
||||||
|
* survives. The site's answer is a `crop`, stored as `x,y,w,h` in percentages of the ORIGINAL, which
|
||||||
|
* `render` turns into an `object-position`.
|
||||||
|
*
|
||||||
|
* Here that is a drag. The visible window is the largest rectangle of the slot's shape that fits inside
|
||||||
|
* the picture — so one axis has slack and the other has none — and dragging slides the window along
|
||||||
|
* whichever axis has room. A picture already the right shape does not move, correctly: there is nothing
|
||||||
|
* to choose between.
|
||||||
|
*
|
||||||
|
* Written in the picture's own coordinates rather than in pixels, because the slot is a different size
|
||||||
|
* on every screen and the crop has to mean the same thing on all of them.
|
||||||
|
*
|
||||||
|
* ── And the wheel zooms ──
|
||||||
|
*
|
||||||
|
* Zooming in is the window getting smaller: `w` and `h` shrink together, keeping the slot's shape, so
|
||||||
|
* less of the picture fills the same box. Fit is the floor — you cannot show more of a photograph than
|
||||||
|
* there is — and it is anchored to the pointer, so the thing under the cursor stays under the cursor.
|
||||||
|
* Anchoring to the centre instead is fewer lines and feels wrong the moment you use it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** `x,y,w,h` in percentages of the original. The site's format — see parseCrop in media/render.ts. */
|
||||||
|
type Crop = { x: number; y: number; w: number; h: number };
|
||||||
|
|
||||||
|
const parseCrop = (value: string | null | undefined): Crop | null => {
|
||||||
|
const parts = (value ?? '').split(',').map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
|
||||||
|
const [x, y, w, h] = parts as [number, number, number, number];
|
||||||
|
return w > 0 && h > 0 ? { x, y, w, h } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCrop = (crop: Crop): string =>
|
||||||
|
[crop.x, crop.y, crop.w, crop.h].map((n) => Math.round(n * 10) / 10).join(',');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window the slot shows of a picture, centred, when nothing has been chosen.
|
||||||
|
*
|
||||||
|
* The largest rectangle of the SLOT's ratio that fits inside the picture. One of the two dimensions is
|
||||||
|
* always 100 — you cannot show more of a picture than there is.
|
||||||
|
*/
|
||||||
|
const centredCrop = (imageAspect: number, slotAspect: number): Crop =>
|
||||||
|
imageAspect > slotAspect
|
||||||
|
? { x: (100 - (slotAspect / imageAspect) * 100) / 2, y: 0, w: (slotAspect / imageAspect) * 100, h: 100 }
|
||||||
|
: { x: 0, y: (100 - (imageAspect / slotAspect) * 100) / 2, w: 100, h: (imageAspect / slotAspect) * 100 };
|
||||||
|
|
||||||
|
/** Crop to CSS. The same arithmetic as cropToPosition in the site's media/render.ts, and it must stay so. */
|
||||||
|
const toObjectPosition = (crop: Crop): string => {
|
||||||
|
const along = (start: number, size: number) => {
|
||||||
|
const remaining = 100 - size;
|
||||||
|
return remaining <= 0 ? 50 : Math.min(100, Math.max(0, (start / remaining) * 100));
|
||||||
|
};
|
||||||
|
return `${along(crop.x, crop.w).toFixed(1)}% ${along(crop.y, crop.h).toFixed(1)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
const DRAG_TYPE = 'application/x-officer-files';
|
const DRAG_TYPE = 'application/x-officer-files';
|
||||||
|
|
||||||
export const MediaField = ({
|
export const MediaField = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
crop,
|
||||||
|
onCropChange,
|
||||||
aspect,
|
aspect,
|
||||||
kind = 'photo',
|
kind = 'photo',
|
||||||
label,
|
label,
|
||||||
@@ -45,6 +101,9 @@ export const MediaField = ({
|
|||||||
/** A path under `originals/`, or null. */
|
/** A path under `originals/`, or null. */
|
||||||
value: string | null;
|
value: string | null;
|
||||||
onChange: (path: string | null) => void;
|
onChange: (path: string | null) => void;
|
||||||
|
/** `x,y,w,h` in percentages of the original, or null for centred. Omit to make the slot un-framable. */
|
||||||
|
crop?: string | null;
|
||||||
|
onCropChange?: (crop: string | null) => void;
|
||||||
/** Tailwind's ratio, as the page spells it — `21/9`, `16/9`, `1/1`. */
|
/** Tailwind's ratio, as the page spells it — `21/9`, `16/9`, `1/1`. */
|
||||||
aspect: string;
|
aspect: string;
|
||||||
/** A video is shown by its poster; there is no width rung to ask for. */
|
/** A video is shown by its poster; there is no width rung to ask for. */
|
||||||
@@ -55,6 +114,110 @@ export const MediaField = ({
|
|||||||
const [picking, setPicking] = useState(false);
|
const [picking, setPicking] = useState(false);
|
||||||
const [over, setOver] = useState(false);
|
const [over, setOver] = useState(false);
|
||||||
const [broken, setBroken] = useState(false);
|
const [broken, setBroken] = useState(false);
|
||||||
|
/** The picture's own proportions, known only once it has loaded. Null until then. */
|
||||||
|
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
||||||
|
const box = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dragging = useRef<{ x: number; y: number; crop: Crop } | null>(null);
|
||||||
|
|
||||||
|
const slotAspect = (() => {
|
||||||
|
const [w, h] = aspect.split('/').map(Number);
|
||||||
|
return w && h ? w / h : 1;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const framable = Boolean(onCropChange && natural && !broken);
|
||||||
|
const current: Crop | null = natural
|
||||||
|
? (parseCrop(crop) ?? centredCrop(natural.w / natural.h, slotAspect))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Pointer events, not mouse — one code path for a trackpad, a touchscreen and a pen. `setPointerCapture`
|
||||||
|
is what keeps the drag alive when the pointer leaves the box, which it will: the whole gesture is
|
||||||
|
about reaching the part of the picture that is currently outside.
|
||||||
|
*/
|
||||||
|
const onPointerDown = (event: React.PointerEvent) => {
|
||||||
|
if (!framable || !current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
(event.target as Element).setPointerCapture?.(event.pointerId);
|
||||||
|
dragging.current = { x: event.clientX, y: event.clientY, crop: current };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (event: React.PointerEvent) => {
|
||||||
|
const start = dragging.current;
|
||||||
|
const rect = box.current?.getBoundingClientRect();
|
||||||
|
if (!start || !rect || !onCropChange) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Pixels to picture-percent.
|
||||||
|
|
||||||
|
The picture is drawn `100 / w` times wider than the slot, so a pixel of travel is worth
|
||||||
|
`100 / (renderedWidth)` percent of it. Dragging RIGHT reveals what is to the LEFT, which is why
|
||||||
|
the delta is subtracted.
|
||||||
|
*/
|
||||||
|
const renderedW = rect.width * (100 / start.crop.w);
|
||||||
|
const renderedH = rect.height * (100 / start.crop.h);
|
||||||
|
const next: Crop = {
|
||||||
|
...start.crop,
|
||||||
|
x: start.crop.x - ((event.clientX - start.x) / renderedW) * 100,
|
||||||
|
y: start.crop.y - ((event.clientY - start.y) / renderedH) * 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clamped so the window cannot leave the picture and show empty space at an edge.
|
||||||
|
next.x = Math.min(100 - next.w, Math.max(0, next.x));
|
||||||
|
next.y = Math.min(100 - next.h, Math.max(0, next.y));
|
||||||
|
onCropChange(formatCrop(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
dragging.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Wheel to zoom, on a NON-PASSIVE listener.
|
||||||
|
|
||||||
|
React attaches `onWheel` passively, and a passive listener may not call preventDefault — so the
|
||||||
|
zoom would work and scroll the panel at the same time. The only way to get a cancellable wheel
|
||||||
|
event is to add it by hand with `{ passive: false }`.
|
||||||
|
|
||||||
|
The handler is rebuilt whenever the crop changes, which is what keeps it reading the current window
|
||||||
|
rather than the one that existed when the picture loaded.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const element = box.current;
|
||||||
|
if (!element || !framable || !current || !onCropChange || !natural) return;
|
||||||
|
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const base = centredCrop(natural.w / natural.h, slotAspect);
|
||||||
|
|
||||||
|
// Where the pointer is in the picture, before anything moves. This is the point that must not.
|
||||||
|
const fx = (event.clientX - rect.left) / rect.width;
|
||||||
|
const fy = (event.clientY - rect.top) / rect.height;
|
||||||
|
const px = current.x + fx * current.w;
|
||||||
|
const py = current.y + fy * current.h;
|
||||||
|
|
||||||
|
// A tenth per notch, and the sign follows the platform's own convention for "away is in".
|
||||||
|
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||||
|
// Fit is the floor and 8x is a sane ceiling: past it a rendition has no pixels left to show.
|
||||||
|
const zoom = Math.min(8, Math.max(1, (base.w / current.w) * factor));
|
||||||
|
|
||||||
|
const next: Crop = {
|
||||||
|
w: base.w / zoom,
|
||||||
|
h: base.h / zoom,
|
||||||
|
x: px - fx * (base.w / zoom),
|
||||||
|
y: py - fy * (base.h / zoom),
|
||||||
|
};
|
||||||
|
next.x = Math.min(100 - next.w, Math.max(0, next.x));
|
||||||
|
next.y = Math.min(100 - next.h, Math.max(0, next.y));
|
||||||
|
|
||||||
|
// At fit exactly there is nothing to remember, so the crop is cleared rather than written as the
|
||||||
|
// centred value. A null crop and a crop that happens to be centred should not be two states.
|
||||||
|
onCropChange(zoom <= 1.001 ? null : formatCrop(next));
|
||||||
|
};
|
||||||
|
|
||||||
|
element.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => element.removeEventListener('wheel', onWheel);
|
||||||
|
}, [framable, current, onCropChange, natural, slotAspect]);
|
||||||
|
|
||||||
const preview = value ? (kind === 'video' ? posterUrl(value) : thumbUrl(value, 640)) : null;
|
const preview = value ? (kind === 'video' ? posterUrl(value) : thumbUrl(value, 640)) : null;
|
||||||
|
|
||||||
@@ -72,6 +235,7 @@ export const MediaField = ({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
// `group` so the controls can appear on hover of the whole slot rather than of themselves.
|
// `group` so the controls can appear on hover of the whole slot rather than of themselves.
|
||||||
|
ref={box}
|
||||||
className={`group relative w-full overflow-hidden rounded-md border ${
|
className={`group relative w-full overflow-hidden rounded-md border ${
|
||||||
over ? 'border-primary bg-primary/5' : 'border-dashed border-border'
|
over ? 'border-primary bg-primary/5' : 'border-dashed border-border'
|
||||||
} ${value ? 'border-solid' : ''}`}
|
} ${value ? 'border-solid' : ''}`}
|
||||||
@@ -98,7 +262,38 @@ export const MediaField = ({
|
|||||||
<img
|
<img
|
||||||
src={preview}
|
src={preview}
|
||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover"
|
/*
|
||||||
|
Positioned and scaled to show exactly the crop rectangle, rather than object-cover with an
|
||||||
|
object-position.
|
||||||
|
|
||||||
|
object-cover always shows the LARGEST window of the slot's shape that fits — which is what
|
||||||
|
a crop with no zoom is, and nothing else. It can be panned and it cannot be zoomed, so a
|
||||||
|
preview built on it would silently ignore half of what this control produces.
|
||||||
|
|
||||||
|
Width `100/w` of the box means "the picture is this many times wider than the window"; the
|
||||||
|
negative offsets slide the chosen corner to the box's origin. It is the crop rectangle,
|
||||||
|
drawn.
|
||||||
|
*/
|
||||||
|
className={`absolute max-w-none touch-none ${framable ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||||
|
style={
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
width: `${(100 / current.w) * 100}%`,
|
||||||
|
height: `${(100 / current.h) * 100}%`,
|
||||||
|
left: `${-(current.x / current.w) * 100}%`,
|
||||||
|
top: `${-(current.y / current.h) * 100}%`,
|
||||||
|
}
|
||||||
|
: { width: '100%', height: '100%', objectFit: 'cover' }
|
||||||
|
}
|
||||||
|
draggable={false}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onLoad={(event) => {
|
||||||
|
const img = event.currentTarget;
|
||||||
|
setNatural({ w: img.naturalWidth, h: img.naturalHeight });
|
||||||
|
}}
|
||||||
// A ladder that has not been built yet, or a file removed since it was chosen. Falls back
|
// 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.
|
// to the empty state rather than a browser's broken-image glyph, which says nothing.
|
||||||
onError={() => setBroken(true)}
|
onError={() => setBroken(true)}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const AboutPanel = () => {
|
|||||||
const [about, setAbout] = useState<About | null>(null);
|
const [about, setAbout] = useState<About | null>(null);
|
||||||
const [headerUrl, setHeaderUrl] = useState('');
|
const [headerUrl, setHeaderUrl] = useState('');
|
||||||
const [phrase, setPhrase] = useState('');
|
const [phrase, setPhrase] = useState('');
|
||||||
|
const [headerCrop, setHeaderCrop] = useState<string | null>(null);
|
||||||
const [aboutText, setAboutText] = useState('');
|
const [aboutText, setAboutText] = useState('');
|
||||||
const [productVideo, setProductVideo] = useState('');
|
const [productVideo, setProductVideo] = useState('');
|
||||||
const [productText, setProductText] = useState('');
|
const [productText, setProductText] = useState('');
|
||||||
@@ -67,6 +68,7 @@ export const AboutPanel = () => {
|
|||||||
const apply = useCallback((data: About) => {
|
const apply = useCallback((data: About) => {
|
||||||
setAbout(data);
|
setAbout(data);
|
||||||
setHeaderUrl(data.header.url ?? '');
|
setHeaderUrl(data.header.url ?? '');
|
||||||
|
setHeaderCrop(data.header.crop);
|
||||||
setPhrase(data.header.phrase ?? '');
|
setPhrase(data.header.phrase ?? '');
|
||||||
setAboutText(data.about_text ?? '');
|
setAboutText(data.about_text ?? '');
|
||||||
setProductVideo(data.product_video_url ?? '');
|
setProductVideo(data.product_video_url ?? '');
|
||||||
@@ -92,6 +94,7 @@ export const AboutPanel = () => {
|
|||||||
/** Every field this page owns, as the API spells them. One place, so save and dirty cannot disagree. */
|
/** Every field this page owns, as the API spells them. One place, so save and dirty cannot disagree. */
|
||||||
const edited = {
|
const edited = {
|
||||||
header_url: orNull(headerUrl),
|
header_url: orNull(headerUrl),
|
||||||
|
header_crop: headerCrop,
|
||||||
header_phrase: orNull(phrase),
|
header_phrase: orNull(phrase),
|
||||||
about_text: orNull(aboutText),
|
about_text: orNull(aboutText),
|
||||||
product_video_url: orNull(productVideo),
|
product_video_url: orNull(productVideo),
|
||||||
@@ -101,6 +104,7 @@ export const AboutPanel = () => {
|
|||||||
|
|
||||||
const stored = about && {
|
const stored = about && {
|
||||||
header_url: about.header.url,
|
header_url: about.header.url,
|
||||||
|
header_crop: about.header.crop,
|
||||||
header_phrase: about.header.phrase,
|
header_phrase: about.header.phrase,
|
||||||
about_text: about.about_text,
|
about_text: about.about_text,
|
||||||
product_video_url: about.product_video_url,
|
product_video_url: about.product_video_url,
|
||||||
@@ -153,7 +157,19 @@ export const AboutPanel = () => {
|
|||||||
{/* 21/9 because that is what the page uses — `aspect-[21/9]` in about/partials.tsx. The two
|
{/* 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
|
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. */}
|
no shape at all: it looks decided. */}
|
||||||
<MediaField value={headerUrl || null} onChange={(path) => setHeaderUrl(path ?? '')} aspect="21/9" label="header photograph" />
|
<MediaField
|
||||||
|
value={headerUrl || null}
|
||||||
|
onChange={(path) => {
|
||||||
|
setHeaderUrl(path ?? '');
|
||||||
|
// A framing belongs to the picture it was made for. Keeping it across a change would apply
|
||||||
|
// one photograph's decision to another one's composition.
|
||||||
|
setHeaderCrop(null);
|
||||||
|
}}
|
||||||
|
crop={headerCrop}
|
||||||
|
onCropChange={setHeaderCrop}
|
||||||
|
aspect="21/9"
|
||||||
|
label="header photograph"
|
||||||
|
/>
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user