widgets panel keyed (516 -> 515)

Also adds tUi to officerdev's useTranslation, mirroring officer-web: the brief says
to reach for ui.* where the string already exists there, and 'Cancel' does. Panel
strings stay in panel.* — a ui.* key may not name a feature, and a panel is one.
This commit is contained in:
2026-08-23 14:21:54 +00:00
parent ac890fd996
commit 55e56ea652
4 changed files with 81 additions and 12 deletions
@@ -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 };
}; };