Compare commits
3
Commits
ac890fd996
...
3e07dad69c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e07dad69c | ||
|
|
c8fb89122e | ||
|
|
55e56ea652 |
@@ -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" />
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
|
|||||||
import { Laptop, Send, Upload, User, X } from 'lucide-react';
|
import { Laptop, Send, Upload, User, X } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
|
import { useSendOff, useSendOffSenders, useSendOffTargets } from './useSendOff';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
|
const label = (p: { name: string | null; email: string }): string => p.name || p.email.split('@')[0] || p.email;
|
||||||
const kb = (n: number): string =>
|
const kb = (n: number): string =>
|
||||||
@@ -17,6 +18,7 @@ type Target = { kind: 'machine'; name: string } | { kind: 'user'; id: number; na
|
|||||||
* needed keeping in sync.
|
* needed keeping in sync.
|
||||||
*/
|
*/
|
||||||
export const SendOffPanel = () => {
|
export const SendOffPanel = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { devices, people, myFolder, isLoading } = useSendOffTargets();
|
const { devices, people, myFolder, isLoading } = useSendOffTargets();
|
||||||
const send = useSendOff();
|
const send = useSendOff();
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
@@ -41,7 +43,7 @@ export const SendOffPanel = () => {
|
|||||||
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
|
<div className="flex h-full min-h-0 flex-col overflow-y-auto p-5">
|
||||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-5">
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-foreground">Files</h3>
|
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.files')}</h3>
|
||||||
<input
|
<input
|
||||||
ref={picker}
|
ref={picker}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -70,7 +72,7 @@ export const SendOffPanel = () => {
|
|||||||
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
|
<span className="shrink-0 text-xs text-muted-foreground">{kb(f.size)}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Remove ${f.name}`}
|
aria-label={t('sendOff.removeFile', { name: f.name })}
|
||||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
|
onClick={() => setFiles((prev) => prev.filter((_, k) => k !== i))}
|
||||||
>
|
>
|
||||||
@@ -86,9 +88,9 @@ export const SendOffPanel = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-foreground">Where</h3>
|
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.where')}</h3>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Loading…</p>
|
<p className="mt-2 text-sm text-muted-foreground">{t('sendOff.loading')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex flex-col gap-3">
|
<div className="mt-2 flex flex-col gap-3">
|
||||||
<TargetGroup
|
<TargetGroup
|
||||||
@@ -124,19 +126,19 @@ export const SendOffPanel = () => {
|
|||||||
<section className="flex flex-col gap-2">
|
<section className="flex flex-col gap-2">
|
||||||
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
|
<Button onClick={doSend} disabled={!ready || send.isPending} size="lg">
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
{send.isPending ? 'Sending…' : target ? `Send to ${target.name}` : 'Send'}
|
{send.isPending ? 'Sending…' : target ? t('sendOff.sendTo', { name: target.name }) : 'Send'}
|
||||||
</Button>
|
</Button>
|
||||||
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
|
{/* The server's own message, not a generic one: a file over the limit and a recipient who has
|
||||||
revoked you are different problems with different answers. */}
|
revoked you are different problems with different answers. */}
|
||||||
{target?.kind === 'user' && myFolder && (
|
{target?.kind === 'user' && myFolder && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Lands in their <span className="text-foreground">Documents/SendOff/{myFolder}</span>.
|
{t('sendOff.landsIn')} <code className="text-foreground">Documents/SendOff/{myFolder}</code>.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
|
{send.error && <p className="text-sm text-destructive">{send.error.message}</p>}
|
||||||
{send.data && (
|
{send.data && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Landed in <span className="text-foreground">{send.data.path}</span> —{' '}
|
{t('sendOff.landedIn')} <span className="text-foreground">{send.data.path}</span> —{' '}
|
||||||
{send.data.files.map((f) => f.name).join(', ')}
|
{send.data.files.map((f) => f.name).join(', ')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -195,15 +197,16 @@ const TargetGroup = ({ icon: Icon, heading, empty, options, selected, onSelect }
|
|||||||
* the answer has to be visible in the same place the question was asked.
|
* the answer has to be visible in the same place the question was asked.
|
||||||
*/
|
*/
|
||||||
const WhoMaySend = () => {
|
const WhoMaySend = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
|
const { candidates, isLoading, setAllowed, isSaving } = useSendOffSenders();
|
||||||
if (isLoading || candidates.length === 0) return null;
|
if (isLoading || candidates.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-t border-border pt-4">
|
<section className="border-t border-border pt-4">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Who may send to me</h3>
|
<h3 className="text-sm font-semibold text-foreground">{t('sendOff.whoMaySend')}</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Files from anyone you allow land in <span className="text-foreground">Documents/SendOff/their-name</span>.
|
{t('sendOff.allowedLandIn')} <code className="text-foreground">Documents/SendOff/their-name</code>.{' '}
|
||||||
Turning someone off stops future sends and leaves what they already sent alone.
|
{t('sendOff.turningOff')}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-col gap-1">
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
{candidates.map((p) => (
|
{candidates.map((p) => (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from 'hooks/useAuth';
|
|||||||
import { Plus, X, RefreshCw } from 'lucide-react';
|
import { Plus, X, RefreshCw } from 'lucide-react';
|
||||||
import { useWidgetRegistry } from '../../WidgetRegistry';
|
import { useWidgetRegistry } from '../../WidgetRegistry';
|
||||||
import { WidgetPicker } from './WidgetPicker';
|
import { WidgetPicker } from './WidgetPicker';
|
||||||
|
import { useTranslation } from '../../locales';
|
||||||
|
|
||||||
type Position = { x: number; y: number }; // percentages (0–100)
|
type Position = { x: number; y: number }; // percentages (0–100)
|
||||||
|
|
||||||
@@ -102,20 +103,23 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
|
|||||||
[instance.position, panelRef],
|
[instance.position, panelRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const endDrag = useCallback(
|
const endDrag = useCallback((ev: ReactPointerEvent) => {
|
||||||
(ev: ReactPointerEvent) => {
|
if (!dragRef.current) return;
|
||||||
if (!dragRef.current) return;
|
dragRef.current = null;
|
||||||
dragRef.current = null;
|
containerRef.current?.releasePointerCapture(ev.pointerId);
|
||||||
containerRef.current?.releasePointerCapture(ev.pointerId);
|
}, []);
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPointerMove = useCallback(
|
const onPointerMove = useCallback(
|
||||||
(ev: ReactPointerEvent) => {
|
(ev: ReactPointerEvent) => {
|
||||||
if (!dragRef.current) return;
|
if (!dragRef.current) return;
|
||||||
const panelRect = panelRef.current?.getBoundingClientRect();
|
const panelRect = panelRef.current?.getBoundingClientRect();
|
||||||
if (panelRect && (ev.clientX < panelRect.left || ev.clientX > panelRect.right || ev.clientY < panelRect.top || ev.clientY > panelRect.bottom)) {
|
if (
|
||||||
|
panelRect &&
|
||||||
|
(ev.clientX < panelRect.left ||
|
||||||
|
ev.clientX > panelRect.right ||
|
||||||
|
ev.clientY < panelRect.top ||
|
||||||
|
ev.clientY > panelRect.bottom)
|
||||||
|
) {
|
||||||
endDrag(ev);
|
endDrag(ev);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -165,6 +169,7 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
|
|||||||
// --- WidgetPanel ---
|
// --- WidgetPanel ---
|
||||||
|
|
||||||
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [config, setConfig] = useWidgetPanelState(panelId);
|
const [config, setConfig] = useWidgetPanelState(panelId);
|
||||||
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
|
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
|
||||||
const [showPicker, setShowPicker] = useState(false);
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
@@ -220,7 +225,9 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}>
|
<div
|
||||||
|
className={`absolute bottom-4 right-4 flex flex-col items-end transition-opacity ${showPicker ? 'opacity-100' : 'opacity-0 group-hover/panel:opacity-100'}`}
|
||||||
|
>
|
||||||
{showPicker && (
|
{showPicker && (
|
||||||
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
|
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
|
||||||
<WidgetPicker onSelect={addWidget} />
|
<WidgetPicker onSelect={addWidget} />
|
||||||
@@ -229,7 +236,7 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title="Resync widgets (reload to pick up newly-deployed widgets)"
|
title={t('widgets.resync')}
|
||||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:text-foreground cursor-pointer"
|
className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-card text-muted-foreground shadow-lg transition-colors hover:text-foreground cursor-pointer"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save"
|
||||||
|
},
|
||||||
"minesweeper": {
|
"minesweeper": {
|
||||||
"notInstalled": "Minesweeper is not installed on this server.",
|
"notInstalled": "Minesweeper is not installed on this server.",
|
||||||
"needs": "It needs wine, a 32-bit prefix, and this package beside the platform:",
|
"needs": "It needs wine, a 32-bit prefix, and this package beside the platform:",
|
||||||
@@ -81,5 +84,30 @@
|
|||||||
"saved": "File saved",
|
"saved": "File saved",
|
||||||
"saveFailed": "Failed to save file",
|
"saveFailed": "Failed to save file",
|
||||||
"title": "Code Editor"
|
"title": "Code Editor"
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"resync": "Resync widgets (reload to pick up newly-deployed widgets)"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Preview",
|
||||||
|
"reload": "Reload",
|
||||||
|
"changeUrl": "Change URL",
|
||||||
|
"openNewTab": "Open in a new tab",
|
||||||
|
"pointAtApp": "Point this panel at a running app",
|
||||||
|
"open": "Open",
|
||||||
|
"hint": "Anything the browser can reach. It stays on this panel and survives a reload.",
|
||||||
|
"connecting": "Connecting…"
|
||||||
|
},
|
||||||
|
"sendOff": {
|
||||||
|
"files": "Files",
|
||||||
|
"where": "Where",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"landsIn": "Lands in their",
|
||||||
|
"landedIn": "Landed in",
|
||||||
|
"whoMaySend": "Who may send to me",
|
||||||
|
"allowedLandIn": "Files from anyone you allow land in",
|
||||||
|
"removeFile": "Remove {{name}}",
|
||||||
|
"sendTo": "Send to {{name}}",
|
||||||
|
"turningOff": "Turning someone off stops future sends and leaves what they already sent alone."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Guardar"
|
||||||
|
},
|
||||||
"minesweeper": {
|
"minesweeper": {
|
||||||
"notInstalled": "O Minesweeper não está instalado neste servidor.",
|
"notInstalled": "O Minesweeper não está instalado neste servidor.",
|
||||||
"needs": "Precisa do wine, de um prefixo de 32 bits e deste pacote junto à plataforma:",
|
"needs": "Precisa do wine, de um prefixo de 32 bits e deste pacote junto à plataforma:",
|
||||||
@@ -81,5 +84,30 @@
|
|||||||
"saved": "Ficheiro guardado",
|
"saved": "Ficheiro guardado",
|
||||||
"saveFailed": "Falha ao guardar o ficheiro",
|
"saveFailed": "Falha ao guardar o ficheiro",
|
||||||
"title": "Editor de código"
|
"title": "Editor de código"
|
||||||
|
},
|
||||||
|
"widgets": {
|
||||||
|
"resync": "Ressincronizar widgets (recarregue para incluir widgets recém-implementados)"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"title": "Pré-visualização",
|
||||||
|
"reload": "Recarregar",
|
||||||
|
"changeUrl": "Alterar URL",
|
||||||
|
"openNewTab": "Abrir num novo separador",
|
||||||
|
"pointAtApp": "Aponte este painel para uma aplicação em execução",
|
||||||
|
"open": "Abrir",
|
||||||
|
"hint": "Qualquer coisa que o navegador consiga alcançar. Fica neste painel e sobrevive a um recarregamento.",
|
||||||
|
"connecting": "A ligar…"
|
||||||
|
},
|
||||||
|
"sendOff": {
|
||||||
|
"files": "Ficheiros",
|
||||||
|
"where": "Onde",
|
||||||
|
"loading": "A carregar…",
|
||||||
|
"landsIn": "Aterra na pasta",
|
||||||
|
"landedIn": "Aterrou em",
|
||||||
|
"whoMaySend": "Quem me pode enviar",
|
||||||
|
"allowedLandIn": "Os ficheiros de quem autorizar aterram em",
|
||||||
|
"removeFile": "Remover {{name}}",
|
||||||
|
"sendTo": "Enviar para {{name}}",
|
||||||
|
"turningOff": "Desativar alguém impede envios futuros e deixa intacto o que já enviou."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import type { InterpolationParams, TranslationKeys } from 'i18n';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useTranslation as useTranslationBase } from 'i18n';
|
import { useTranslation as useTranslationBase } from 'i18n';
|
||||||
import type en from './en-GB.json';
|
import type en from './en-GB.json';
|
||||||
|
import type uiEn from '@/components/locales/en-GB.json';
|
||||||
|
|
||||||
export type PanelTranslationKey = TranslationKeys<typeof en>;
|
export type PanelTranslationKey = TranslationKeys<typeof en>;
|
||||||
|
type UiTranslationKey = TranslationKeys<typeof uiEn>;
|
||||||
|
|
||||||
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
// The `panel.` prefix is namespace wiring for the merged catalog (D6) — callers never spell it, they
|
||||||
// pass the same bare key the JSON file uses.
|
// pass the same bare key the JSON file uses.
|
||||||
@@ -15,5 +17,9 @@ export const useTranslation = () => {
|
|||||||
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
|
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
|
||||||
[baseT],
|
[baseT],
|
||||||
);
|
);
|
||||||
return { t, locale };
|
// `ui.*` for the words the shared components already own — "Cancel", "Close". Panel-specific strings
|
||||||
|
// stay in `panel.*`: a ui.* key may not name a feature, and a panel is a feature.
|
||||||
|
const tUi = useCallback((key: UiTranslationKey, params?: InterpolationParams) => baseT(`ui.${key}`, params), [baseT]);
|
||||||
|
|
||||||
|
return { t, tUi, locale };
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user