dashboards panel keyed (469 -> 445)

The layout templates are module-level data, so they carry nameKey and both consumers
resolve it — the visible label with t(), and the preview's own mini-registry with
panelName(), which is the same late-bound-thunk problem the app registry has.

Cancel comes from ui.button.cancel; Delete is panel.common.delete since nothing in
the shared components renders one.
This commit is contained in:
2026-08-23 14:45:23 +00:00
parent 1ae986bb31
commit 06d5c64de3
4 changed files with 88 additions and 29 deletions
@@ -26,8 +26,10 @@ import {
} from './constants'; } from './constants';
import { useNewDashboardDraft } from './useNewDashboardDraft'; import { useNewDashboardDraft } from './useNewDashboardDraft';
import { cwdToPath } from '../../components/Workspace'; import { cwdToPath } from '../../components/Workspace';
import { useTranslation } from '../../locales';
export const DashboardListApp = () => { export const DashboardListApp = () => {
const { t, tUi } = useTranslation();
const location = useLocation(); const location = useLocation();
const client = useClient(); const client = useClient();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -105,7 +107,7 @@ export const DashboardListApp = () => {
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal" className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal"
> >
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="h-4 w-4" />
Dashboards {t('dashboards.title')}
</Link> </Link>
<button <button
type="button" type="button"
@@ -113,7 +115,7 @@ export const DashboardListApp = () => {
className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-white transition-all cursor-pointer" className="flex items-center justify-center gap-1 py-2 px-3 rounded-lg text-sm font-medium bg-duck-teal hover:bg-duck-teal/90 text-white transition-all cursor-pointer"
> >
<Plus className="h-4 w-4 shrink-0" /> <Plus className="h-4 w-4 shrink-0" />
New Dashboard {t('dashboards.new')}
</button> </button>
</div> </div>
<div className="relative px-3 pt-2"> <div className="relative px-3 pt-2">
@@ -122,7 +124,7 @@ export const DashboardListApp = () => {
type="text" type="text"
value={search} value={search}
onChange={(ev) => setSearch(ev.target.value)} onChange={(ev) => setSearch(ev.target.value)}
placeholder="Search dashboards" placeholder={t('dashboards.search')}
className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40" className="w-full rounded-lg border border-duck-dark/15 bg-transparent py-1.5 pl-8 pr-3 text-sm text-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-duck-teal/30 focus:border-duck-teal/40"
/> />
</div> </div>
@@ -182,15 +184,15 @@ export const DashboardListApp = () => {
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete dashboard</AlertDialogTitle> <AlertDialogTitle>{t('dashboards.delete')}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone. {t('dashboards.confirmDelete')} <strong>{deleting?.name}</strong>? This action cannot be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>{tUi('button.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700"> <AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
Delete {t('common.delete')}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@@ -19,21 +19,23 @@ import {
NEW_DASH_DESC_KEY, NEW_DASH_DESC_KEY,
NEW_DASH_TEMPLATE_KEY, NEW_DASH_TEMPLATE_KEY,
} from './constants'; } from './constants';
import type { PanelTranslationKey } from '../../locales';
import { panelName, useTranslation } from '../../locales';
// --- Layout Templates --- // --- Layout Templates ---
type LayoutTemplate = { type LayoutTemplate = {
name: string; nameKey: PanelTranslationKey;
layout: () => LayoutNode; layout: () => LayoutNode;
}; };
const templates: LayoutTemplate[] = [ const templates: LayoutTemplate[] = [
{ {
name: 'Single', nameKey: 'dashboards.templates.single',
layout: () => ({ type: 'panel', id: uid(), appType: null }), layout: () => ({ type: 'panel', id: uid(), appType: null }),
}, },
{ {
name: '2 Columns', nameKey: 'dashboards.templates.twoColumns',
layout: () => ({ layout: () => ({
type: 'group', type: 'group',
id: uid(), id: uid(),
@@ -45,7 +47,7 @@ const templates: LayoutTemplate[] = [
}), }),
}, },
{ {
name: 'Main + Side', nameKey: 'dashboards.templates.mainSide',
layout: () => ({ layout: () => ({
type: 'group', type: 'group',
id: uid(), id: uid(),
@@ -68,7 +70,7 @@ const templates: LayoutTemplate[] = [
}), }),
}, },
{ {
name: 'Sidebar', nameKey: 'dashboards.templates.sidebar',
layout: () => ({ layout: () => ({
type: 'group', type: 'group',
id: uid(), id: uid(),
@@ -80,7 +82,7 @@ const templates: LayoutTemplate[] = [
}), }),
}, },
{ {
name: '2x2 Grid', nameKey: 'dashboards.templates.grid',
layout: () => ({ layout: () => ({
type: 'group', type: 'group',
id: uid(), id: uid(),
@@ -114,7 +116,7 @@ const templates: LayoutTemplate[] = [
}), }),
}, },
{ {
name: 'Cols + Bottom', nameKey: 'dashboards.templates.colsBottom',
layout: () => ({ layout: () => ({
type: 'group', type: 'group',
id: uid(), id: uid(),
@@ -157,6 +159,7 @@ const ThumbnailNode = ({ node }: { node: LayoutNode }) => {
}; };
const TemplateCell = ({ index }: { index: number }) => { const TemplateCell = ({ index }: { index: number }) => {
const { t } = useTranslation();
const tpl = templates[index]!; const tpl = templates[index]!;
const [selected, setSelected] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0); const [selected, setSelected] = useGlobal<number>(NEW_DASH_TEMPLATE_KEY, 0);
const node = tpl.layout(); const node = tpl.layout();
@@ -174,7 +177,9 @@ const TemplateCell = ({ index }: { index: number }) => {
<div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden"> <div className="w-full flex-1 min-h-0 rounded border border-duck-dark/20 overflow-hidden">
<ThumbnailNode node={node} /> <ThumbnailNode node={node} />
</div> </div>
<span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>{tpl.name}</span> <span className={`text-xs font-medium ${isSelected ? 'text-duck-teal' : 'text-duck-dark/60'}`}>
{t(tpl.nameKey)}
</span>
</button> </button>
</div> </div>
); );
@@ -219,7 +224,7 @@ const tplPanelLayout: LayoutNode = {
const tplRegistry = Object.fromEntries( const tplRegistry = Object.fromEntries(
templates.map((tpl, i) => [ templates.map((tpl, i) => [
`tpl-${i}`, `tpl-${i}`,
{ name: tpl.name, icon: Layout, component: () => <TemplateCell index={i} /> }, { name: panelName(tpl.nameKey), icon: Layout, component: () => <TemplateCell index={i} /> },
]), ]),
); );
@@ -230,6 +235,7 @@ const TemplatePanel = () => (
// --- Panel: Name --- // --- Panel: Name ---
const NamePanel = () => { const NamePanel = () => {
const { t } = useTranslation();
const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, ''); const [name, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
const [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, ''); const [description, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
@@ -239,13 +245,13 @@ const NamePanel = () => {
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5"> <label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<Type className="h-3 w-3" /> <Type className="h-3 w-3" />
Name {t('dashboards.name')}
</label> </label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={(ev) => setName(ev.target.value)} onChange={(ev) => setName(ev.target.value)}
placeholder="My Dashboard" placeholder={t('dashboards.namePlaceholder')}
autoFocus autoFocus
className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50" className="rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/> />
@@ -253,12 +259,12 @@ const NamePanel = () => {
<div className="flex flex-1 min-h-0 flex-col gap-1.5"> <div className="flex flex-1 min-h-0 flex-col gap-1.5">
<label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5"> <label className="text-xs font-medium text-duck-dark/50 flex items-center gap-1.5">
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
Description {t('dashboards.description')}
</label> </label>
<textarea <textarea
value={description} value={description}
onChange={(ev) => setDescription(ev.target.value)} onChange={(ev) => setDescription(ev.target.value)}
placeholder="What is this dashboard for?" placeholder={t('dashboards.descriptionPlaceholder')}
className="flex-1 min-h-0 resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50" className="flex-1 min-h-0 resize-none rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/> />
</div> </div>
@@ -270,6 +276,7 @@ const NamePanel = () => {
// --- Panel: Create --- // --- Panel: Create ---
const CreatePanel = () => { const CreatePanel = () => {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const client = useClient(); const client = useClient();
@@ -401,7 +408,7 @@ const CreatePanel = () => {
<Button asChild variant="outline" className="cursor-pointer"> <Button asChild variant="outline" className="cursor-pointer">
<Link to={`/dashboards/${editingId}`}> <Link to={`/dashboards/${editingId}`}>
<ArrowRight className="h-4 w-4 mr-1" /> <ArrowRight className="h-4 w-4 mr-1" />
Open Dashboard {t('dashboards.open')}
</Link> </Link>
</Button> </Button>
)} )}
@@ -453,13 +460,14 @@ const newDashLayout: LayoutNode = {
}; };
const NewDashboardForm = () => { const NewDashboardForm = () => {
const { t } = useTranslation();
const { registry } = useAppRegistry(); const { registry } = useAppRegistry();
const newDashRegistry = { const newDashRegistry = {
...registry, ...registry,
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser }, 'file-browser-cwd': { name: panelName('panelName.fileBrowser'), icon: FolderOpen, component: CwdFileBrowser },
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel }, 'new-ws-name': { name: t('dashboards.name'), icon: Type, component: NamePanel },
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel }, 'new-ws-template': { name: t('dashboards.template'), icon: Layout, component: TemplatePanel },
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel }, 'new-ws-create': { name: t('dashboards.create'), icon: Rocket, component: CreatePanel },
}; };
return <WorkspaceLayout layout={newDashLayout} onLayoutChange={() => {}} registry={newDashRegistry} noHeader />; return <WorkspaceLayout layout={newDashLayout} onLayoutChange={() => {}} registry={newDashRegistry} noHeader />;
@@ -470,6 +478,7 @@ const NewDashboardForm = () => {
// Only reached when nothing is selected AND nothing is being created or edited — DashboardPreview checks // Only reached when nothing is selected AND nothing is being created or edited — DashboardPreview checks
// those first now, so this component no longer needs its own creating/editing branch. // those first now, so this component no longer needs its own creating/editing branch.
const DashboardPreviewEmpty = () => { const DashboardPreviewEmpty = () => {
const { t } = useTranslation();
const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false); const [, setCreating] = useGlobal<boolean>(CREATING_DASHBOARD_KEY, false);
const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, ''); const [, setName] = useGlobal<string>(NEW_DASH_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, ''); const [, setDescription] = useGlobal<string>(NEW_DASH_DESC_KEY, '');
@@ -488,10 +497,10 @@ const DashboardPreviewEmpty = () => {
<div className="h-full w-full"> <div className="h-full w-full">
<div className="flex h-full w-full flex-col items-center justify-center gap-4"> <div className="flex h-full w-full flex-col items-center justify-center gap-4">
<LayoutGrid className="h-10 w-10 text-white/80" /> <LayoutGrid className="h-10 w-10 text-white/80" />
<p className="text-sm text-white/80">Select a dashboard or create a new one</p> <p className="text-sm text-white/80">{t('dashboards.selectOne')}</p>
<Button onClick={handleCreate} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer"> <Button onClick={handleCreate} className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer">
<Plus className="h-4 w-4 mr-1" /> <Plus className="h-4 w-4 mr-1" />
New Dashboard {t('dashboards.new')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
{ {
"common": { "common": {
"save": "Save" "save": "Save",
"delete": "Delete"
}, },
"minesweeper": { "minesweeper": {
"notInstalled": "Minesweeper is not installed on this server.", "notInstalled": "Minesweeper is not installed on this server.",
@@ -136,5 +137,28 @@
"hostTerminal": "Host Terminal", "hostTerminal": "Host Terminal",
"runningShells": "Running Shells", "runningShells": "Running Shells",
"pm2Processes": "pm2 processes" "pm2Processes": "pm2 processes"
},
"dashboards": {
"title": "Dashboards",
"new": "New Dashboard",
"delete": "Delete dashboard",
"confirmDelete": "Are you sure you want to delete",
"search": "Search dashboards",
"name": "Name",
"description": "Description",
"open": "Open Dashboard",
"selectOne": "Select a dashboard or create a new one",
"namePlaceholder": "My Dashboard",
"descriptionPlaceholder": "What is this dashboard for?",
"template": "Template",
"create": "Create",
"templates": {
"single": "Single",
"twoColumns": "2 Columns",
"mainSide": "Main + Side",
"sidebar": "Sidebar",
"grid": "2x2 Grid",
"colsBottom": "Cols + Bottom"
}
} }
} }
@@ -1,6 +1,7 @@
{ {
"common": { "common": {
"save": "Guardar" "save": "Guardar",
"delete": "Eliminar"
}, },
"minesweeper": { "minesweeper": {
"notInstalled": "O Minesweeper não está instalado neste servidor.", "notInstalled": "O Minesweeper não está instalado neste servidor.",
@@ -136,5 +137,28 @@
"hostTerminal": "Terminal do anfitrião", "hostTerminal": "Terminal do anfitrião",
"runningShells": "Shells em execução", "runningShells": "Shells em execução",
"pm2Processes": "processos pm2" "pm2Processes": "processos pm2"
},
"dashboards": {
"title": "Painéis",
"new": "Novo painel",
"delete": "Eliminar painel",
"confirmDelete": "Tem a certeza de que quer eliminar",
"search": "Pesquisar painéis",
"name": "Nome",
"description": "Descrição",
"open": "Abrir painel",
"selectOne": "Selecione um painel ou crie um novo",
"namePlaceholder": "O meu painel",
"descriptionPlaceholder": "Para que serve este painel?",
"template": "Modelo",
"create": "Criar",
"templates": {
"single": "Único",
"twoColumns": "2 colunas",
"mainSide": "Principal + lateral",
"sidebar": "Barra lateral",
"grid": "Grelha 2x2",
"colsBottom": "Colunas + inferior"
}
} }
} }