import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; import { useSearchParams } from 'react-router'; /** * 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. * * ── Both live in the URL ── * * `?mode=edit&device=mobile`. I had argued the device should stay in React state — a way of LOOKING * rather than a thing being looked at — and that distinction did not survive contact with the obvious * use: "here is what it does on a phone" is exactly the link somebody wants to send, and a URL that * drops the width sends them the desktop. * * docs/navigation-audit.md says addressable state goes in the URL, and both of these are addressable. * The rule was already right; the exception was not. */ export type Mode = 'edit' | 'view'; export type DeviceName = 'desktop' | 'tablet' | 'mobile'; /** * The widths a preview is rendered at, and why these numbers. * * Widths ONLY. There were heights too, and they were wrong: media queries answer width, so a frame's * height changes how much page you see and never which layout you get. The preview takes whatever * height the panel has. * * 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 }, tablet: { label: 'Tablet', width: 834 }, mobile: { label: 'Mobile', width: 390 }, }; 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 [params, setParams] = useSearchParams(); /* The mode lives in the URL — `?mode=edit`. docs/navigation-audit.md draws the line: anything ADDRESSABLE goes in the URL. Whether you are editing a page or looking at it is exactly that. It survives a reload, it can be sent to somebody, and a link to a page being edited is a different thing from a link to the page. `mode`, not `view`. The parameter names the AXIS and the value names the state, so the pair reads as `mode=edit` and `mode=view` rather than the tautological `view=view`. Absent means View, which is the default and the common case — so an ordinary link carries nothing, and the parameter only appears once somebody has chosen. */ const mode: Mode = params.get('mode') === 'edit' ? 'edit' : 'view'; const setMode = useCallback( (next: Mode) => { const search = new URLSearchParams(params); // Written even for the default, once somebody has touched it: a URL that says `mode=view` is // unambiguous, and a toggle that sometimes leaves no trace reads as a toggle that failed. search.set('mode', next); setParams(search, { replace: true }); }, [params, setParams], ); /* The device, alongside the mode, for the same reason. Absent means desktop — the default and the common case — so an ordinary link carries neither parameter and each appears only once somebody has chosen it. */ const raw = params.get('device'); const device: DeviceName = raw === 'tablet' || raw === 'mobile' ? raw : 'desktop'; const setDevice = useCallback( (next: DeviceName) => { const search = new URLSearchParams(params); search.set('device', next); setParams(search, { replace: true }); }, [params, setParams], ); 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]); };