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 { useWidgetRegistry } from '../../WidgetRegistry';
import { WidgetPicker } from './WidgetPicker';
import { useTranslation } from '../../locales';
type Position = { x: number; y: number }; // percentages (0–100)
@@ -102,20 +103,23 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
[instance.position, panelRef],
);
const endDrag = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
dragRef.current = null;
containerRef.current?.releasePointerCapture(ev.pointerId);
},
[],
);
const endDrag = useCallback((ev: ReactPointerEvent) => {
if (!dragRef.current) return;
dragRef.current = null;
containerRef.current?.releasePointerCapture(ev.pointerId);
}, []);
const onPointerMove = useCallback(
(ev: ReactPointerEvent) => {
if (!dragRef.current) return;
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);
return;
}
@@ -165,6 +169,7 @@ const DraggableWidget = ({ instance, panelRef, onMove, onRemove }: DraggableWidg
// --- WidgetPanel ---
export const WidgetPanel = ({ panelId }: { panelId: string }) => {
const { t } = useTranslation();
const [config, setConfig] = useWidgetPanelState(panelId);
const [instances, setInstances] = useState<WidgetInstance[]>(config.instances);
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 && (
<div className="mb-2 rounded-xl border border-border bg-card p-3 shadow-lg">
<WidgetPicker onSelect={addWidget} />
@@ -229,7 +236,7 @@ export const WidgetPanel = ({ panelId }: { panelId: string }) => {
<div className="flex items-center gap-2">
<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"
onClick={() => window.location.reload()}
>
@@ -1,4 +1,7 @@
{
"common": {
"save": "Save"
},
"minesweeper": {
"notInstalled": "Minesweeper is not installed on this server.",
"needs": "It needs wine, a 32-bit prefix, and this package beside the platform:",
@@ -81,5 +84,30 @@
"saved": "File saved",
"saveFailed": "Failed to save file",
"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": {
"notInstalled": "O Minesweeper não está instalado neste servidor.",
"needs": "Precisa do wine, de um prefixo de 32 bits e deste pacote junto à plataforma:",
@@ -81,5 +84,30 @@
"saved": "Ficheiro guardado",
"saveFailed": "Falha ao guardar o ficheiro",
"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 { useTranslation as useTranslationBase } from 'i18n';
import type en from './en-GB.json';
import type uiEn from '@/components/locales/en-GB.json';
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
// pass the same bare key the JSON file uses.
@@ -15,5 +17,9 @@ export const useTranslation = () => {
(key: PanelTranslationKey, params?: InterpolationParams) => baseT(`panel.${key}`, params),
[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 };
};