preview panel keyed (515 -> 503)

Cancel comes from ui.button.cancel. Save has no ui.* home — nothing in the shared
components renders one — so it is panel.common.save rather than a new unused key in
the guarded phase-1 catalogue.
This commit is contained in:
2026-08-23 14:21:54 +00:00
parent 55e56ea652
commit c8fb89122e
2 changed files with 25 additions and 15 deletions
@@ -3,6 +3,7 @@ import { Globe } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import { normalizeUrl, usePreview } from './use-preview'; import { normalizeUrl, usePreview } from './use-preview';
import { useTranslation } from '../../locales';
/** /**
* A live app, in a panel. * A live app, in a panel.
@@ -19,6 +20,7 @@ import { normalizeUrl, usePreview } from './use-preview';
* machine; pretending otherwise with an attribute that does not hold is worse than not claiming it. * machine; pretending otherwise with an attribute that does not hold is worse than not claiming it.
*/ */
export const PreviewApp = ({ panelId }: { panelId: string }) => { export const PreviewApp = ({ panelId }: { panelId: string }) => {
const { t } = useTranslation();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { url, target, setUrl, reportLocation, reloadToken } = usePreview(panelId, workspace); const { url, target, setUrl, reportLocation, reloadToken } = usePreview(panelId, workspace);
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
@@ -34,7 +36,7 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
}} }}
> >
<Globe className="h-6 w-6 text-duck-dark/30" /> <Globe className="h-6 w-6 text-duck-dark/30" />
<span className="text-sm text-duck-dark/50">Point this panel at a running app</span> <span className="text-sm text-duck-dark/50">{t('preview.pointAtApp')}</span>
<input <input
autoFocus autoFocus
value={draft} value={draft}
@@ -47,11 +49,9 @@ export const PreviewApp = ({ panelId }: { panelId: string }) => {
disabled={!normalizeUrl(draft)} disabled={!normalizeUrl(draft)}
className="rounded-md bg-duck-teal px-3 py-1.5 text-sm text-duck-yellow transition-opacity hover:opacity-90 disabled:opacity-40" className="rounded-md bg-duck-teal px-3 py-1.5 text-sm text-duck-yellow transition-opacity hover:opacity-90 disabled:opacity-40"
> >
Open {t('preview.open')}
</button> </button>
<p className="text-xs text-duck-dark/40"> <p className="text-xs text-duck-dark/40">{t('preview.hint')}</p>
Anything the browser can reach. It stays on this panel and survives a reload.
</p>
</form> </form>
); );
} }
@@ -86,6 +86,7 @@ type DevProxyFrameProps = {
}; };
export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevProxyFrameProps) => { export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevProxyFrameProps) => {
const { t } = useTranslation();
const { get } = useClient(); const { get } = useClient();
const needsSession = entry.startsWith('/api/dev-proxy/'); const needsSession = entry.startsWith('/api/dev-proxy/');
const [ready, setReady] = useState(!needsSession); const [ready, setReady] = useState(!needsSession);
@@ -95,7 +96,7 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
* The session cookie, and why this effect must NOT watch the URL. * The session cookie, and why this effect must NOT watch the URL.
* *
* It used to depend on the full composed URL, which changes every time the app navigates. Each change * It used to depend on the full composed URL, which changes every time the app navigates. Each change
* re-ran this, and `setReady(false)` tears the frame down to show "Connecting…" — so a route change was * re-ran this, and `setReady(false)` tears the frame down to show t('preview.connecting') — so a route change was
* followed, one debounce later, by the whole app reloading. Reported as "I change routes and a second * followed, one debounce later, by the whole app reloading. Reported as "I change routes and a second
* later the page refreshes"; the second was the 800ms debounce on writing the location down. * later the page refreshes"; the second was the 800ms debounce on writing the location down.
* *
@@ -128,7 +129,9 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
); );
} }
if (!ready) { if (!ready) {
return <div className="flex h-full items-center justify-center text-xs text-duck-dark/40">Connecting…</div>; return (
<div className="flex h-full items-center justify-center text-xs text-duck-dark/40">{t('preview.connecting')}</div>
);
} }
// Keyed on the ENTRY url as well as the reload counter. Freezing `src` at mount stopped the app's own // Keyed on the ENTRY url as well as the reload counter. Freezing `src` at mount stopped the app's own
// navigation from reloading the frame — and stopped a newly typed URL from loading at all, since nothing // navigation from reloading the frame — and stopped a newly typed URL from loading at all, since nothing
@@ -159,6 +162,7 @@ export const DevProxyFrame = ({ entry, target, reloadToken, onLocation }: DevPro
* two panels are never pointed at the same port. * two panels are never pointed at the same port.
*/ */
const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocation: (path: string) => void }) => { const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocation: (path: string) => void }) => {
const { t } = useTranslation();
const [src] = useState(initialSrc); const [src] = useState(initialSrc);
const frame = useRef<HTMLIFrameElement | null>(null); const frame = useRef<HTMLIFrameElement | null>(null);
@@ -174,5 +178,5 @@ const PreviewFrame = ({ initialSrc, onLocation }: { initialSrc: string; onLocati
return () => window.removeEventListener('message', onMessage); return () => window.removeEventListener('message', onMessage);
}, [onLocation]); }, [onLocation]);
return <iframe ref={frame} src={src} title="Preview" className="h-full w-full border-none bg-white" />; return <iframe ref={frame} src={src} title={t('preview.title')} className="h-full w-full border-none bg-white" />;
}; };
@@ -2,17 +2,19 @@ import { useEffect, useState } from 'react';
import { Globe, RefreshCw, ExternalLink, Pencil, Check, X } from 'lucide-react'; import { Globe, RefreshCw, ExternalLink, Pencil, Check, X } from 'lucide-react';
import { useWorkspace } from '../../components/Workspace'; import { useWorkspace } from '../../components/Workspace';
import { normalizeUrl, usePreview } from './use-preview'; import { normalizeUrl, usePreview } from './use-preview';
import { useTranslation } from '../../locales';
/** /**
* The preview panel's header: where it points, reload, open in a real tab, and change it. * The preview panel's header: where it points, reload, open in a real tab, and change it.
* *
* "Open in a new tab" is not decoration. A framed app gets PARTITIONED storage — the browser keys it by * t('preview.openNewTab') is not decoration. A framed app gets PARTITIONED storage — the browser keys it by
* top-level site — so the login inside this panel is separate from the login in a tab, and a page that * top-level site — so the login inside this panel is separate from the login in a tab, and a page that
* refuses to be framed at all (`X-Frame-Options`, CSP `frame-ancestors`) renders blank here with only a * refuses to be framed at all (`X-Frame-Options`, CSP `frame-ancestors`) renders blank here with only a
* console error. When either happens, the tab is the answer, and it should be one click away rather than * console error. When either happens, the tab is the answer, and it should be one click away rather than
* something you rebuild by copying the URL out. * something you rebuild by copying the URL out.
*/ */
export const PreviewHeader = ({ panelId }: { panelId?: string }) => { export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
const { t, tUi } = useTranslation();
const { workspace } = useWorkspace(); const { workspace } = useWorkspace();
const { url, target, display, setUrl, reload } = usePreview(panelId, workspace); const { url, target, display, setUrl, reload } = usePreview(panelId, workspace);
@@ -46,13 +48,13 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
}} }}
className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs" className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs"
/> />
<button type="submit" title="Save" className="p-0.5 cursor-pointer shrink-0"> <button type="submit" title={t('common.save')} className="p-0.5 cursor-pointer shrink-0">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
</button> </button>
<button <button
type="button" type="button"
onClick={() => setEditing(false)} onClick={() => setEditing(false)}
title="Cancel" title={tUi('button.cancel')}
className="p-0.5 cursor-pointer shrink-0" className="p-0.5 cursor-pointer shrink-0"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@@ -64,10 +66,14 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
return ( return (
<> <>
<Globe className="h-3.5 w-3.5 shrink-0 text-duck-teal" /> <Globe className="h-3.5 w-3.5 shrink-0 text-duck-teal" />
<span className="text-xs font-medium shrink-0">Preview</span> <span className="text-xs font-medium shrink-0">{t('preview.title')}</span>
{url ? ( {url ? (
<> <>
<button onClick={reload} title="Reload" className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"> <button
onClick={reload}
title={t('preview.reload')}
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
>
<RefreshCw className="h-3 w-3" /> <RefreshCw className="h-3 w-3" />
</button> </button>
<span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60" title={display}> <span className="min-w-0 flex-1 truncate text-[10px] font-mono opacity-60" title={display}>
@@ -75,7 +81,7 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
</span> </span>
<button <button
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
title="Change URL" title={t('preview.changeUrl')}
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0" className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
> >
<Pencil className="h-3 w-3" /> <Pencil className="h-3 w-3" />
@@ -84,7 +90,7 @@ export const PreviewHeader = ({ panelId }: { panelId?: string }) => {
href={target} href={target}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
title="Open in a new tab" title={t('preview.openNewTab')}
className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0" className="p-0.5 rounded hover:bg-black/10 cursor-pointer shrink-0"
> >
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />