Edit/View on the left, three widths in the middle
The bar is three groups now: how you are looking at it, at what width, and what to do with it. The middle is centred against the BAR rather than against its neighbours, because the outer two change width as sections come and go and a middle that drifted with them would never be in the same place twice. A section OPTS IN, the same way it opts into Save. Most have no preview — Media is a file browser, Administration is a connection form, Messages is an inbox — and offering to view any of them at tablet width offers something that means nothing. A control that does nothing teaches people that controls do nothing. The widths are REAL viewport sizes and the frame is SCALED to fit, never squeezed into whatever the panel has. That distinction is the whole point: an iframe given the panel's width has the site's own media queries answering the PANEL's size, so "Desktop" in a narrow panel renders the tablet layout and labels it desktop — the one thing a preview must not do. Never scaled up either: a 390px phone blown up to fill a wide panel is a phone nobody has, and every judgement made against it is wrong in the direction of "looks fine". 390 is the iPhone width slots.ts already assumes. 834 is iPad portrait, deliberately above the site's md: breakpoint at 768 so it shows the desktop nav rather than the drawer — 768 exactly sits on the boundary and is the least informative width available. 1440 is past the 1280 the content column caps at, so the margins either side are visible rather than implied. View gives the preview the whole panel. That is what makes it worth having: at edit height a phone is a letterbox, and a phone is the width somebody most wants to look at properly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+94
-8
@@ -1,5 +1,5 @@
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import { Check, Eye, Loader2, Monitor, Pencil, Smartphone, Tablet } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DEFAULT_SECTION, isSectionKey, sectionFor, sectionOpen, type SectionKey } from './sections';
|
||||
import { AdministrationPanel } from './AdministrationPanel';
|
||||
@@ -8,6 +8,7 @@ import { GalleriesPanel } from './sections/GalleriesPanel';
|
||||
import { LayoutPanel } from './sections/LayoutPanel';
|
||||
import { PublishButton } from './PublishButton';
|
||||
import { SaveTargetProvider, useSaveButton } from './SaveTarget';
|
||||
import { DEVICES, ViewModeProvider, useViewMode, type DeviceName, type Mode } from './ViewMode';
|
||||
import { MessagesPanel } from './MessagesPanel';
|
||||
import { PlannedSection } from './PlannedSection';
|
||||
import { useReady } from './useReady';
|
||||
@@ -79,6 +80,72 @@ const SaveButton = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Editing, or just looking.
|
||||
*
|
||||
* Two buttons rather than one that toggles, so the current state is readable without knowing what the
|
||||
* label means. A single button saying "View" has to be interpreted — is that what it is, or what it
|
||||
* would become? — and that question is asked every time.
|
||||
*/
|
||||
const ModeToggle = () => {
|
||||
const { previewable, mode, setMode } = useViewMode();
|
||||
if (!previewable) return null;
|
||||
|
||||
const options: { value: Mode; label: string; Icon: typeof Pencil }[] = [
|
||||
{ value: 'edit', label: 'Edit', Icon: Pencil },
|
||||
{ value: 'view', label: 'View', Icon: Eye },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
|
||||
{options.map(({ value, label, Icon }) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant={mode === value ? 'secondary' : 'ghost'}
|
||||
className="h-5 gap-1 px-2 text-xs"
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The width the preview is rendered at. See DEVICES for why these three and not others. */
|
||||
const DeviceToggle = () => {
|
||||
const { previewable, device, setDevice } = useViewMode();
|
||||
if (!previewable) return null;
|
||||
|
||||
const options: { value: DeviceName; Icon: typeof Monitor }[] = [
|
||||
{ value: 'desktop', Icon: Monitor },
|
||||
{ value: 'tablet', Icon: Tablet },
|
||||
{ value: 'mobile', Icon: Smartphone },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
|
||||
{options.map(({ value, Icon }) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant={device === value ? 'secondary' : 'ghost'}
|
||||
className="h-5 gap-1 px-2 text-xs"
|
||||
onClick={() => setDevice(value)}
|
||||
// The width is in the tooltip rather than the label. It is the thing somebody occasionally
|
||||
// needs and never needs three of at once, and three widths in a row is a row of numbers.
|
||||
title={`${DEVICES[value].label} — ${DEVICES[value].width}px`}
|
||||
>
|
||||
<Icon className="size-3" />
|
||||
<span className="sr-only">{DEVICES[value].label}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DetailPanel = () => {
|
||||
const [params] = useSearchParams();
|
||||
const raw = params.get('section');
|
||||
@@ -120,14 +187,33 @@ export const DetailPanel = () => {
|
||||
plugin and to the site it administers.
|
||||
*/}
|
||||
<SaveTargetProvider>
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-border px-4 py-2">
|
||||
{/* Left of Publish, and in that order for a reason: save what you wrote, then make it
|
||||
public. A row that put them the other way round would read as the sequence backwards. */}
|
||||
<SaveButton />
|
||||
<PublishButton />
|
||||
</div>
|
||||
<ViewModeProvider>
|
||||
{/*
|
||||
Three groups: how you are looking at it, at what width, and what to do with it.
|
||||
|
||||
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
|
||||
The middle one is centred against the BAR rather than against what is beside it — the two
|
||||
outer groups change width as sections come and go, and a middle that drifted with them
|
||||
would never be in the same place twice.
|
||||
*/}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-2">
|
||||
<div className="flex flex-1 justify-start">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<DeviceToggle />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-end gap-2">
|
||||
{/* Left of Publish, and in that order for a reason: save what you wrote, then make it
|
||||
public. A row that put them the other way round would read as the sequence backwards. */}
|
||||
<SaveButton />
|
||||
<PublishButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">{Panel ? <Panel /> : <PlannedSection section={current} />}</div>
|
||||
</ViewModeProvider>
|
||||
</SaveTargetProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* How the open section is being looked at: editing or just viewing, and at what width.
|
||||
*
|
||||
* ── Why a context, and the same shape as SaveTarget ──
|
||||
*
|
||||
* The controls live in DetailPanel's bar and the thing they control lives in the section. DetailPanel
|
||||
* deliberately knows nothing about the sections beyond a map of keys to components, so the section
|
||||
* announces itself rather than being handed props — exactly as it does for Save.
|
||||
*
|
||||
* ── Why a section has to OPT IN ──
|
||||
*
|
||||
* Most sections have no preview. Media is a file browser, Administration is a connection form, Messages
|
||||
* is an inbox — offering to view any of them at tablet width is offering something that means nothing,
|
||||
* and a control that does nothing teaches people that controls do nothing.
|
||||
*
|
||||
* So the bar shows these only when the open section says it has a preview, the same way Save renders
|
||||
* nothing when there is nothing to save.
|
||||
*/
|
||||
|
||||
export type Mode = 'edit' | 'view';
|
||||
export type DeviceName = 'desktop' | 'tablet' | 'mobile';
|
||||
|
||||
/**
|
||||
* The widths a preview is rendered at, and why these numbers.
|
||||
*
|
||||
* They are REAL viewport sizes, and the preview is scaled down to fit rather than being given whatever
|
||||
* width the panel happens to have. That distinction is the whole point: a "Desktop" preview squeezed
|
||||
* into a 900px panel would render the site's tablet layout and call it desktop, which is precisely the
|
||||
* lie a preview exists to prevent.
|
||||
*
|
||||
* mobile 390 — the iPhone width the site's own slot table already assumes (see media/slots.ts,
|
||||
* where MOBILE is 342: this minus the layout's 48px of padding)
|
||||
* tablet 834 — iPad portrait, and deliberately ABOVE the site's `md:` breakpoint at 768, so it
|
||||
* shows the desktop navigation rather than the drawer. A tablet at 768 exactly sits
|
||||
* on the boundary and is the least informative width available.
|
||||
* desktop 1440 — a common laptop, comfortably past the 1280 the content column caps at, so the
|
||||
* margins either side are visible rather than implied.
|
||||
*/
|
||||
export const DEVICES: Record<DeviceName, { label: string; width: number; height: number }> = {
|
||||
desktop: { label: 'Desktop', width: 1440, height: 900 },
|
||||
tablet: { label: 'Tablet', width: 834, height: 1112 },
|
||||
mobile: { label: 'Mobile', width: 390, height: 844 },
|
||||
};
|
||||
|
||||
type ViewModeValue = {
|
||||
mode: Mode;
|
||||
device: DeviceName;
|
||||
setMode: (mode: Mode) => void;
|
||||
setDevice: (device: DeviceName) => void;
|
||||
/** True while a section with a preview is open. The bar shows its controls only then. */
|
||||
previewable: boolean;
|
||||
announcePreviewable: (has: boolean) => void;
|
||||
};
|
||||
|
||||
const ViewModeContext = createContext<ViewModeValue>({
|
||||
mode: 'edit',
|
||||
device: 'desktop',
|
||||
setMode: () => {},
|
||||
setDevice: () => {},
|
||||
previewable: false,
|
||||
announcePreviewable: () => {},
|
||||
});
|
||||
|
||||
export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [mode, setMode] = useState<Mode>('edit');
|
||||
const [device, setDevice] = useState<DeviceName>('desktop');
|
||||
const [previewable, setPreviewable] = useState(false);
|
||||
|
||||
// Stable, so the effect in `usePreviewable` is not re-run by this provider re-rendering.
|
||||
const announcePreviewable = useCallback((has: boolean) => setPreviewable(has), []);
|
||||
|
||||
return (
|
||||
<ViewModeContext.Provider value={{ mode, device, setMode, setDevice, previewable, announcePreviewable }}>
|
||||
{children}
|
||||
</ViewModeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/** Read by a section: how it is being looked at. */
|
||||
export const useViewMode = (): ViewModeValue => useContext(ViewModeContext);
|
||||
|
||||
/**
|
||||
* Called by a section that HAS a preview, so the bar offers the controls.
|
||||
*
|
||||
* The cleanup clears it, so a section that unmounts leaves no controls behind for a preview that is no
|
||||
* longer on screen.
|
||||
*/
|
||||
export const usePreviewable = (): void => {
|
||||
const { announcePreviewable } = useContext(ViewModeContext);
|
||||
useEffect(() => {
|
||||
announcePreviewable(true);
|
||||
return () => announcePreviewable(false);
|
||||
}, [announcePreviewable]);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { useClient } from 'hooks/useClient';
|
||||
import { readableError } from '../errors';
|
||||
import { useFlash } from '../useFlash';
|
||||
import { useSaveTarget } from '../SaveTarget';
|
||||
import { DEVICES, usePreviewable, useViewMode } from '../ViewMode';
|
||||
|
||||
/**
|
||||
* The shell every page renders inside: the menu, the footer, the colours and the typefaces.
|
||||
@@ -141,9 +142,27 @@ export const LayoutPanel = () => {
|
||||
};
|
||||
|
||||
useSaveTarget(dirty, busy, save);
|
||||
// This section has a preview, so the bar offers Edit/View and the three widths.
|
||||
usePreviewable();
|
||||
const { mode } = useViewMode();
|
||||
|
||||
if (!draft) return <div className="p-4 text-xs text-muted-foreground">{error ?? 'Loading…'}</div>;
|
||||
|
||||
/*
|
||||
Viewing: the preview and nothing else, filling the panel.
|
||||
|
||||
Not the same thing as hiding the controls. The preview is given the whole height, which is what
|
||||
makes View worth having — at edit height a phone is a letterbox, and a phone is the width somebody
|
||||
most wants to look at properly.
|
||||
*/
|
||||
if (mode === 'view') {
|
||||
return (
|
||||
<div className="flex size-full flex-col p-4">
|
||||
<LayoutPreview port={devPort} fill />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col gap-4 overflow-auto p-4">
|
||||
{saved && <p className="text-xs text-success">Saved</p>}
|
||||
@@ -244,7 +263,7 @@ export const LayoutPanel = () => {
|
||||
* was the other option and it is a guess about where the browser kept it; the platform tried that and
|
||||
* says so in its own comments.
|
||||
*/
|
||||
const LayoutPreview = ({ port }: { port: number | null }) => {
|
||||
const LayoutPreview = ({ port, fill }: { port: number | null; fill?: boolean }) => {
|
||||
const client = useClient();
|
||||
/*
|
||||
Through a ref, and this is the whole reason the preview hung.
|
||||
@@ -260,9 +279,35 @@ const LayoutPreview = ({ port }: { port: number | null }) => {
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const { device } = useViewMode();
|
||||
const { width, height } = DEVICES[device];
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState<string | null>(null);
|
||||
|
||||
/*
|
||||
How much room there is, so the frame can be SCALED rather than squeezed.
|
||||
|
||||
The iframe is always the real device width — 390, 834, 1440 — and shrunk to fit with a transform.
|
||||
Giving it the panel's width instead would mean the site's own media queries answered the panel's
|
||||
size, so "Desktop" in a narrow panel would render the tablet layout and label it desktop. That is
|
||||
the one thing a preview must not do.
|
||||
|
||||
Measured rather than assumed because the panel is resizable and the workspace splits it.
|
||||
*/
|
||||
const box = useRef<HTMLDivElement | null>(null);
|
||||
const [available, setAvailable] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const element = box.current;
|
||||
if (!element) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (entry) setAvailable({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!port) return;
|
||||
let alive = true;
|
||||
@@ -283,26 +328,55 @@ const LayoutPreview = ({ port }: { port: number | null }) => {
|
||||
: !ready ? 'Connecting…'
|
||||
: null;
|
||||
|
||||
/*
|
||||
Never scaled UP. A 390px phone blown up to fill a wide panel is a phone nobody has, and every
|
||||
judgement made against it — how big that headline is, how much fits above the fold — would be wrong
|
||||
in the direction of "looks fine".
|
||||
*/
|
||||
const scale = available.width ? Math.min(1, available.width / width) : 1;
|
||||
|
||||
// In View the frame gets the panel's height; editing, a fixed band, so the controls below stay put
|
||||
// instead of moving every time somebody switches device.
|
||||
const shownHeight = fill ? Math.max(0, available.height) : Math.min(height * scale, 420);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="overflow-hidden rounded-lg border border-border bg-white">
|
||||
{message ? (
|
||||
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
|
||||
) : (
|
||||
/*
|
||||
No `sandbox`, for the reason the platform's own Preview gives: a sandbox WITHOUT
|
||||
allow-same-origin has an opaque origin and no storage, and one WITH allow-same-origin and
|
||||
allow-scripts is worth nothing, because the frame can reach up and remove the attribute.
|
||||
This is the owner's own development server on this machine.
|
||||
*/
|
||||
<iframe
|
||||
src={`/api/dev-proxy/${port}/layout`}
|
||||
title="The site's layout"
|
||||
className="h-[420px] w-full border-0"
|
||||
/>
|
||||
)}
|
||||
<section className={`flex flex-col gap-2 ${fill ? 'min-h-0 flex-1' : ''}`}>
|
||||
<div ref={box} className={`min-h-0 ${fill ? 'flex-1' : ''}`}>
|
||||
<div
|
||||
className="mx-auto overflow-hidden rounded-lg border border-border bg-white"
|
||||
// The scaled footprint, so the border hugs the frame and the page below does not jump when
|
||||
// the device changes.
|
||||
style={{ width: Math.min(width * scale, available.width || width), height: shownHeight }}
|
||||
>
|
||||
{message ? (
|
||||
<p className="p-6 text-center text-xs text-muted-foreground">{message}</p>
|
||||
) : (
|
||||
/*
|
||||
No `sandbox`, for the reason the platform's own Preview gives: a sandbox WITHOUT
|
||||
allow-same-origin has an opaque origin and therefore no storage, and one WITH
|
||||
allow-same-origin and allow-scripts is worth nothing, because the frame can reach up and
|
||||
remove the attribute. This is the owner's own development server on this machine.
|
||||
*/
|
||||
<iframe
|
||||
src={`/api/dev-proxy/${port}/layout`}
|
||||
title="The site's layout"
|
||||
className="border-0"
|
||||
style={{
|
||||
width,
|
||||
height: shownHeight / scale,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">The development server, live. Save to see a change here.</p>
|
||||
|
||||
{!fill && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
The development server, live at {width}px. Save to see a change here.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user