diff --git a/web/DetailPanel.tsx b/web/DetailPanel.tsx index 41088b4..cadfcf8 100644 --- a/web/DetailPanel.tsx +++ b/web/DetailPanel.tsx @@ -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 ( +
+ {options.map(({ value, label, Icon }) => ( + + ))} +
+ ); +}; + +/** 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 ( +
+ {options.map(({ value, Icon }) => ( + + ))} +
+ ); +}; + 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. */} -
- {/* 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. */} - - -
+ + {/* + Three groups: how you are looking at it, at what width, and what to do with it. -
{Panel ? : }
+ 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. + */} +
+
+ +
+ +
+ +
+ +
+ {/* 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. */} + + +
+
+ +
{Panel ? : }
+
); diff --git a/web/ViewMode.tsx b/web/ViewMode.tsx new file mode 100644 index 0000000..61cadac --- /dev/null +++ b/web/ViewMode.tsx @@ -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 = { + 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({ + mode: 'edit', + device: 'desktop', + setMode: () => {}, + setDevice: () => {}, + previewable: false, + announcePreviewable: () => {}, +}); + +export const ViewModeProvider = ({ children }: { children: ReactNode }) => { + const [mode, setMode] = useState('edit'); + const [device, setDevice] = useState('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 ( + + {children} + + ); +}; + +/** 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]); +}; diff --git a/web/sections/LayoutPanel.tsx b/web/sections/LayoutPanel.tsx index 8968de3..88cbf99 100644 --- a/web/sections/LayoutPanel.tsx +++ b/web/sections/LayoutPanel.tsx @@ -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
{error ?? 'Loading…'}
; + /* + 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 ( +
+ +
+ ); + } + return (
{saved &&

Saved

} @@ -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(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(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 ( -
-
- {message ? ( -

{message}

- ) : ( - /* - 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. - */ -