diff --git a/src/main/engine/MenuEngine.ts b/src/main/engine/MenuEngine.ts index 5b01f05..c151633 100644 --- a/src/main/engine/MenuEngine.ts +++ b/src/main/engine/MenuEngine.ts @@ -5,7 +5,19 @@ import { randomUUID } from 'crypto'; import { app } from 'electron'; import { XMLBuilder, XMLParser } from 'fast-xml-parser'; -export type MenuItemKind = 'page' | 'submenu'; +export type MenuItemKind = 'page' | 'submenu' | 'category-archive'; + +const HOME_MENU_ID = 'menu-home'; + +const DEFAULT_HOME_ITEM: MenuItemData = { + id: HOME_MENU_ID, + title: 'Home', + kind: 'page', + pageId: undefined, + pageSlug: 'home', + categoryName: undefined, + children: [], +}; export interface MenuItemData { id: string; @@ -13,6 +25,7 @@ export interface MenuItemData { kind: MenuItemKind; pageId?: string; pageSlug?: string; + categoryName?: string; children: MenuItemData[]; } @@ -27,6 +40,7 @@ type OpmlOutlineNode = { '@_type'?: string; '@_pageId'?: string; '@_pageSlug'?: string; + '@_categoryName'?: string; outline?: OpmlOutlineNode | OpmlOutlineNode[]; }; @@ -61,7 +75,11 @@ function normalizeNonEmptyString(value: unknown): string | undefined { function sanitizeMenuItem(input: unknown): MenuItemData { const candidate = (input && typeof input === 'object') ? input as Record : {}; - const kind = candidate.kind === 'submenu' ? 'submenu' : 'page'; + const kind: MenuItemKind = candidate.kind === 'submenu' + ? 'submenu' + : candidate.kind === 'category-archive' + ? 'category-archive' + : 'page'; const childrenSource = Array.isArray(candidate.children) ? candidate.children : []; const title = normalizeNonEmptyString(candidate.title) || 'Untitled'; @@ -71,10 +89,70 @@ function sanitizeMenuItem(input: unknown): MenuItemData { kind, pageId: kind === 'page' ? normalizeNonEmptyString(candidate.pageId) : undefined, pageSlug: kind === 'page' ? normalizeNonEmptyString(candidate.pageSlug) : undefined, + categoryName: kind === 'category-archive' ? normalizeNonEmptyString(candidate.categoryName) : undefined, children: childrenSource.map((child) => sanitizeMenuItem(child)), }; } +function normalizeHomeItem(item: MenuItemData): MenuItemData { + return { + ...item, + id: HOME_MENU_ID, + title: 'Home', + kind: 'page', + pageId: undefined, + pageSlug: 'home', + categoryName: undefined, + children: [], + }; +} + +function extractHomeItem(items: MenuItemData[]): { homeItem: MenuItemData | null; remainingItems: MenuItemData[] } { + let extractedHome: MenuItemData | null = null; + + const isHomeCandidate = (node: MenuItemData): boolean => { + if (node.id === HOME_MENU_ID) { + return true; + } + + return node.kind === 'page' && (node.pageSlug?.toLowerCase() === 'home' || node.title.trim().toLowerCase() === 'home'); + }; + + const walk = (nodes: MenuItemData[]): MenuItemData[] => { + const next: MenuItemData[] = []; + + for (const node of nodes) { + if (isHomeCandidate(node)) { + if (!extractedHome) { + extractedHome = normalizeHomeItem(node); + } + continue; + } + + next.push({ + ...node, + children: walk(node.children), + }); + } + + return next; + }; + + const remainingItems = walk(items); + return { + homeItem: extractedHome, + remainingItems, + }; +} + +function enforceHomeEntry(input: MenuDocument): MenuDocument { + const { homeItem, remainingItems } = extractHomeItem(input.items); + const ensuredHome = homeItem ? normalizeHomeItem(homeItem) : { ...DEFAULT_HOME_ITEM }; + return { + items: [ensuredHome, ...remainingItems], + }; +} + function sanitizeMenuDocument(input: unknown): MenuDocument { const candidate = (input && typeof input === 'object') ? input as Record : {}; const items = Array.isArray(candidate.items) ? candidate.items : []; @@ -84,7 +162,12 @@ function sanitizeMenuDocument(input: unknown): MenuDocument { } function parseOutlineNode(node: OpmlOutlineNode): MenuItemData { - const kind: MenuItemKind = node['@_type'] === 'submenu' ? 'submenu' : 'page'; + const rawType = normalizeNonEmptyString(node['@_type']); + const kind: MenuItemKind = rawType === 'submenu' + ? 'submenu' + : rawType === 'category-archive' + ? 'category-archive' + : 'page'; const title = normalizeNonEmptyString(node['@_text']) || normalizeNonEmptyString(node['@_title']) || 'Untitled'; return { @@ -93,6 +176,7 @@ function parseOutlineNode(node: OpmlOutlineNode): MenuItemData { kind, pageId: kind === 'page' ? normalizeNonEmptyString(node['@_pageId']) : undefined, pageSlug: kind === 'page' ? normalizeNonEmptyString(node['@_pageSlug']) : undefined, + categoryName: kind === 'category-archive' ? normalizeNonEmptyString(node['@_categoryName']) : undefined, children: normalizeOutlineNodes(node.outline).map((child) => parseOutlineNode(child)), }; } @@ -112,6 +196,10 @@ function toOpmlOutlineNode(item: MenuItemData): OpmlOutlineNode { outlineNode['@_pageSlug'] = item.pageSlug; } + if (item.kind === 'category-archive' && item.categoryName) { + outlineNode['@_categoryName'] = item.categoryName; + } + if (item.children.length > 0) { outlineNode.outline = item.children.map((child) => toOpmlOutlineNode(child)); } @@ -154,7 +242,7 @@ export class MenuEngine extends EventEmitter { } catch (error) { const asErrno = error as NodeJS.ErrnoException; if (asErrno?.code === 'ENOENT') { - return { items: [] }; + return enforceHomeEntry({ items: [] }); } throw error; } @@ -175,11 +263,11 @@ export class MenuEngine extends EventEmitter { const outlineNodes = normalizeOutlineNodes(parsed?.opml?.body?.outline); const items = outlineNodes.map((node) => parseOutlineNode(node)); - return sanitizeMenuDocument({ items }); + return enforceHomeEntry(sanitizeMenuDocument({ items })); } async saveMenu(input: MenuDocument): Promise { - const sanitized = sanitizeMenuDocument(input); + const sanitized = enforceHomeEntry(sanitizeMenuDocument(input)); const builder = new XMLBuilder({ ignoreAttributes: false, diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 7aaca43..980bd4a 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -422,7 +422,7 @@ export interface SiteValidationApplyResult { removedEmptyDirCount: number; } -export type MenuItemKind = 'page' | 'submenu'; +export type MenuItemKind = 'page' | 'submenu' | 'category-archive'; export interface MenuItemData { id: string; @@ -430,6 +430,7 @@ export interface MenuItemData { kind: MenuItemKind; pageId?: string; pageSlug?: string; + categoryName?: string; children: MenuItemData[]; } diff --git a/src/renderer/components/CategoryInput/CategoryInput.css b/src/renderer/components/CategoryInput/CategoryInput.css new file mode 100644 index 0000000..729c16f --- /dev/null +++ b/src/renderer/components/CategoryInput/CategoryInput.css @@ -0,0 +1,18 @@ +.category-input-wrapper-inline { + padding: 0; + min-height: 0; + border: none; + background: transparent; + gap: 0; +} + +.category-input-wrapper-inline:focus-within { + border: none; + outline: none; +} + +.category-input-field-inline { + min-width: 0; + padding: 0; + line-height: 1.25; +} diff --git a/src/renderer/components/CategoryInput/CategoryInput.tsx b/src/renderer/components/CategoryInput/CategoryInput.tsx new file mode 100644 index 0000000..9964768 --- /dev/null +++ b/src/renderer/components/CategoryInput/CategoryInput.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import '../TagInput/TagInput.css'; +import './CategoryInput.css'; + +interface CategoryInputProps { + categories: string[]; + onSelectCategory: (categoryName: string) => void; + placeholder?: string; + createCategoryArchiveLabel: string; + disabled?: boolean; + autoFocus?: boolean; + inlinePlain?: boolean; +} + +export const CategoryInput: React.FC = ({ + categories, + onSelectCategory, + placeholder = '', + createCategoryArchiveLabel, + disabled = false, + autoFocus = false, + inlinePlain = false, +}) => { + const [inputValue, setInputValue] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const inputRef = useRef(null); + const containerRef = useRef(null); + + const suggestions = useMemo(() => { + if (!inputValue.trim()) { + return []; + } + + const query = inputValue.toLowerCase().trim(); + return categories + .filter((categoryName) => categoryName.toLowerCase().includes(query)) + .slice(0, 8); + }, [categories, inputValue]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent): void => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowSuggestions(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + if (!autoFocus || disabled) { + return; + } + + const timer = setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + + return () => clearTimeout(timer); + }, [autoFocus, disabled]); + + const createArchive = (label: string): void => { + const trimmed = label.trim(); + if (!trimmed) { + return; + } + + onSelectCategory(trimmed); + setInputValue(''); + setShowSuggestions(false); + setSelectedIndex(-1); + }; + + const exactMatchExists = inputValue.trim() + ? suggestions.some((item) => item.toLowerCase() === inputValue.trim().toLowerCase()) + : false; + + const showCreateOption = inputValue.trim() && !exactMatchExists; + + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + const maxIndex = suggestions.length + (showCreateOption ? 0 : -1); + setSelectedIndex((previous) => Math.min(previous + 1, maxIndex)); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + setSelectedIndex((previous) => Math.max(previous - 1, -1)); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + createArchive(suggestions[selectedIndex]); + } else if (selectedIndex === suggestions.length && showCreateOption) { + createArchive(inputValue); + } else { + const exactMatch = categories.find((categoryName) => categoryName.toLowerCase() === inputValue.trim().toLowerCase()); + if (exactMatch) { + createArchive(exactMatch); + } else if (inputValue.trim()) { + createArchive(inputValue); + } + } + return; + } + + if (event.key === 'Escape') { + setShowSuggestions(false); + setInputValue(''); + } + }; + + return ( +
+
+ { + setInputValue(event.target.value); + setShowSuggestions(true); + }} + onInput={(event) => { + setInputValue((event.target as HTMLInputElement).value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + autoComplete="off" + /> +
+ + {showSuggestions && (suggestions.length > 0 || showCreateOption) && ( +
+ {suggestions.map((categoryName, index) => ( + + ))} + + {showCreateOption && ( + + )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/CategoryInput/index.ts b/src/renderer/components/CategoryInput/index.ts new file mode 100644 index 0000000..c4ff9eb --- /dev/null +++ b/src/renderer/components/CategoryInput/index.ts @@ -0,0 +1 @@ +export { CategoryInput } from './CategoryInput'; diff --git a/src/renderer/components/MenuEditorView/MenuEditorView.css b/src/renderer/components/MenuEditorView/MenuEditorView.css index f2ff14f..36f7005 100644 --- a/src/renderer/components/MenuEditorView/MenuEditorView.css +++ b/src/renderer/components/MenuEditorView/MenuEditorView.css @@ -29,12 +29,16 @@ } .menu-editor-main { - display: block; + display: flex; + flex-direction: column; min-height: 0; flex: 1; } .menu-editor-tree-wrap { + display: flex; + flex-direction: column; + flex: 1; border: 1px solid var(--vscode-panel-border); border-radius: 6px; background: var(--vscode-editor-background); @@ -42,6 +46,11 @@ min-height: 0; } +.menu-editor-tree-wrap [role='tree'] { + flex: 1; + min-height: 0; +} + .menu-editor-toolbar { display: flex; align-items: center; diff --git a/src/renderer/components/MenuEditorView/MenuEditorView.tsx b/src/renderer/components/MenuEditorView/MenuEditorView.tsx index a0b5252..14c0149 100644 --- a/src/renderer/components/MenuEditorView/MenuEditorView.tsx +++ b/src/renderer/components/MenuEditorView/MenuEditorView.tsx @@ -4,12 +4,15 @@ import { useI18n } from '../../i18n'; import { showToast } from '../Toast'; import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi'; import { PageInput } from '../PageInput'; +import { CategoryInput } from '../CategoryInput'; import { createAutoExpandController } from './menuAutoExpand'; import { resolveInsertTarget } from './menuInsertTarget'; import { isPickerCloseKey } from './menuPagePicker'; import { applyTreeMove } from './menuTreeMove'; import './MenuEditorView.css'; +const HOME_MENU_ID = 'menu-home'; + interface ToolButtonProps { label: string; disabled?: boolean; @@ -152,13 +155,14 @@ function mapItems(items: MenuItemData[], mapper: (item: MenuItemData) => MenuIte }); } -function createDraftEntry(): MenuItemData { +function createDraftEntry(kind: MenuItemData['kind'] = 'submenu'): MenuItemData { return { id: createMenuItemId(), title: '', - kind: 'submenu', + kind, pageId: undefined, pageSlug: undefined, + categoryName: undefined, children: [], }; } @@ -171,9 +175,15 @@ export const MenuEditorView: React.FC = () => { const [isSaving, setIsSaving] = useState(false); const [isLoadingPages, setIsLoadingPages] = useState(false); const [pagePosts, setPagePosts] = useState([]); + const [categories, setCategories] = useState([]); + const [isLoadingCategories, setIsLoadingCategories] = useState(false); const [editingEntryId, setEditingEntryId] = useState(null); + const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null); + const [treeHeight, setTreeHeight] = useState(460); const [toolbarTooltip, setToolbarTooltip] = useState(''); const [recentParentInsertId, setRecentParentInsertId] = useState(null); + const treeWrapRef = useRef(null); + const toolbarRef = useRef(null); const recentInsertTimerRef = useRef | null>(null); const autoExpandController = useMemo(() => createAutoExpandController(450), []); @@ -220,6 +230,7 @@ export const MenuEditorView: React.FC = () => { return removeItemByPath(previous, path).next; }); setEditingEntryId(null); + setEditingEntryType(null); } }; @@ -230,7 +241,7 @@ export const MenuEditorView: React.FC = () => { }, [editingEntryId]); useEffect(() => { - if (!editingEntryId || isLoadingPages) { + if (!editingEntryId || (editingEntryType === 'page' && isLoadingPages) || (editingEntryType === 'category' && isLoadingCategories)) { return; } @@ -250,7 +261,50 @@ export const MenuEditorView: React.FC = () => { clearTimeout(immediate); clearTimeout(delayed); }; - }, [editingEntryId, isLoadingPages]); + }, [editingEntryId, editingEntryType, isLoadingPages, isLoadingCategories]); + + useEffect(() => { + const updateTreeHeight = (): void => { + const wrap = treeWrapRef.current; + const toolbar = toolbarRef.current; + if (!wrap) { + return; + } + + const wrapHeight = wrap.clientHeight; + const toolbarHeight = toolbar?.offsetHeight ?? 0; + const next = Math.max(120, wrapHeight - toolbarHeight - 8); + setTreeHeight(next); + }; + + updateTreeHeight(); + + if (typeof ResizeObserver === 'undefined') { + if (typeof window.addEventListener !== 'function') { + return; + } + + window.addEventListener('resize', updateTreeHeight); + return () => { + window.removeEventListener('resize', updateTreeHeight); + }; + } + + const observer = new ResizeObserver(() => { + updateTreeHeight(); + }); + + if (treeWrapRef.current) { + observer.observe(treeWrapRef.current); + } + if (toolbarRef.current) { + observer.observe(toolbarRef.current); + } + + return () => { + observer.disconnect(); + }; + }, [editingEntryId]); const selectedPath = useMemo(() => { if (!selectedId) { @@ -277,6 +331,24 @@ export const MenuEditorView: React.FC = () => { } }; + const ensureCategoriesLoaded = async (): Promise => { + if (categories.length > 0) { + return; + } + + setIsLoadingCategories(true); + try { + const nextCategories = await window.electronAPI.meta.getCategories(); + setCategories(nextCategories); + } catch (error) { + console.error('Failed to load categories:', error); + showToast.error(tr('menuEditor.categoryPicker.loadError')); + setCategories([]); + } finally { + setIsLoadingCategories(false); + } + }; + const setDraftAsSubmenu = (label: string): void => { if (!editingEntryId) { return; @@ -300,6 +372,7 @@ export const MenuEditorView: React.FC = () => { })); setEditingEntryId(null); + setEditingEntryType(null); }; const setDraftAsPage = (post: PostData): void => { @@ -322,6 +395,36 @@ export const MenuEditorView: React.FC = () => { })); setEditingEntryId(null); + setEditingEntryType(null); + }; + + const setDraftAsCategoryArchive = (categoryName: string): void => { + if (!editingEntryId) { + return; + } + + const trimmed = categoryName.trim(); + if (!trimmed) { + return; + } + + setItems((previous) => mapItems(previous, (item) => { + if (item.id !== editingEntryId) { + return item; + } + + return { + ...item, + title: trimmed, + kind: 'category-archive', + pageId: undefined, + pageSlug: undefined, + categoryName: trimmed, + }; + })); + + setEditingEntryId(null); + setEditingEntryType(null); }; const startCreateEntry = async (): Promise => { @@ -360,6 +463,22 @@ export const MenuEditorView: React.FC = () => { setSelectedId(newEntry.id); setEditingEntryId(newEntry.id); + setEditingEntryType('page'); + }; + + const startCreateCategoryArchive = async (): Promise => { + await ensureCategoriesLoaded(); + + const newEntry = createDraftEntry('category-archive'); + const target = resolveInsertTarget(items, selectedId); + + setItems((previous) => { + return insertItemAtPath(previous, target.parentPath, target.index, newEntry); + }); + + setSelectedId(newEntry.id); + setEditingEntryId(newEntry.id); + setEditingEntryType('category'); }; const save = async (): Promise => { @@ -445,6 +564,10 @@ export const MenuEditorView: React.FC = () => { return; } + if (selectedId === HOME_MENU_ID) { + return; + } + setItems((previous) => { const removed = removeItemByPath(previous, selectedPath); return removed.next; @@ -452,10 +575,13 @@ export const MenuEditorView: React.FC = () => { if (editingEntryId === selectedId) { setEditingEntryId(null); + setEditingEntryType(null); } setSelectedId(null); }; + const isHomeSelected = selectedId === HOME_MENU_ID; + return (
@@ -469,8 +595,8 @@ export const MenuEditorView: React.FC = () => {
{tr('menuEditor.loading')}
) : (
-
-
+
+
void startCreateEntry()} @@ -488,6 +614,14 @@ export const MenuEditorView: React.FC = () => { > + void startCreateCategoryArchive()} + onShowTooltip={setToolbarTooltip} + onHideTooltip={() => setToolbarTooltip('')} + > + + moveSelected('up')} @@ -527,7 +661,7 @@ export const MenuEditorView: React.FC = () => { setToolbarTooltip('')} > @@ -544,7 +678,7 @@ export const MenuEditorView: React.FC = () => { data={items} width="100%" - height={editingEntryId ? 320 : 460} + height={treeHeight} rowHeight={32} indent={20} openByDefault @@ -584,20 +718,36 @@ export const MenuEditorView: React.FC = () => { > <> - {node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')} + {node.data.kind === 'page' + ? tr('menuEditor.type.page') + : node.data.kind === 'category-archive' + ? tr('menuEditor.type.categoryArchive') + : tr('menuEditor.type.submenu')} {editingEntryId === node.data.id ? ( - + editingEntryType === 'category' ? ( + + ) : ( + + ) ) : node.data.title} diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index c4f2e86..b231484 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -59,6 +59,8 @@ "menuEditor.pagePicker.empty": "Keine passenden Seiten gefunden.", "menuEditor.pagePicker.loadError": "Seiten konnten nicht geladen werden", "menuEditor.addPage": "Seite hinzufügen", + "menuEditor.addCategoryArchive": "Kategorie-Archiv hinzufügen", + "menuEditor.addCategoryArchiveShort": "C+", "menuEditor.addSubmenu": "Untermenü hinzufügen", "menuEditor.addChildPage": "Unterseite hinzufügen", "menuEditor.addChildSubmenu": "Unter-Untermenü hinzufügen", @@ -75,9 +77,12 @@ "menuEditor.field.pageId": "Seiten-ID", "menuEditor.type.page": "Seite", "menuEditor.type.submenu": "Untermenü", + "menuEditor.type.categoryArchive": "Kategorie-Archiv", "menuEditor.empty": "Noch keine Menüeinträge. Füge eine Seite oder ein Untermenü hinzu.", "menuEditor.newPage": "Neue Seite", "menuEditor.newSubmenu": "Neues Untermenü", + "menuEditor.newCategoryPlaceholder": "Kategorie-Namen eingeben", + "menuEditor.categoryPicker.loadError": "Kategorien konnten nicht geladen werden", "settings.language.english": "Englisch", "settings.language.german": "Deutsch", "settings.language.french": "Französisch", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index f5839ef..bc56e3a 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -59,6 +59,8 @@ "menuEditor.pagePicker.empty": "No matching pages found.", "menuEditor.pagePicker.loadError": "Failed to load pages", "menuEditor.addPage": "Add Page", + "menuEditor.addCategoryArchive": "Add Category Archive", + "menuEditor.addCategoryArchiveShort": "C+", "menuEditor.addSubmenu": "Add Submenu", "menuEditor.addChildPage": "Add Child Page", "menuEditor.addChildSubmenu": "Add Child Submenu", @@ -75,9 +77,12 @@ "menuEditor.field.pageId": "Page ID", "menuEditor.type.page": "Page", "menuEditor.type.submenu": "Submenu", + "menuEditor.type.categoryArchive": "Category Archive", "menuEditor.empty": "No menu entries yet. Add a page or submenu to start.", "menuEditor.newPage": "New Page", "menuEditor.newSubmenu": "New Submenu", + "menuEditor.newCategoryPlaceholder": "Type a category name", + "menuEditor.categoryPicker.loadError": "Failed to load categories", "settings.language.english": "English", "settings.language.german": "German", "settings.language.french": "French", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index fa98a47..f3e408b 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -59,6 +59,8 @@ "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.addCategoryArchive": "Añadir archivo de categoría", + "menuEditor.addCategoryArchiveShort": "C+", "menuEditor.addSubmenu": "Añadir submenú", "menuEditor.addChildPage": "Añadir página hija", "menuEditor.addChildSubmenu": "Añadir submenú hijo", @@ -75,9 +77,12 @@ "menuEditor.field.pageId": "ID de página", "menuEditor.type.page": "Página", "menuEditor.type.submenu": "Submenú", + "menuEditor.type.categoryArchive": "Archivo de categoría", "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ú", + "menuEditor.newCategoryPlaceholder": "Escribe un nombre de categoría", + "menuEditor.categoryPicker.loadError": "No se pudieron cargar las categorías", "settings.language.english": "Inglés", "settings.language.german": "Alemán", "settings.language.french": "Francés", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 4797971..47501d6 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -59,6 +59,8 @@ "menuEditor.pagePicker.empty": "Aucune page correspondante trouvée.", "menuEditor.pagePicker.loadError": "Impossible de charger les pages", "menuEditor.addPage": "Ajouter une page", + "menuEditor.addCategoryArchive": "Ajouter une archive de catégorie", + "menuEditor.addCategoryArchiveShort": "C+", "menuEditor.addSubmenu": "Ajouter un sous-menu", "menuEditor.addChildPage": "Ajouter une page enfant", "menuEditor.addChildSubmenu": "Ajouter un sous-menu enfant", @@ -75,9 +77,12 @@ "menuEditor.field.pageId": "ID de page", "menuEditor.type.page": "Page", "menuEditor.type.submenu": "Sous-menu", + "menuEditor.type.categoryArchive": "Archive de catégorie", "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", + "menuEditor.newCategoryPlaceholder": "Saisissez un nom de catégorie", + "menuEditor.categoryPicker.loadError": "Impossible de charger les catégories", "settings.language.english": "Anglais", "settings.language.german": "Allemand", "settings.language.french": "Français", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 1d1816b..e27788f 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -59,6 +59,8 @@ "menuEditor.pagePicker.empty": "Nessuna pagina corrispondente trovata.", "menuEditor.pagePicker.loadError": "Impossibile caricare le pagine", "menuEditor.addPage": "Aggiungi pagina", + "menuEditor.addCategoryArchive": "Aggiungi archivio categoria", + "menuEditor.addCategoryArchiveShort": "C+", "menuEditor.addSubmenu": "Aggiungi sottomenu", "menuEditor.addChildPage": "Aggiungi pagina figlia", "menuEditor.addChildSubmenu": "Aggiungi sottomenu figlio", @@ -75,9 +77,12 @@ "menuEditor.field.pageId": "ID pagina", "menuEditor.type.page": "Pagina", "menuEditor.type.submenu": "Sottomenu", + "menuEditor.type.categoryArchive": "Archivio categoria", "menuEditor.empty": "Nessuna voce menu. Aggiungi una pagina o un sottomenu per iniziare.", "menuEditor.newPage": "Nuova pagina", "menuEditor.newSubmenu": "Nuovo sottomenu", + "menuEditor.newCategoryPlaceholder": "Digita un nome categoria", + "menuEditor.categoryPicker.loadError": "Impossibile caricare le categorie", "settings.language.english": "Inglese", "settings.language.german": "Tedesco", "settings.language.french": "Francese", diff --git a/tests/engine/MenuEngine.test.ts b/tests/engine/MenuEngine.test.ts index 2109d1c..a4e880e 100644 --- a/tests/engine/MenuEngine.test.ts +++ b/tests/engine/MenuEngine.test.ts @@ -50,7 +50,13 @@ describe('MenuEngine', () => { it('returns an empty menu when no OPML file exists', async () => { const result = await menuEngine.getMenu(); - expect(result.items).toEqual([]); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + id: 'menu-home', + title: 'Home', + kind: 'page', + pageSlug: 'home', + }); }); it('parses nested OPML outlines into menu items', async () => { @@ -64,7 +70,7 @@ describe('MenuEngine', () => { expect(result.items).toHaveLength(2); expect(result.items[0]).toMatchObject({ - id: 'home', + id: 'menu-home', title: 'Home', kind: 'page', pageSlug: 'home', @@ -102,9 +108,26 @@ describe('MenuEngine', () => { ], }); - expect(saved.items[0].title).toBe('Top'); + expect(saved.items.some((item) => item.id === 'menu-home')).toBe(true); + expect(saved.items.some((item) => item.title === 'Top')).toBe(true); const roundTrip = await menuEngine.getMenu(); expect(roundTrip).toEqual(saved); }); + + it('keeps Home entry when payload tries to remove it', async () => { + const saved = await menuEngine.saveMenu({ + items: [ + { + id: 'custom-page', + title: 'Custom', + kind: 'page', + pageSlug: 'custom', + children: [], + }, + ], + }); + + expect(saved.items.some((item) => item.id === 'menu-home')).toBe(true); + }); }); \ No newline at end of file diff --git a/tests/renderer/components/MenuEditorView.test.tsx b/tests/renderer/components/MenuEditorView.test.tsx index 36fb92c..70208e2 100644 --- a/tests/renderer/components/MenuEditorView.test.tsx +++ b/tests/renderer/components/MenuEditorView.test.tsx @@ -14,15 +14,20 @@ describe('MenuEditorView entry editor', () => { get: vi.fn().mockResolvedValue({ items: [ { - id: 'root-page', + id: 'menu-home', title: 'Home', kind: 'page', + pageSlug: 'home', children: [], }, ], }), save: vi.fn().mockResolvedValue({ items: [] }), }, + meta: { + ...(window as any).electronAPI?.meta, + getCategories: vi.fn().mockResolvedValue(['news', 'tech']), + }, posts: { ...(window as any).electronAPI?.posts, filter: vi.fn().mockResolvedValue([ @@ -188,4 +193,28 @@ describe('MenuEditorView entry editor', () => { expect(screen.getByText('About')).toBeInTheDocument(); }); + it('shows a category archive create button (C+) in toolbar', async () => { + render(); + + await screen.findByRole('button', { name: /add entry/i }); + expect(screen.getByRole('button', { name: /add category archive/i })).toBeInTheDocument(); + }); + + it('opens category input when category archive button is clicked', async () => { + render(); + + const button = await screen.findByRole('button', { name: /add category archive/i }); + fireEvent.click(button); + + expect(await screen.findByPlaceholderText(/type a category name/i)).toBeInTheDocument(); + }); + + it('disables delete action when Home entry is selected', async () => { + render(); + + await screen.findByText('Home'); + const deleteButton = screen.getByRole('button', { name: /^delete$/i }); + expect(deleteButton).toBeDisabled(); + }); + });