feat: first cut at menu editor

This commit is contained in:
2026-02-21 19:51:34 +01:00
parent f371dbd2b2
commit 76c3a8368e
37 changed files with 2148 additions and 4 deletions

View File

@@ -0,0 +1,223 @@
import { EventEmitter } from 'events';
import * as fs from 'fs/promises';
import * as path from 'path';
import { randomUUID } from 'crypto';
import { app } from 'electron';
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
export type MenuItemKind = 'page' | 'submenu';
export interface MenuItemData {
id: string;
title: string;
kind: MenuItemKind;
pageId?: string;
pageSlug?: string;
children: MenuItemData[];
}
export interface MenuDocument {
items: MenuItemData[];
}
type OpmlOutlineNode = {
'@_id'?: string;
'@_text'?: string;
'@_title'?: string;
'@_type'?: string;
'@_pageId'?: string;
'@_pageSlug'?: string;
outline?: OpmlOutlineNode | OpmlOutlineNode[];
};
function generateMenuItemId(): string {
try {
return randomUUID();
} catch {
return `menu-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
}
function normalizeOutlineNodes(value: unknown): OpmlOutlineNode[] {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value as OpmlOutlineNode[];
}
return [value as OpmlOutlineNode];
}
function normalizeNonEmptyString(value: unknown): string | undefined {
if (value === undefined || value === null) {
return undefined;
}
const trimmed = String(value).trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function sanitizeMenuItem(input: unknown): MenuItemData {
const candidate = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
const kind = candidate.kind === 'submenu' ? 'submenu' : 'page';
const childrenSource = Array.isArray(candidate.children) ? candidate.children : [];
const title = normalizeNonEmptyString(candidate.title) || 'Untitled';
return {
id: normalizeNonEmptyString(candidate.id) || generateMenuItemId(),
title,
kind,
pageId: kind === 'page' ? normalizeNonEmptyString(candidate.pageId) : undefined,
pageSlug: kind === 'page' ? normalizeNonEmptyString(candidate.pageSlug) : undefined,
children: childrenSource.map((child) => sanitizeMenuItem(child)),
};
}
function sanitizeMenuDocument(input: unknown): MenuDocument {
const candidate = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
const items = Array.isArray(candidate.items) ? candidate.items : [];
return {
items: items.map((item) => sanitizeMenuItem(item)),
};
}
function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
const kind: MenuItemKind = node['@_type'] === 'submenu' ? 'submenu' : 'page';
const title = normalizeNonEmptyString(node['@_text']) || normalizeNonEmptyString(node['@_title']) || 'Untitled';
return {
id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(),
title,
kind,
pageId: kind === 'page' ? normalizeNonEmptyString(node['@_pageId']) : undefined,
pageSlug: kind === 'page' ? normalizeNonEmptyString(node['@_pageSlug']) : undefined,
children: normalizeOutlineNodes(node.outline).map((child) => parseOutlineNode(child)),
};
}
function toOpmlOutlineNode(item: MenuItemData): OpmlOutlineNode {
const outlineNode: OpmlOutlineNode = {
'@_id': item.id,
'@_text': item.title,
'@_type': item.kind,
};
if (item.kind === 'page' && item.pageId) {
outlineNode['@_pageId'] = item.pageId;
}
if (item.kind === 'page' && item.pageSlug) {
outlineNode['@_pageSlug'] = item.pageSlug;
}
if (item.children.length > 0) {
outlineNode.outline = item.children.map((child) => toOpmlOutlineNode(child));
}
return outlineNode;
}
export class MenuEngine extends EventEmitter {
private currentProjectId: string = 'default';
private dataDir: string | null = null;
private getDefaultBaseDir(): string {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'projects', this.currentProjectId);
}
private getBaseDir(): string {
return this.dataDir || this.getDefaultBaseDir();
}
getMetaDir(): string {
return path.join(this.getBaseDir(), 'meta');
}
private getMenuFilePath(): string {
return path.join(this.getMetaDir(), 'menu.opml');
}
setProjectContext(projectId: string, dataDir?: string): void {
this.currentProjectId = projectId;
this.dataDir = dataDir || null;
}
async getMenu(): Promise<MenuDocument> {
const menuPath = this.getMenuFilePath();
let xmlContent: string;
try {
xmlContent = await fs.readFile(menuPath, 'utf-8');
} catch (error) {
const asErrno = error as NodeJS.ErrnoException;
if (asErrno?.code === 'ENOENT') {
return { items: [] };
}
throw error;
}
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
});
const parsed = parser.parse(xmlContent) as {
opml?: {
body?: {
outline?: OpmlOutlineNode | OpmlOutlineNode[];
};
};
};
const outlineNodes = normalizeOutlineNodes(parsed?.opml?.body?.outline);
const items = outlineNodes.map((node) => parseOutlineNode(node));
return sanitizeMenuDocument({ items });
}
async saveMenu(input: MenuDocument): Promise<MenuDocument> {
const sanitized = sanitizeMenuDocument(input);
const builder = new XMLBuilder({
ignoreAttributes: false,
attributeNamePrefix: '@_',
format: true,
suppressEmptyNode: true,
});
const opmlPayload = {
'?xml': {
'@_version': '1.0',
'@_encoding': 'UTF-8',
},
opml: {
'@_version': '2.0',
head: {
title: 'Blog Menu',
},
body: {
outline: sanitized.items.map((item) => toOpmlOutlineNode(item)),
},
},
};
const xml = builder.build(opmlPayload);
await fs.mkdir(this.getMetaDir(), { recursive: true });
await fs.writeFile(this.getMenuFilePath(), xml, 'utf-8');
this.emit('menuUpdated', sanitized);
return sanitized;
}
}
let menuEngine: MenuEngine | null = null;
export function getMenuEngine(): MenuEngine {
if (!menuEngine) {
menuEngine = new MenuEngine();
}
return menuEngine;
}

View File

@@ -93,3 +93,10 @@ export {
type BlogGenerationOptions,
type BlogGenerationResult,
} from './BlogGenerationEngine';
export {
MenuEngine,
getMenuEngine,
type MenuItemData,
type MenuDocument,
type MenuItemKind,
} from './MenuEngine';

View File

@@ -6,6 +6,7 @@ import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engin
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
import { getMetaEngine } from '../engine/MetaEngine';
import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine';
import { getTagEngine } from '../engine/TagEngine';
import { getPostMediaEngine } from '../engine/PostMediaEngine';
import { getGitEngine } from '../engine/GitEngine';
@@ -248,10 +249,12 @@ export function registerIpcHandlers(): void {
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
const metaEngine = getMetaEngine();
const menuEngine = getMenuEngine();
const tagEngine = getTagEngine();
postEngine.setProjectContext(project.id, dataDir);
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
menuEngine.setProjectContext(project.id, dataDir);
tagEngine.setProjectContext(project.id, dataDir);
const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(project.id);
@@ -284,10 +287,12 @@ export function registerIpcHandlers(): void {
const postEngine = getPostEngine();
const mediaEngine = getMediaEngine();
const metaEngine = getMetaEngine();
const menuEngine = getMenuEngine();
const tagEngine = getTagEngine();
postEngine.setProjectContext(project.id, dataDir);
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
metaEngine.setProjectContext(project.id, dataDir);
menuEngine.setProjectContext(project.id, dataDir);
tagEngine.setProjectContext(project.id, dataDir);
const postMediaEngine = getPostMediaEngine();
postMediaEngine.setProjectContext(project.id);
@@ -813,6 +818,34 @@ export function registerIpcHandlers(): void {
// ============ Meta Handlers ============
safeHandle('menu:get', async () => {
const projectEngine = getProjectEngine();
const menuEngine = getMenuEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
throw new Error('No active project');
}
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
menuEngine.setProjectContext(project.id, dataDir);
return menuEngine.getMenu();
});
safeHandle('menu:save', async (_, menu: MenuDocument) => {
const projectEngine = getProjectEngine();
const menuEngine = getMenuEngine();
const project = await projectEngine.getActiveProject();
if (!project) {
throw new Error('No active project');
}
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
menuEngine.setProjectContext(project.id, dataDir);
return menuEngine.saveMenu(menu);
});
safeHandle('meta:getTags', async () => {
const engine = getMetaEngine();
return engine.getTags();

View File

@@ -258,6 +258,11 @@ export const electronAPI: ElectronAPI = {
applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report),
},
menu: {
get: () => ipcRenderer.invoke('menu:get'),
save: (menu: import('./shared/electronApi').MenuDocument) => ipcRenderer.invoke('menu:save', menu),
},
// AI Chat (OpenCode Zen API integration)
chat: {
// API Key Management

View File

@@ -422,6 +422,21 @@ export interface SiteValidationApplyResult {
removedEmptyDirCount: number;
}
export type MenuItemKind = 'page' | 'submenu';
export interface MenuItemData {
id: string;
title: string;
kind: MenuItemKind;
pageId?: string;
pageSlug?: string;
children: MenuItemData[];
}
export interface MenuDocument {
items: MenuItemData[];
}
export interface ElectronAPI {
git: {
checkAvailability: () => Promise<GitAvailability>;
@@ -629,6 +644,10 @@ export interface ElectronAPI {
validateSite: () => Promise<SiteValidationReport>;
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
};
menu: {
get: () => Promise<MenuDocument>;
save: (menu: MenuDocument) => Promise<MenuDocument>;
};
chat: {
// API Key Management
checkReady: () => Promise<ChatReadyStatus>;

View File

@@ -36,6 +36,7 @@
"menu.item.rebuildDatabase": "Datenbank aus Dateien neu aufbauen",
"menu.item.reindexText": "Suchtext neu indizieren",
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
"menu.item.editMenu": "Blog-Menü bearbeiten",
"menu.item.generateSitemap": "Site rendern",
"menu.item.validateSite": "Website validieren",
"menu.item.about": "Über Blogging Desktop Server",

View File

@@ -36,6 +36,7 @@
"menu.item.rebuildDatabase": "Rebuild Database from Files",
"menu.item.reindexText": "Reindex Search Text",
"menu.item.metadataDiff": "Metadata Diff Tool",
"menu.item.editMenu": "Edit Blog Menu",
"menu.item.generateSitemap": "Render Site",
"menu.item.validateSite": "Validate Site",
"menu.item.about": "About Blogging Desktop Server",

View File

@@ -36,6 +36,7 @@
"menu.item.rebuildDatabase": "Reconstruir Database from Files",
"menu.item.reindexText": "Reindex Buscar Text",
"menu.item.metadataDiff": "Herramienta diff de metadatos",
"menu.item.editMenu": "Editar menú del blog",
"menu.item.generateSitemap": "Renderizar sitio",
"menu.item.validateSite": "Validar sitio",
"menu.item.about": "Acerca de Blogging Desktop Server",

View File

@@ -36,6 +36,7 @@
"menu.item.rebuildDatabase": "Reconstruire Database from Files",
"menu.item.reindexText": "Reindex Recherche Text",
"menu.item.metadataDiff": "Outil de diff des métadonnées",
"menu.item.editMenu": "Modifier le menu du blog",
"menu.item.generateSitemap": "Rendre le site",
"menu.item.validateSite": "Valider le site",
"menu.item.about": "À propos de Blogging Desktop Server",

View File

@@ -36,6 +36,7 @@
"menu.item.rebuildDatabase": "Ricostruisci Database from Files",
"menu.item.reindexText": "Reindex Ricerca Text",
"menu.item.metadataDiff": "Strumento diff metadati",
"menu.item.editMenu": "Modifica menu blog",
"menu.item.generateSitemap": "Renderizza sito",
"menu.item.validateSite": "Valida sito",
"menu.item.about": "Informazioni su Blogging Desktop Server",

View File

@@ -31,6 +31,7 @@ export type AppMenuAction =
| 'rebuildDatabase'
| 'reindexText'
| 'metadataDiff'
| 'editMenu'
| 'generateSitemap'
| 'validateSite'
| 'openDocumentation'
@@ -123,6 +124,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: 'menu.item.reindexText', action: 'reindexText' },
{ label: '', action: 'blog-separator-3', separator: true },
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
{ label: 'menu.item.editMenu', action: 'editMenu' },
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
],
@@ -156,6 +158,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
rebuildDatabase: 'menu:rebuildDatabase',
reindexText: 'menu:reindexText',
metadataDiff: 'menu:metadataDiff',
editMenu: 'menu:editMenu',
generateSitemap: 'menu:generateSitemap',
validateSite: 'menu:validateSite',
openDocumentation: 'menu:openDocumentation',

View File

@@ -276,6 +276,12 @@ const App: React.FC = () => {
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:editMenu', () => {
openSingletonToolTab(openTab, 'menu-editor');
}) || (() => {})
);
// Rebuild events - clear store on start, reload on complete
unsubscribers.push(
window.electronAPI?.on('posts:rebuildStarted', () => {

View File

@@ -14,6 +14,7 @@ import { TagsView } from '../TagsView';
import { TagInput } from '../TagInput';
import { ChatPanel } from '../ChatPanel';
import { ImportAnalysisView } from '../ImportAnalysisView';
import { MenuEditorView } from '../MenuEditorView/MenuEditorView';
import { MetadataDiffPanel } from '../MetadataDiffPanel';
import { GitDiffView } from '../GitDiffView/GitDiffView';
import { DocumentationView } from '../DocumentationView/DocumentationView';
@@ -1784,6 +1785,7 @@ export const Editor: React.FC = () => {
chat: () => (editorRoute.tabId ? <ChatPanel key={editorRoute.tabId} conversationId={editorRoute.tabId} /> : <Dashboard />),
import: () =>
editorRoute.tabId ? <ImportAnalysisView key={editorRoute.tabId} definitionId={editorRoute.tabId} /> : <Dashboard />,
'menu-editor': () => <MenuEditorView />,
'metadata-diff': () => <MetadataDiffPanel />,
'git-diff': () =>
editorRoute.tabId && editorRoute.gitDiffResource

View File

@@ -0,0 +1,196 @@
.menu-editor-view {
padding: 1rem;
height: 100%;
display: flex;
flex-direction: column;
gap: 0.75rem;
position: relative;
}
.menu-editor-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.menu-editor-header h2 {
margin: 0;
}
.menu-editor-header p {
margin: 0.25rem 0 0;
color: var(--vscode-descriptionForeground);
}
.menu-editor-loading,
.menu-editor-empty {
color: var(--vscode-descriptionForeground);
}
.menu-editor-main {
display: grid;
grid-template-columns: minmax(480px, 1fr) minmax(280px, 340px);
gap: 0.75rem;
min-height: 0;
flex: 1;
}
.menu-editor-tree-wrap,
.menu-editor-details {
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background: var(--vscode-editor-background);
padding: 0.5rem;
min-height: 0;
}
.menu-editor-toolbar {
display: flex;
align-items: center;
gap: 0.2rem;
margin-bottom: 0.5rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--vscode-panel-border);
}
.menu-editor-tool {
width: 1.8rem;
height: 1.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--vscode-foreground);
cursor: pointer;
padding: 0;
}
.menu-editor-tool:hover:not(:disabled) {
background: var(--vscode-toolbar-hoverBackground);
border-color: var(--vscode-panel-border);
}
.menu-editor-tool:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.menu-editor-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
cursor: pointer;
}
.menu-editor-row.is-selected {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.menu-editor-row-kind {
font-size: 0.75rem;
opacity: 0.85;
}
.menu-editor-row-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.menu-editor-details {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.menu-editor-details h3 {
margin: 0;
}
.menu-editor-details label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.menu-editor-picker-backdrop {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--vscode-editor-background) 75%, black);
display: flex;
align-items: center;
justify-content: center;
}
.menu-editor-picker {
width: min(580px, 90%);
max-height: 75%;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-editor-background);
border-radius: 6px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.menu-editor-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.menu-editor-picker-header h3 {
margin: 0;
}
.menu-editor-picker-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
overflow: auto;
}
.menu-editor-picker-item {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border: 1px solid var(--vscode-panel-border);
border-radius: 4px;
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
padding: 0.45rem 0.55rem;
text-align: left;
cursor: pointer;
}
.menu-editor-picker-item:hover {
border-color: var(--vscode-focusBorder);
background: var(--vscode-list-hoverBackground);
}
.menu-editor-picker-item.is-active {
border-color: var(--vscode-focusBorder);
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.menu-editor-picker-item.is-active small {
color: var(--vscode-list-activeSelectionForeground);
opacity: 0.8;
}
.menu-editor-picker-item small {
color: var(--vscode-descriptionForeground);
}
.menu-editor-picker-state {
color: var(--vscode-descriptionForeground);
}

View File

@@ -0,0 +1,650 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Tree } from 'react-arborist';
import { useI18n } from '../../i18n';
import { showToast } from '../Toast';
import type { MenuDocument, MenuItemData, MenuItemKind, PostData } from '../../../main/shared/electronApi';
import { createAutoExpandController } from './menuAutoExpand';
import {
createMenuPageItemFromPost,
filterPagePosts,
getNextPickerIndex,
isPickerCloseKey,
isPickerFocusShortcut,
} from './menuPagePicker';
import { applyTreeMove } from './menuTreeMove';
import './MenuEditorView.css';
function createMenuItem(kind: MenuItemKind, title: string): MenuItemData {
return {
id: `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
title,
kind,
pageId: undefined,
pageSlug: undefined,
children: [],
};
}
function findPathById(items: MenuItemData[], id: string, path: number[] = []): number[] | null {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const nextPath = [...path, index];
if (item.id === id) {
return nextPath;
}
const nested = findPathById(item.children, id, nextPath);
if (nested) {
return nested;
}
}
return null;
}
function updateItemsAtLevel(
items: MenuItemData[],
path: number[],
updater: (level: MenuItemData[]) => MenuItemData[],
): MenuItemData[] {
if (path.length === 0) {
return updater(items);
}
const [head, ...tail] = path;
return items.map((item, index) => {
if (index !== head) {
return item;
}
return {
...item,
children: updateItemsAtLevel(item.children, tail, updater),
};
});
}
function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuItemData[]; removed: MenuItemData | null } {
if (path.length === 0) {
return { next: items, removed: null };
}
if (path.length === 1) {
const [index] = path;
if (index < 0 || index >= items.length) {
return { next: items, removed: null };
}
const removed = items[index];
return {
next: items.filter((_, currentIndex) => currentIndex !== index),
removed,
};
}
const [head, ...tail] = path;
const current = items[head];
if (!current) {
return { next: items, removed: null };
}
const nested = removeItemByPath(current.children, tail);
if (!nested.removed) {
return { next: items, removed: null };
}
const next = items.map((item, index) => (index === head ? { ...item, children: nested.next } : item));
return { next, removed: nested.removed };
}
function insertItemAtPath(items: MenuItemData[], parentPath: number[], index: number, node: MenuItemData): MenuItemData[] {
if (parentPath.length === 0) {
const boundedIndex = Math.max(0, Math.min(index, items.length));
return [...items.slice(0, boundedIndex), node, ...items.slice(boundedIndex)];
}
const [head, ...tail] = parentPath;
return items.map((item, currentIndex) => {
if (currentIndex !== head) {
return item;
}
return {
...item,
children: insertItemAtPath(item.children, tail, index, node),
};
});
}
function mapItems(items: MenuItemData[], mapper: (item: MenuItemData) => MenuItemData): MenuItemData[] {
return items.map((item) => {
const mapped = mapper(item);
if (mapped.children.length === 0) {
return mapped;
}
return {
...mapped,
children: mapItems(mapped.children, mapper),
};
});
}
export const MenuEditorView: React.FC = () => {
const { t: tr } = useI18n();
const [items, setItems] = useState<MenuItemData[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showPagePicker, setShowPagePicker] = useState(false);
const [pagePickerParentId, setPagePickerParentId] = useState<string | null>(null);
const [pagePickerLoading, setPagePickerLoading] = useState(false);
const [pagePickerQuery, setPagePickerQuery] = useState('');
const [pagePickerPosts, setPagePickerPosts] = useState<PostData[]>([]);
const [pagePickerActiveIndex, setPagePickerActiveIndex] = useState(-1);
const pagePickerInputRef = useRef<HTMLInputElement | null>(null);
const autoExpandController = useMemo(() => createAutoExpandController(450), []);
useEffect(() => {
const load = async () => {
setIsLoading(true);
try {
const menu = await window.electronAPI.menu.get();
setItems(menu.items);
setSelectedId(menu.items[0]?.id ?? null);
} catch (error) {
console.error('Failed to load menu:', error);
showToast.error(tr('menuEditor.loadError'));
} finally {
setIsLoading(false);
}
};
void load();
}, [tr]);
useEffect(() => {
return () => {
autoExpandController.cancelAll();
};
}, [autoExpandController]);
const selectedPath = useMemo(() => {
if (!selectedId) {
return null;
}
return findPathById(items, selectedId);
}, [items, selectedId]);
const selectedItem = useMemo(() => {
if (!selectedPath || selectedPath.length === 0) {
return null;
}
let currentItems = items;
let current: MenuItemData | null = null;
for (const segment of selectedPath) {
current = currentItems[segment] || null;
if (!current) {
return null;
}
currentItems = current.children;
}
return current;
}, [items, selectedPath]);
const filteredPagePosts = useMemo(() => {
return filterPagePosts(pagePickerPosts, pagePickerQuery);
}, [pagePickerPosts, pagePickerQuery]);
const replaceSelected = (updater: (item: MenuItemData) => MenuItemData): void => {
if (!selectedId) {
return;
}
setItems((previous) => mapItems(previous, (item) => (item.id === selectedId ? updater(item) : item)));
};
const insertItem = (previous: MenuItemData[], node: MenuItemData, parentId: string | null): MenuItemData[] => {
if (!parentId) {
return [...previous, node];
}
return mapItems(previous, (item) => {
if (item.id !== parentId) {
return item;
}
return {
...item,
children: [...item.children, node],
};
});
};
const closePagePicker = (): void => {
setShowPagePicker(false);
setPagePickerParentId(null);
setPagePickerQuery('');
setPagePickerActiveIndex(-1);
};
const openPagePicker = async (parentId: string | null): Promise<void> => {
setShowPagePicker(true);
setPagePickerParentId(parentId);
setPagePickerQuery('');
setPagePickerActiveIndex(-1);
setPagePickerLoading(true);
try {
const posts = await window.electronAPI.posts.filter({ categories: ['page'] });
setPagePickerPosts(posts);
} catch (error) {
console.error('Failed to load page posts:', error);
showToast.error(tr('menuEditor.pagePicker.loadError'));
setPagePickerPosts([]);
} finally {
setPagePickerLoading(false);
}
};
const selectPageForMenu = (post: PostData): void => {
const node = createMenuPageItemFromPost(post);
setItems((previous) => insertItem(previous, node, pagePickerParentId));
setSelectedId(node.id);
closePagePicker();
};
useEffect(() => {
if (!showPagePicker) {
return;
}
if (filteredPagePosts.length === 0) {
setPagePickerActiveIndex(-1);
return;
}
setPagePickerActiveIndex((previous) => {
if (previous < 0) {
return 0;
}
return Math.min(previous, filteredPagePosts.length - 1);
});
}, [filteredPagePosts, showPagePicker]);
useEffect(() => {
if (!showPagePicker) {
return;
}
const onWindowKeyDown = (event: KeyboardEvent): void => {
if (isPickerFocusShortcut({ key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey })) {
event.preventDefault();
pagePickerInputRef.current?.focus();
pagePickerInputRef.current?.select();
}
};
window.addEventListener('keydown', onWindowKeyDown);
return () => {
window.removeEventListener('keydown', onWindowKeyDown);
};
}, [showPagePicker]);
const addRootItem = (kind: MenuItemKind): void => {
if (kind === 'page') {
void openPagePicker(null);
return;
}
const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu');
const node = createMenuItem(kind, title);
setItems((previous) => [...previous, node]);
setSelectedId(node.id);
};
const addChildItem = (kind: MenuItemKind): void => {
if (!selectedId) {
addRootItem(kind);
return;
}
if (kind === 'page') {
void openPagePicker(selectedId);
return;
}
const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu');
const node = createMenuItem(kind, title);
setItems((previous) => mapItems(previous, (item) => {
if (item.id !== selectedId) {
return item;
}
return {
...item,
children: [...item.children, node],
};
}));
setSelectedId(node.id);
};
const moveSelected = (direction: 'up' | 'down'): void => {
if (!selectedPath || selectedPath.length === 0) {
return;
}
const parentPath = selectedPath.slice(0, -1);
const index = selectedPath[selectedPath.length - 1];
const delta = direction === 'up' ? -1 : 1;
setItems((previous) => updateItemsAtLevel(previous, parentPath, (level) => {
const targetIndex = index + delta;
if (targetIndex < 0 || targetIndex >= level.length) {
return level;
}
const next = [...level];
const [moved] = next.splice(index, 1);
next.splice(targetIndex, 0, moved);
return next;
}));
};
const indentSelected = (): void => {
if (!selectedPath || selectedPath.length === 0) {
return;
}
const index = selectedPath[selectedPath.length - 1];
if (index <= 0) {
return;
}
const parentPath = selectedPath.slice(0, -1);
setItems((previous) => {
const removed = removeItemByPath(previous, selectedPath);
if (!removed.removed) {
return previous;
}
const previousSiblingPath = [...parentPath, index - 1];
return updateItemsAtLevel(removed.next, previousSiblingPath, (level) => [...level, removed.removed as MenuItemData]);
});
};
const unindentSelected = (): void => {
if (!selectedPath || selectedPath.length < 2) {
return;
}
const parentPath = selectedPath.slice(0, -1);
const parentIndex = parentPath[parentPath.length - 1];
const grandParentPath = parentPath.slice(0, -1);
setItems((previous) => {
const removed = removeItemByPath(previous, selectedPath);
if (!removed.removed) {
return previous;
}
return insertItemAtPath(removed.next, grandParentPath, parentIndex + 1, removed.removed);
});
};
const deleteSelected = (): void => {
if (!selectedPath || selectedPath.length === 0 || !selectedId) {
return;
}
setItems((previous) => {
const removed = removeItemByPath(previous, selectedPath);
return removed.next;
});
setSelectedId(null);
};
const save = async (): Promise<void> => {
setIsSaving(true);
try {
const payload: MenuDocument = { items };
const saved = await window.electronAPI.menu.save(payload);
setItems(saved.items);
showToast.success(tr('menuEditor.saved'));
} catch (error) {
console.error('Failed to save menu:', error);
showToast.error(tr('menuEditor.saveFailed'));
} finally {
setIsSaving(false);
}
};
return (
<div className="menu-editor-view">
<div className="menu-editor-header">
<div>
<h2>{tr('menuEditor.title')}</h2>
<p>{tr('menuEditor.description')}</p>
</div>
</div>
{isLoading ? (
<div className="menu-editor-loading">{tr('menuEditor.loading')}</div>
) : (
<div className="menu-editor-main">
<div className="menu-editor-tree-wrap">
<div className="menu-editor-toolbar" role="toolbar" aria-label={tr('menuEditor.title')}>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addPage')} aria-label={tr('menuEditor.addPage')} onClick={() => addRootItem('page')}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M3 2h6l4 4v8H3V2zm6 1.5V6h2.5L9 3.5zM7 8V6h2v2h2v2H9v2H7v-2H5V8h2z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addSubmenu')} aria-label={tr('menuEditor.addSubmenu')} onClick={() => addRootItem('submenu')}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h8v2H2V3zm0 4h8v2H2V7zm0 4h8v2H2v-2zm9-8h3v3h-1V4h-2V3zm2 4h1v6h-6v-1h5V7z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addChildPage')} aria-label={tr('menuEditor.addChildPage')} onClick={() => addChildItem('page')}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h5v2H4v6h3v2H2V3zm6 5V6h2v2h2v2h-2v2H8v-2H6V8h2z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addChildSubmenu')} aria-label={tr('menuEditor.addChildSubmenu')} onClick={() => addChildItem('submenu')}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h5v2H4v6h3v2H2V3zm5 2h7v2H7V5zm3 3h4v2h-4V8zm0 3h4v2h-4v-2z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.moveUp')} aria-label={tr('menuEditor.moveUp')} onClick={() => moveSelected('up')} disabled={!selectedPath}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.moveDown')} aria-label={tr('menuEditor.moveDown')} onClick={() => moveSelected('down')} disabled={!selectedPath}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.indent')} aria-label={tr('menuEditor.indent')} onClick={indentSelected} disabled={!selectedPath || selectedPath[selectedPath.length - 1] === 0}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.unindent')} aria-label={tr('menuEditor.unindent')} onClick={unindentSelected} disabled={!selectedPath || selectedPath.length < 2}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.delete')} aria-label={tr('menuEditor.delete')} onClick={deleteSelected} disabled={!selectedPath}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
</button>
<button type="button" className="menu-editor-tool" title={tr('menuEditor.save')} aria-label={tr('menuEditor.save')} onClick={() => void save()} disabled={isSaving}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
</button>
</div>
{items.length === 0 ? (
<div className="menu-editor-empty">{tr('menuEditor.empty')}</div>
) : (
<Tree<MenuItemData>
data={items}
width={720}
height={420}
rowHeight={30}
indent={20}
openByDefault
disableEdit
disableMultiSelection
onMove={({ dragIds, parentId, index }) => {
setItems((previous) => applyTreeMove(previous, {
dragIds,
parentId,
index,
}));
}}
onSelect={(nodes) => {
setSelectedId(nodes[0]?.data.id || null);
}}
>
{({ node, style, tree }) => (
<div
style={style}
className={`menu-editor-row ${selectedId === node.data.id ? 'is-selected' : ''}`}
onClick={() => setSelectedId(node.data.id)}
onMouseEnter={() => {
if (!tree.dragNode || !node.isInternal || node.isOpen) {
autoExpandController.cancel(node.id);
return;
}
autoExpandController.schedule(node.id, () => {
node.open();
});
}}
onMouseLeave={() => {
autoExpandController.cancel(node.id);
}}
>
<span className="menu-editor-row-kind">
{node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')}
</span>
<span className="menu-editor-row-title">{node.data.title}</span>
</div>
)}
</Tree>
)}
</div>
<div className="menu-editor-details">
<h3>{tr('menuEditor.details')}</h3>
{!selectedItem ? (
<p>{tr('menuEditor.selectItem')}</p>
) : (
<>
<label>
<span>{tr('menuEditor.field.title')}</span>
<input
type="text"
value={selectedItem.title}
onChange={(event) => {
const value = event.target.value;
replaceSelected((item) => ({ ...item, title: value }));
}}
/>
</label>
<label>
<span>{tr('menuEditor.field.type')}</span>
<select
value={selectedItem.kind}
onChange={(event) => {
const value = event.target.value as MenuItemKind;
replaceSelected((item) => ({ ...item, kind: value }));
}}
>
<option value="page">{tr('menuEditor.type.page')}</option>
<option value="submenu">{tr('menuEditor.type.submenu')}</option>
</select>
</label>
{selectedItem.kind === 'page' && (
<>
<label>
<span>{tr('menuEditor.field.pageSlug')}</span>
<input
type="text"
value={selectedItem.pageSlug || ''}
onChange={(event) => {
const value = event.target.value;
replaceSelected((item) => ({ ...item, pageSlug: value || undefined }));
}}
/>
</label>
<label>
<span>{tr('menuEditor.field.pageId')}</span>
<input
type="text"
value={selectedItem.pageId || ''}
onChange={(event) => {
const value = event.target.value;
replaceSelected((item) => ({ ...item, pageId: value || undefined }));
}}
/>
</label>
</>
)}
</>
)}
</div>
</div>
)}
{showPagePicker && (
<div className="menu-editor-picker-backdrop" onClick={closePagePicker}>
<div className="menu-editor-picker" onClick={(event) => event.stopPropagation()}>
<div className="menu-editor-picker-header">
<h3>{tr('menuEditor.pagePicker.title')}</h3>
<button type="button" onClick={closePagePicker}>{tr('common.cancel')}</button>
</div>
<input
ref={pagePickerInputRef}
type="text"
value={pagePickerQuery}
onChange={(event) => setPagePickerQuery(event.target.value)}
onKeyDown={(event) => {
if (isPickerCloseKey(event.key)) {
event.preventDefault();
closePagePicker();
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
setPagePickerActiveIndex((previous) => getNextPickerIndex(previous, event.key, filteredPagePosts.length));
return;
}
if (event.key === 'Enter' && pagePickerActiveIndex >= 0 && pagePickerActiveIndex < filteredPagePosts.length) {
event.preventDefault();
selectPageForMenu(filteredPagePosts[pagePickerActiveIndex]);
}
}}
placeholder={tr('menuEditor.pagePicker.searchPlaceholder')}
autoFocus
/>
{pagePickerLoading ? (
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.loading')}</div>
) : filteredPagePosts.length === 0 ? (
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.empty')}</div>
) : (
<div className="menu-editor-picker-list">
{filteredPagePosts.map((post) => (
<button
key={post.id}
type="button"
className={`menu-editor-picker-item ${filteredPagePosts[pagePickerActiveIndex]?.id === post.id ? 'is-active' : ''}`}
onClick={() => selectPageForMenu(post)}
onMouseEnter={() => {
const nextIndex = filteredPagePosts.findIndex((candidate) => candidate.id === post.id);
setPagePickerActiveIndex(nextIndex);
}}
>
<span>{post.title}</span>
<small>/{post.slug}</small>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,43 @@
interface AutoExpandController {
schedule: (id: string, callback: () => void) => void;
cancel: (id: string) => void;
cancelAll: () => void;
}
export function createAutoExpandController(delayMs: number): AutoExpandController {
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const cancel = (id: string): void => {
const timer = timers.get(id);
if (!timer) {
return;
}
clearTimeout(timer);
timers.delete(id);
};
const cancelAll = (): void => {
for (const timer of timers.values()) {
clearTimeout(timer);
}
timers.clear();
};
const schedule = (id: string, callback: () => void): void => {
cancel(id);
const timer = setTimeout(() => {
timers.delete(id);
callback();
}, delayMs);
timers.set(id, timer);
};
return {
schedule,
cancel,
cancelAll,
};
}

View File

@@ -0,0 +1,55 @@
import type { MenuItemData, PostData } from '../../../main/shared/electronApi';
function createMenuItemId(): string {
return `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function filterPagePosts(posts: PostData[], query: string): PostData[] {
const normalized = query.trim().toLowerCase();
return posts.filter((post) => {
if (!(post.categories || []).includes('page')) {
return false;
}
if (!normalized) {
return true;
}
return post.title.toLowerCase().includes(normalized) || post.slug.toLowerCase().includes(normalized);
});
}
export function createMenuPageItemFromPost(post: PostData): MenuItemData {
return {
id: createMenuItemId(),
title: post.title,
kind: 'page',
pageId: post.id,
pageSlug: post.slug,
children: [],
};
}
export function getNextPickerIndex(currentIndex: number, key: 'ArrowDown' | 'ArrowUp', total: number): number {
if (total <= 0) {
return -1;
}
if (key === 'ArrowDown') {
const next = currentIndex + 1;
return next >= total ? 0 : Math.max(0, next);
}
const next = currentIndex < 0 ? total - 1 : currentIndex - 1;
return next < 0 ? total - 1 : next;
}
export function isPickerCloseKey(key: string): boolean {
return key === 'Escape';
}
export function isPickerFocusShortcut(event: { key: string; metaKey: boolean; ctrlKey: boolean }): boolean {
const normalizedKey = event.key.toLowerCase();
return normalizedKey === 'k' && (event.metaKey || event.ctrlKey);
}

View File

@@ -0,0 +1,115 @@
import type { MenuItemData } from '../../../main/shared/electronApi';
export type MenuTreeItem = MenuItemData;
interface TreeMoveInput {
dragIds: string[];
parentId: string | null;
index: number;
}
function findPathById(items: MenuTreeItem[], id: string, path: number[] = []): number[] | null {
for (let index = 0; index < items.length; index += 1) {
const item = items[index];
const nextPath = [...path, index];
if (item.id === id) {
return nextPath;
}
const nested = findPathById(item.children, id, nextPath);
if (nested) {
return nested;
}
}
return null;
}
function removeItemByPath(items: MenuTreeItem[], path: number[]): { next: MenuTreeItem[]; removed: MenuTreeItem | null } {
if (path.length === 0) {
return { next: items, removed: null };
}
if (path.length === 1) {
const [index] = path;
if (index < 0 || index >= items.length) {
return { next: items, removed: null };
}
const removed = items[index];
return {
next: items.filter((_, currentIndex) => currentIndex !== index),
removed,
};
}
const [head, ...tail] = path;
const current = items[head];
if (!current) {
return { next: items, removed: null };
}
const nested = removeItemByPath(current.children, tail);
if (!nested.removed) {
return { next: items, removed: null };
}
const next = items.map((item, index) => (index === head ? { ...item, children: nested.next } : item));
return { next, removed: nested.removed };
}
function insertItemsAtPath(items: MenuTreeItem[], parentPath: number[], index: number, nodes: MenuTreeItem[]): MenuTreeItem[] {
if (parentPath.length === 0) {
const boundedIndex = Math.max(0, Math.min(index, items.length));
return [
...items.slice(0, boundedIndex),
...nodes,
...items.slice(boundedIndex),
];
}
const [head, ...tail] = parentPath;
return items.map((item, currentIndex) => {
if (currentIndex !== head) {
return item;
}
return {
...item,
children: insertItemsAtPath(item.children, tail, index, nodes),
};
});
}
export function applyTreeMove(items: MenuTreeItem[], move: TreeMoveInput): MenuTreeItem[] {
if (!move.dragIds.length) {
return items;
}
let working = items;
const draggedNodes: MenuTreeItem[] = [];
for (const dragId of move.dragIds) {
const path = findPathById(working, dragId);
if (!path) {
continue;
}
const removed = removeItemByPath(working, path);
if (removed.removed) {
draggedNodes.push(removed.removed);
working = removed.next;
}
}
if (!draggedNodes.length) {
return items;
}
const parentPath = move.parentId ? findPathById(working, move.parentId) : [];
if (move.parentId && !parentPath) {
return working;
}
return insertItemsAtPath(working, parentPath || [], move.index, draggedNodes);
}

View File

@@ -64,6 +64,10 @@ const getTabTitle = (
return importDefTitles.get(tab.id) || tr('activity.import');
}
if (tab.type === 'menu-editor') {
return tr('menuEditor.tabTitle');
}
if (tab.type === 'metadata-diff') {
return tr('app.metadataDiff');
}
@@ -129,6 +133,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
);
case 'menu-editor':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 3h12v1H2V3zm0 3h12v1H2V6zm0 3h8v1H2V9zm0 3h8v1H2v-1zm10-2 2 2-2 2v-1H9v-2h3V10z"/>
</svg>
);
case 'metadata-diff':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

View File

@@ -41,6 +41,40 @@
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
"siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen",
"siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht",
"menuEditor.tabTitle": "Blog-Menü",
"menuEditor.title": "Blog-Menü-Editor",
"menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.",
"menuEditor.loading": "Menü wird geladen...",
"menuEditor.loadError": "Blog-Menü konnte nicht geladen werden",
"menuEditor.save": "Menü speichern",
"menuEditor.saving": "Speichern...",
"menuEditor.saved": "Blog-Menü gespeichert",
"menuEditor.saveFailed": "Blog-Menü konnte nicht gespeichert werden",
"menuEditor.pagePicker.title": "Seite auswählen",
"menuEditor.pagePicker.searchPlaceholder": "Seiten nach Titel oder Slug durchsuchen...",
"menuEditor.pagePicker.loading": "Seiten werden geladen...",
"menuEditor.pagePicker.empty": "Keine passenden Seiten gefunden.",
"menuEditor.pagePicker.loadError": "Seiten konnten nicht geladen werden",
"menuEditor.addPage": "Seite hinzufügen",
"menuEditor.addSubmenu": "Untermenü hinzufügen",
"menuEditor.addChildPage": "Unterseite hinzufügen",
"menuEditor.addChildSubmenu": "Unter-Untermenü hinzufügen",
"menuEditor.moveUp": "Nach oben",
"menuEditor.moveDown": "Nach unten",
"menuEditor.indent": "Einrücken",
"menuEditor.unindent": "Ausrücken",
"menuEditor.delete": "Löschen",
"menuEditor.details": "Eintragsdetails",
"menuEditor.selectItem": "Wähle einen Eintrag, um Details zu bearbeiten.",
"menuEditor.field.title": "Titel",
"menuEditor.field.type": "Typ",
"menuEditor.field.pageSlug": "Seiten-Slug",
"menuEditor.field.pageId": "Seiten-ID",
"menuEditor.type.page": "Seite",
"menuEditor.type.submenu": "Untermenü",
"menuEditor.empty": "Noch keine Menüeinträge. Füge eine Seite oder ein Untermenü hinzu.",
"menuEditor.newPage": "Neue Seite",
"menuEditor.newSubmenu": "Neues Untermenü",
"settings.language.english": "Englisch",
"settings.language.german": "Deutsch",
"settings.language.french": "Französisch",

View File

@@ -41,6 +41,40 @@
"siteValidation.error.validate": "Site validation failed",
"siteValidation.error.apply": "Applying validation failed",
"siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted",
"menuEditor.tabTitle": "Blog Menu",
"menuEditor.title": "Blog Menu Editor",
"menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.",
"menuEditor.loading": "Loading menu...",
"menuEditor.loadError": "Failed to load blog menu",
"menuEditor.save": "Save Menu",
"menuEditor.saving": "Saving...",
"menuEditor.saved": "Blog menu saved",
"menuEditor.saveFailed": "Failed to save blog menu",
"menuEditor.pagePicker.title": "Select Page",
"menuEditor.pagePicker.searchPlaceholder": "Search pages by title or slug...",
"menuEditor.pagePicker.loading": "Loading pages...",
"menuEditor.pagePicker.empty": "No matching pages found.",
"menuEditor.pagePicker.loadError": "Failed to load pages",
"menuEditor.addPage": "Add Page",
"menuEditor.addSubmenu": "Add Submenu",
"menuEditor.addChildPage": "Add Child Page",
"menuEditor.addChildSubmenu": "Add Child Submenu",
"menuEditor.moveUp": "Move Up",
"menuEditor.moveDown": "Move Down",
"menuEditor.indent": "Indent",
"menuEditor.unindent": "Unindent",
"menuEditor.delete": "Delete",
"menuEditor.details": "Entry Details",
"menuEditor.selectItem": "Select an entry to edit details.",
"menuEditor.field.title": "Title",
"menuEditor.field.type": "Type",
"menuEditor.field.pageSlug": "Page Slug",
"menuEditor.field.pageId": "Page ID",
"menuEditor.type.page": "Page",
"menuEditor.type.submenu": "Submenu",
"menuEditor.empty": "No menu entries yet. Add a page or submenu to start.",
"menuEditor.newPage": "New Page",
"menuEditor.newSubmenu": "New Submenu",
"settings.language.english": "English",
"settings.language.german": "German",
"settings.language.french": "French",

View File

@@ -41,6 +41,40 @@
"siteValidation.error.validate": "La validación del sitio falló",
"siteValidation.error.apply": "La aplicación de la validación falló",
"siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas",
"menuEditor.tabTitle": "Menú del blog",
"menuEditor.title": "Editor del menú del blog",
"menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.",
"menuEditor.loading": "Cargando menú...",
"menuEditor.loadError": "No se pudo cargar el menú del blog",
"menuEditor.save": "Guardar menú",
"menuEditor.saving": "Guardando...",
"menuEditor.saved": "Menú del blog guardado",
"menuEditor.saveFailed": "No se pudo guardar el menú del blog",
"menuEditor.pagePicker.title": "Seleccionar página",
"menuEditor.pagePicker.searchPlaceholder": "Buscar páginas por título o slug...",
"menuEditor.pagePicker.loading": "Cargando páginas...",
"menuEditor.pagePicker.empty": "No se encontraron páginas coincidentes.",
"menuEditor.pagePicker.loadError": "No se pudieron cargar las páginas",
"menuEditor.addPage": "Añadir página",
"menuEditor.addSubmenu": "Añadir submenú",
"menuEditor.addChildPage": "Añadir página hija",
"menuEditor.addChildSubmenu": "Añadir submenú hijo",
"menuEditor.moveUp": "Mover arriba",
"menuEditor.moveDown": "Mover abajo",
"menuEditor.indent": "Sangrar",
"menuEditor.unindent": "Quitar sangría",
"menuEditor.delete": "Eliminar",
"menuEditor.details": "Detalles de la entrada",
"menuEditor.selectItem": "Selecciona una entrada para editar sus detalles.",
"menuEditor.field.title": "Título",
"menuEditor.field.type": "Tipo",
"menuEditor.field.pageSlug": "Slug de página",
"menuEditor.field.pageId": "ID de página",
"menuEditor.type.page": "Página",
"menuEditor.type.submenu": "Submenú",
"menuEditor.empty": "Aún no hay entradas de menú. Añade una página o un submenú para empezar.",
"menuEditor.newPage": "Nueva página",
"menuEditor.newSubmenu": "Nuevo submenú",
"settings.language.english": "Inglés",
"settings.language.german": "Alemán",
"settings.language.french": "Francés",

View File

@@ -41,6 +41,40 @@
"siteValidation.error.validate": "Échec de la validation du site",
"siteValidation.error.apply": "Échec de lapplication de la validation",
"siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées",
"menuEditor.tabTitle": "Menu du blog",
"menuEditor.title": "Éditeur du menu du blog",
"menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.",
"menuEditor.loading": "Chargement du menu...",
"menuEditor.loadError": "Impossible de charger le menu du blog",
"menuEditor.save": "Enregistrer le menu",
"menuEditor.saving": "Enregistrement...",
"menuEditor.saved": "Menu du blog enregistré",
"menuEditor.saveFailed": "Impossible denregistrer le menu du blog",
"menuEditor.pagePicker.title": "Sélectionner une page",
"menuEditor.pagePicker.searchPlaceholder": "Rechercher des pages par titre ou slug...",
"menuEditor.pagePicker.loading": "Chargement des pages...",
"menuEditor.pagePicker.empty": "Aucune page correspondante trouvée.",
"menuEditor.pagePicker.loadError": "Impossible de charger les pages",
"menuEditor.addPage": "Ajouter une page",
"menuEditor.addSubmenu": "Ajouter un sous-menu",
"menuEditor.addChildPage": "Ajouter une page enfant",
"menuEditor.addChildSubmenu": "Ajouter un sous-menu enfant",
"menuEditor.moveUp": "Monter",
"menuEditor.moveDown": "Descendre",
"menuEditor.indent": "Indenter",
"menuEditor.unindent": "Désindenter",
"menuEditor.delete": "Supprimer",
"menuEditor.details": "Détails de lentrée",
"menuEditor.selectItem": "Sélectionnez une entrée pour modifier ses détails.",
"menuEditor.field.title": "Titre",
"menuEditor.field.type": "Type",
"menuEditor.field.pageSlug": "Slug de page",
"menuEditor.field.pageId": "ID de page",
"menuEditor.type.page": "Page",
"menuEditor.type.submenu": "Sous-menu",
"menuEditor.empty": "Aucune entrée de menu. Ajoutez une page ou un sous-menu pour commencer.",
"menuEditor.newPage": "Nouvelle page",
"menuEditor.newSubmenu": "Nouveau sous-menu",
"settings.language.english": "Anglais",
"settings.language.german": "Allemand",
"settings.language.french": "Français",

View File

@@ -41,6 +41,40 @@
"siteValidation.error.validate": "Validazione del sito non riuscita",
"siteValidation.error.apply": "Applicazione della validazione non riuscita",
"siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati",
"menuEditor.tabTitle": "Menu blog",
"menuEditor.title": "Editor del menu blog",
"menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.",
"menuEditor.loading": "Caricamento menu...",
"menuEditor.loadError": "Impossibile caricare il menu blog",
"menuEditor.save": "Salva menu",
"menuEditor.saving": "Salvataggio...",
"menuEditor.saved": "Menu blog salvato",
"menuEditor.saveFailed": "Impossibile salvare il menu blog",
"menuEditor.pagePicker.title": "Seleziona pagina",
"menuEditor.pagePicker.searchPlaceholder": "Cerca pagine per titolo o slug...",
"menuEditor.pagePicker.loading": "Caricamento pagine...",
"menuEditor.pagePicker.empty": "Nessuna pagina corrispondente trovata.",
"menuEditor.pagePicker.loadError": "Impossibile caricare le pagine",
"menuEditor.addPage": "Aggiungi pagina",
"menuEditor.addSubmenu": "Aggiungi sottomenu",
"menuEditor.addChildPage": "Aggiungi pagina figlia",
"menuEditor.addChildSubmenu": "Aggiungi sottomenu figlio",
"menuEditor.moveUp": "Sposta su",
"menuEditor.moveDown": "Sposta giù",
"menuEditor.indent": "Indenta",
"menuEditor.unindent": "Riduci rientro",
"menuEditor.delete": "Elimina",
"menuEditor.details": "Dettagli voce",
"menuEditor.selectItem": "Seleziona una voce per modificarne i dettagli.",
"menuEditor.field.title": "Titolo",
"menuEditor.field.type": "Tipo",
"menuEditor.field.pageSlug": "Slug pagina",
"menuEditor.field.pageId": "ID pagina",
"menuEditor.type.page": "Pagina",
"menuEditor.type.submenu": "Sottomenu",
"menuEditor.empty": "Nessuna voce menu. Aggiungi una pagina o un sottomenu per iniziare.",
"menuEditor.newPage": "Nuova pagina",
"menuEditor.newSubmenu": "Nuovo sottomenu",
"settings.language.english": "Inglese",
"settings.language.german": "Tedesco",
"settings.language.french": "Francese",

View File

@@ -10,6 +10,7 @@ export type EditorRoute =
| 'tags'
| 'chat'
| 'import'
| 'menu-editor'
| 'metadata-diff'
| 'git-diff'
| 'documentation'
@@ -23,6 +24,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
tags: 'tags',
chat: 'chat',
import: 'import',
'menu-editor': 'menu-editor',
'metadata-diff': 'metadata-diff',
'git-diff': 'git-diff',
documentation: 'documentation',

View File

@@ -4,6 +4,7 @@ export type SingletonToolTabKey =
| 'settings'
| 'tags'
| 'style'
| 'menu-editor'
| 'documentation'
| 'metadata-diff'
| 'site-validation';
@@ -22,6 +23,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
settings: { type: 'settings', id: 'settings', isTransient: false },
tags: { type: 'tags', id: 'tags', isTransient: false },
style: { type: 'style', id: 'style', isTransient: false },
'menu-editor': { type: 'menu-editor', id: 'menu-editor', isTransient: false },
documentation: { type: 'documentation', id: 'documentation', isTransient: false },
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },

View File

@@ -13,7 +13,7 @@ import type {
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation';
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation';
export interface Tab {
type: TabType;