feat: category menus
This commit is contained in:
@@ -5,7 +5,19 @@ import { randomUUID } from 'crypto';
|
|||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
|
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 {
|
export interface MenuItemData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,6 +25,7 @@ export interface MenuItemData {
|
|||||||
kind: MenuItemKind;
|
kind: MenuItemKind;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
pageSlug?: string;
|
pageSlug?: string;
|
||||||
|
categoryName?: string;
|
||||||
children: MenuItemData[];
|
children: MenuItemData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +40,7 @@ type OpmlOutlineNode = {
|
|||||||
'@_type'?: string;
|
'@_type'?: string;
|
||||||
'@_pageId'?: string;
|
'@_pageId'?: string;
|
||||||
'@_pageSlug'?: string;
|
'@_pageSlug'?: string;
|
||||||
|
'@_categoryName'?: string;
|
||||||
outline?: OpmlOutlineNode | OpmlOutlineNode[];
|
outline?: OpmlOutlineNode | OpmlOutlineNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,7 +75,11 @@ function normalizeNonEmptyString(value: unknown): string | undefined {
|
|||||||
|
|
||||||
function sanitizeMenuItem(input: unknown): MenuItemData {
|
function sanitizeMenuItem(input: unknown): MenuItemData {
|
||||||
const candidate = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
|
const candidate = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
|
||||||
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 childrenSource = Array.isArray(candidate.children) ? candidate.children : [];
|
||||||
const title = normalizeNonEmptyString(candidate.title) || 'Untitled';
|
const title = normalizeNonEmptyString(candidate.title) || 'Untitled';
|
||||||
|
|
||||||
@@ -71,10 +89,70 @@ function sanitizeMenuItem(input: unknown): MenuItemData {
|
|||||||
kind,
|
kind,
|
||||||
pageId: kind === 'page' ? normalizeNonEmptyString(candidate.pageId) : undefined,
|
pageId: kind === 'page' ? normalizeNonEmptyString(candidate.pageId) : undefined,
|
||||||
pageSlug: kind === 'page' ? normalizeNonEmptyString(candidate.pageSlug) : undefined,
|
pageSlug: kind === 'page' ? normalizeNonEmptyString(candidate.pageSlug) : undefined,
|
||||||
|
categoryName: kind === 'category-archive' ? normalizeNonEmptyString(candidate.categoryName) : undefined,
|
||||||
children: childrenSource.map((child) => sanitizeMenuItem(child)),
|
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 {
|
function sanitizeMenuDocument(input: unknown): MenuDocument {
|
||||||
const candidate = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
|
const candidate = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
|
||||||
const items = Array.isArray(candidate.items) ? candidate.items : [];
|
const items = Array.isArray(candidate.items) ? candidate.items : [];
|
||||||
@@ -84,7 +162,12 @@ function sanitizeMenuDocument(input: unknown): MenuDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
|
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';
|
const title = normalizeNonEmptyString(node['@_text']) || normalizeNonEmptyString(node['@_title']) || 'Untitled';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -93,6 +176,7 @@ function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
|
|||||||
kind,
|
kind,
|
||||||
pageId: kind === 'page' ? normalizeNonEmptyString(node['@_pageId']) : undefined,
|
pageId: kind === 'page' ? normalizeNonEmptyString(node['@_pageId']) : undefined,
|
||||||
pageSlug: kind === 'page' ? normalizeNonEmptyString(node['@_pageSlug']) : undefined,
|
pageSlug: kind === 'page' ? normalizeNonEmptyString(node['@_pageSlug']) : undefined,
|
||||||
|
categoryName: kind === 'category-archive' ? normalizeNonEmptyString(node['@_categoryName']) : undefined,
|
||||||
children: normalizeOutlineNodes(node.outline).map((child) => parseOutlineNode(child)),
|
children: normalizeOutlineNodes(node.outline).map((child) => parseOutlineNode(child)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -112,6 +196,10 @@ function toOpmlOutlineNode(item: MenuItemData): OpmlOutlineNode {
|
|||||||
outlineNode['@_pageSlug'] = item.pageSlug;
|
outlineNode['@_pageSlug'] = item.pageSlug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.kind === 'category-archive' && item.categoryName) {
|
||||||
|
outlineNode['@_categoryName'] = item.categoryName;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.children.length > 0) {
|
if (item.children.length > 0) {
|
||||||
outlineNode.outline = item.children.map((child) => toOpmlOutlineNode(child));
|
outlineNode.outline = item.children.map((child) => toOpmlOutlineNode(child));
|
||||||
}
|
}
|
||||||
@@ -154,7 +242,7 @@ export class MenuEngine extends EventEmitter {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const asErrno = error as NodeJS.ErrnoException;
|
const asErrno = error as NodeJS.ErrnoException;
|
||||||
if (asErrno?.code === 'ENOENT') {
|
if (asErrno?.code === 'ENOENT') {
|
||||||
return { items: [] };
|
return enforceHomeEntry({ items: [] });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -175,11 +263,11 @@ export class MenuEngine extends EventEmitter {
|
|||||||
|
|
||||||
const outlineNodes = normalizeOutlineNodes(parsed?.opml?.body?.outline);
|
const outlineNodes = normalizeOutlineNodes(parsed?.opml?.body?.outline);
|
||||||
const items = outlineNodes.map((node) => parseOutlineNode(node));
|
const items = outlineNodes.map((node) => parseOutlineNode(node));
|
||||||
return sanitizeMenuDocument({ items });
|
return enforceHomeEntry(sanitizeMenuDocument({ items }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveMenu(input: MenuDocument): Promise<MenuDocument> {
|
async saveMenu(input: MenuDocument): Promise<MenuDocument> {
|
||||||
const sanitized = sanitizeMenuDocument(input);
|
const sanitized = enforceHomeEntry(sanitizeMenuDocument(input));
|
||||||
|
|
||||||
const builder = new XMLBuilder({
|
const builder = new XMLBuilder({
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
|
|||||||
@@ -422,7 +422,7 @@ export interface SiteValidationApplyResult {
|
|||||||
removedEmptyDirCount: number;
|
removedEmptyDirCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MenuItemKind = 'page' | 'submenu';
|
export type MenuItemKind = 'page' | 'submenu' | 'category-archive';
|
||||||
|
|
||||||
export interface MenuItemData {
|
export interface MenuItemData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -430,6 +430,7 @@ export interface MenuItemData {
|
|||||||
kind: MenuItemKind;
|
kind: MenuItemKind;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
pageSlug?: string;
|
pageSlug?: string;
|
||||||
|
categoryName?: string;
|
||||||
children: MenuItemData[];
|
children: MenuItemData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
src/renderer/components/CategoryInput/CategoryInput.css
Normal file
18
src/renderer/components/CategoryInput/CategoryInput.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
172
src/renderer/components/CategoryInput/CategoryInput.tsx
Normal file
172
src/renderer/components/CategoryInput/CategoryInput.tsx
Normal file
@@ -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<CategoryInputProps> = ({
|
||||||
|
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<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>): 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 (
|
||||||
|
<div className="tag-input-container" ref={containerRef}>
|
||||||
|
<div className={`tag-input-wrapper ${inlinePlain ? 'category-input-wrapper-inline' : ''}`}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className={`tag-input-field ${inlinePlain ? 'category-input-field-inline' : ''}`}
|
||||||
|
value={inputValue}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
onChange={(event) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
|
||||||
|
<div className="tag-suggestions">
|
||||||
|
{suggestions.map((categoryName, index) => (
|
||||||
|
<button
|
||||||
|
key={categoryName}
|
||||||
|
type="button"
|
||||||
|
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
|
||||||
|
onClick={() => createArchive(categoryName)}
|
||||||
|
>
|
||||||
|
<span className="tag-suggestion-name">{categoryName}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{showCreateOption && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
|
||||||
|
onClick={() => createArchive(inputValue)}
|
||||||
|
>
|
||||||
|
<span className="tag-suggestion-icon">+</span>
|
||||||
|
<span>{createCategoryArchiveLabel}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/CategoryInput/index.ts
Normal file
1
src/renderer/components/CategoryInput/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CategoryInput } from './CategoryInput';
|
||||||
@@ -29,12 +29,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-editor-main {
|
.menu-editor-main {
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-editor-tree-wrap {
|
.menu-editor-tree-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
border: 1px solid var(--vscode-panel-border);
|
border: 1px solid var(--vscode-panel-border);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--vscode-editor-background);
|
background: var(--vscode-editor-background);
|
||||||
@@ -42,6 +46,11 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-wrap [role='tree'] {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.menu-editor-toolbar {
|
.menu-editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import { useI18n } from '../../i18n';
|
|||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
|
||||||
import { PageInput } from '../PageInput';
|
import { PageInput } from '../PageInput';
|
||||||
|
import { CategoryInput } from '../CategoryInput';
|
||||||
import { createAutoExpandController } from './menuAutoExpand';
|
import { createAutoExpandController } from './menuAutoExpand';
|
||||||
import { resolveInsertTarget } from './menuInsertTarget';
|
import { resolveInsertTarget } from './menuInsertTarget';
|
||||||
import { isPickerCloseKey } from './menuPagePicker';
|
import { isPickerCloseKey } from './menuPagePicker';
|
||||||
import { applyTreeMove } from './menuTreeMove';
|
import { applyTreeMove } from './menuTreeMove';
|
||||||
import './MenuEditorView.css';
|
import './MenuEditorView.css';
|
||||||
|
|
||||||
|
const HOME_MENU_ID = 'menu-home';
|
||||||
|
|
||||||
interface ToolButtonProps {
|
interface ToolButtonProps {
|
||||||
label: string;
|
label: string;
|
||||||
disabled?: boolean;
|
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 {
|
return {
|
||||||
id: createMenuItemId(),
|
id: createMenuItemId(),
|
||||||
title: '',
|
title: '',
|
||||||
kind: 'submenu',
|
kind,
|
||||||
pageId: undefined,
|
pageId: undefined,
|
||||||
pageSlug: undefined,
|
pageSlug: undefined,
|
||||||
|
categoryName: undefined,
|
||||||
children: [],
|
children: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -171,9 +175,15 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isLoadingPages, setIsLoadingPages] = useState(false);
|
const [isLoadingPages, setIsLoadingPages] = useState(false);
|
||||||
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
|
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
|
||||||
|
const [categories, setCategories] = useState<string[]>([]);
|
||||||
|
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||||
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||||
|
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
|
||||||
|
const [treeHeight, setTreeHeight] = useState<number>(460);
|
||||||
const [toolbarTooltip, setToolbarTooltip] = useState<string>('');
|
const [toolbarTooltip, setToolbarTooltip] = useState<string>('');
|
||||||
const [recentParentInsertId, setRecentParentInsertId] = useState<string | null>(null);
|
const [recentParentInsertId, setRecentParentInsertId] = useState<string | null>(null);
|
||||||
|
const treeWrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const autoExpandController = useMemo(() => createAutoExpandController(450), []);
|
const autoExpandController = useMemo(() => createAutoExpandController(450), []);
|
||||||
|
|
||||||
@@ -220,6 +230,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
return removeItemByPath(previous, path).next;
|
return removeItemByPath(previous, path).next;
|
||||||
});
|
});
|
||||||
setEditingEntryId(null);
|
setEditingEntryId(null);
|
||||||
|
setEditingEntryType(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,7 +241,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
}, [editingEntryId]);
|
}, [editingEntryId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editingEntryId || isLoadingPages) {
|
if (!editingEntryId || (editingEntryType === 'page' && isLoadingPages) || (editingEntryType === 'category' && isLoadingCategories)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +261,50 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
clearTimeout(immediate);
|
clearTimeout(immediate);
|
||||||
clearTimeout(delayed);
|
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(() => {
|
const selectedPath = useMemo(() => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
@@ -277,6 +331,24 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ensureCategoriesLoaded = async (): Promise<void> => {
|
||||||
|
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 => {
|
const setDraftAsSubmenu = (label: string): void => {
|
||||||
if (!editingEntryId) {
|
if (!editingEntryId) {
|
||||||
return;
|
return;
|
||||||
@@ -300,6 +372,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
setEditingEntryId(null);
|
setEditingEntryId(null);
|
||||||
|
setEditingEntryType(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setDraftAsPage = (post: PostData): void => {
|
const setDraftAsPage = (post: PostData): void => {
|
||||||
@@ -322,6 +395,36 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
setEditingEntryId(null);
|
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<void> => {
|
const startCreateEntry = async (): Promise<void> => {
|
||||||
@@ -360,6 +463,22 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
|
|
||||||
setSelectedId(newEntry.id);
|
setSelectedId(newEntry.id);
|
||||||
setEditingEntryId(newEntry.id);
|
setEditingEntryId(newEntry.id);
|
||||||
|
setEditingEntryType('page');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCreateCategoryArchive = async (): Promise<void> => {
|
||||||
|
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<void> => {
|
const save = async (): Promise<void> => {
|
||||||
@@ -445,6 +564,10 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedId === HOME_MENU_ID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setItems((previous) => {
|
setItems((previous) => {
|
||||||
const removed = removeItemByPath(previous, selectedPath);
|
const removed = removeItemByPath(previous, selectedPath);
|
||||||
return removed.next;
|
return removed.next;
|
||||||
@@ -452,10 +575,13 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
|
|
||||||
if (editingEntryId === selectedId) {
|
if (editingEntryId === selectedId) {
|
||||||
setEditingEntryId(null);
|
setEditingEntryId(null);
|
||||||
|
setEditingEntryType(null);
|
||||||
}
|
}
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isHomeSelected = selectedId === HOME_MENU_ID;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="menu-editor-view">
|
<div className="menu-editor-view">
|
||||||
<div className="menu-editor-header">
|
<div className="menu-editor-header">
|
||||||
@@ -469,8 +595,8 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
<div className="menu-editor-loading">{tr('menuEditor.loading')}</div>
|
<div className="menu-editor-loading">{tr('menuEditor.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="menu-editor-main">
|
<div className="menu-editor-main">
|
||||||
<div className="menu-editor-tree-wrap">
|
<div className="menu-editor-tree-wrap" ref={treeWrapRef}>
|
||||||
<div className="menu-editor-toolbar" role="toolbar" aria-label={tr('menuEditor.title')}>
|
<div className="menu-editor-toolbar" role="toolbar" aria-label={tr('menuEditor.title')} ref={toolbarRef}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
label={tr('menuEditor.addEntry')}
|
label={tr('menuEditor.addEntry')}
|
||||||
onClick={() => void startCreateEntry()}
|
onClick={() => void startCreateEntry()}
|
||||||
@@ -488,6 +614,14 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
|
<ToolButton
|
||||||
|
label={tr('menuEditor.addCategoryArchive')}
|
||||||
|
onClick={() => void startCreateCategoryArchive()}
|
||||||
|
onShowTooltip={setToolbarTooltip}
|
||||||
|
onHideTooltip={() => setToolbarTooltip('')}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{tr('menuEditor.addCategoryArchiveShort')}</span>
|
||||||
|
</ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
label={tr('menuEditor.moveUp')}
|
label={tr('menuEditor.moveUp')}
|
||||||
onClick={() => moveSelected('up')}
|
onClick={() => moveSelected('up')}
|
||||||
@@ -527,7 +661,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
<ToolButton
|
<ToolButton
|
||||||
label={tr('menuEditor.delete')}
|
label={tr('menuEditor.delete')}
|
||||||
onClick={deleteSelected}
|
onClick={deleteSelected}
|
||||||
disabled={!selectedPath}
|
disabled={!selectedPath || isHomeSelected}
|
||||||
onShowTooltip={setToolbarTooltip}
|
onShowTooltip={setToolbarTooltip}
|
||||||
onHideTooltip={() => setToolbarTooltip('')}
|
onHideTooltip={() => setToolbarTooltip('')}
|
||||||
>
|
>
|
||||||
@@ -544,7 +678,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
<Tree<MenuItemData>
|
<Tree<MenuItemData>
|
||||||
data={items}
|
data={items}
|
||||||
width="100%"
|
width="100%"
|
||||||
height={editingEntryId ? 320 : 460}
|
height={treeHeight}
|
||||||
rowHeight={32}
|
rowHeight={32}
|
||||||
indent={20}
|
indent={20}
|
||||||
openByDefault
|
openByDefault
|
||||||
@@ -584,10 +718,25 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
<span className="menu-editor-row-kind">
|
<span className="menu-editor-row-kind">
|
||||||
{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')}
|
||||||
</span>
|
</span>
|
||||||
<span className={`menu-editor-row-title ${editingEntryId === node.data.id ? 'is-editing' : ''}`}>
|
<span className={`menu-editor-row-title ${editingEntryId === node.data.id ? 'is-editing' : ''}`}>
|
||||||
{editingEntryId === node.data.id ? (
|
{editingEntryId === node.data.id ? (
|
||||||
|
editingEntryType === 'category' ? (
|
||||||
|
<CategoryInput
|
||||||
|
categories={categories}
|
||||||
|
onSelectCategory={setDraftAsCategoryArchive}
|
||||||
|
createCategoryArchiveLabel={tr('menuEditor.addCategoryArchive')}
|
||||||
|
placeholder={tr('menuEditor.newCategoryPlaceholder')}
|
||||||
|
disabled={isLoadingCategories}
|
||||||
|
autoFocus
|
||||||
|
inlinePlain
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<PageInput
|
<PageInput
|
||||||
pages={pagePosts}
|
pages={pagePosts}
|
||||||
onSelectPage={setDraftAsPage}
|
onSelectPage={setDraftAsPage}
|
||||||
@@ -598,6 +747,7 @@ export const MenuEditorView: React.FC = () => {
|
|||||||
autoFocus
|
autoFocus
|
||||||
inlinePlain
|
inlinePlain
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
) : node.data.title}
|
) : node.data.title}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
"menuEditor.pagePicker.empty": "Keine passenden Seiten gefunden.",
|
"menuEditor.pagePicker.empty": "Keine passenden Seiten gefunden.",
|
||||||
"menuEditor.pagePicker.loadError": "Seiten konnten nicht geladen werden",
|
"menuEditor.pagePicker.loadError": "Seiten konnten nicht geladen werden",
|
||||||
"menuEditor.addPage": "Seite hinzufügen",
|
"menuEditor.addPage": "Seite hinzufügen",
|
||||||
|
"menuEditor.addCategoryArchive": "Kategorie-Archiv hinzufügen",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
"menuEditor.addSubmenu": "Untermenü hinzufügen",
|
"menuEditor.addSubmenu": "Untermenü hinzufügen",
|
||||||
"menuEditor.addChildPage": "Unterseite hinzufügen",
|
"menuEditor.addChildPage": "Unterseite hinzufügen",
|
||||||
"menuEditor.addChildSubmenu": "Unter-Untermenü hinzufügen",
|
"menuEditor.addChildSubmenu": "Unter-Untermenü hinzufügen",
|
||||||
@@ -75,9 +77,12 @@
|
|||||||
"menuEditor.field.pageId": "Seiten-ID",
|
"menuEditor.field.pageId": "Seiten-ID",
|
||||||
"menuEditor.type.page": "Seite",
|
"menuEditor.type.page": "Seite",
|
||||||
"menuEditor.type.submenu": "Untermenü",
|
"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.empty": "Noch keine Menüeinträge. Füge eine Seite oder ein Untermenü hinzu.",
|
||||||
"menuEditor.newPage": "Neue Seite",
|
"menuEditor.newPage": "Neue Seite",
|
||||||
"menuEditor.newSubmenu": "Neues Untermenü",
|
"menuEditor.newSubmenu": "Neues Untermenü",
|
||||||
|
"menuEditor.newCategoryPlaceholder": "Kategorie-Namen eingeben",
|
||||||
|
"menuEditor.categoryPicker.loadError": "Kategorien konnten nicht geladen werden",
|
||||||
"settings.language.english": "Englisch",
|
"settings.language.english": "Englisch",
|
||||||
"settings.language.german": "Deutsch",
|
"settings.language.german": "Deutsch",
|
||||||
"settings.language.french": "Französisch",
|
"settings.language.french": "Französisch",
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
"menuEditor.pagePicker.empty": "No matching pages found.",
|
"menuEditor.pagePicker.empty": "No matching pages found.",
|
||||||
"menuEditor.pagePicker.loadError": "Failed to load pages",
|
"menuEditor.pagePicker.loadError": "Failed to load pages",
|
||||||
"menuEditor.addPage": "Add Page",
|
"menuEditor.addPage": "Add Page",
|
||||||
|
"menuEditor.addCategoryArchive": "Add Category Archive",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
"menuEditor.addSubmenu": "Add Submenu",
|
"menuEditor.addSubmenu": "Add Submenu",
|
||||||
"menuEditor.addChildPage": "Add Child Page",
|
"menuEditor.addChildPage": "Add Child Page",
|
||||||
"menuEditor.addChildSubmenu": "Add Child Submenu",
|
"menuEditor.addChildSubmenu": "Add Child Submenu",
|
||||||
@@ -75,9 +77,12 @@
|
|||||||
"menuEditor.field.pageId": "Page ID",
|
"menuEditor.field.pageId": "Page ID",
|
||||||
"menuEditor.type.page": "Page",
|
"menuEditor.type.page": "Page",
|
||||||
"menuEditor.type.submenu": "Submenu",
|
"menuEditor.type.submenu": "Submenu",
|
||||||
|
"menuEditor.type.categoryArchive": "Category Archive",
|
||||||
"menuEditor.empty": "No menu entries yet. Add a page or submenu to start.",
|
"menuEditor.empty": "No menu entries yet. Add a page or submenu to start.",
|
||||||
"menuEditor.newPage": "New Page",
|
"menuEditor.newPage": "New Page",
|
||||||
"menuEditor.newSubmenu": "New Submenu",
|
"menuEditor.newSubmenu": "New Submenu",
|
||||||
|
"menuEditor.newCategoryPlaceholder": "Type a category name",
|
||||||
|
"menuEditor.categoryPicker.loadError": "Failed to load categories",
|
||||||
"settings.language.english": "English",
|
"settings.language.english": "English",
|
||||||
"settings.language.german": "German",
|
"settings.language.german": "German",
|
||||||
"settings.language.french": "French",
|
"settings.language.french": "French",
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
"menuEditor.pagePicker.empty": "No se encontraron páginas coincidentes.",
|
"menuEditor.pagePicker.empty": "No se encontraron páginas coincidentes.",
|
||||||
"menuEditor.pagePicker.loadError": "No se pudieron cargar las páginas",
|
"menuEditor.pagePicker.loadError": "No se pudieron cargar las páginas",
|
||||||
"menuEditor.addPage": "Añadir página",
|
"menuEditor.addPage": "Añadir página",
|
||||||
|
"menuEditor.addCategoryArchive": "Añadir archivo de categoría",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
"menuEditor.addSubmenu": "Añadir submenú",
|
"menuEditor.addSubmenu": "Añadir submenú",
|
||||||
"menuEditor.addChildPage": "Añadir página hija",
|
"menuEditor.addChildPage": "Añadir página hija",
|
||||||
"menuEditor.addChildSubmenu": "Añadir submenú hijo",
|
"menuEditor.addChildSubmenu": "Añadir submenú hijo",
|
||||||
@@ -75,9 +77,12 @@
|
|||||||
"menuEditor.field.pageId": "ID de página",
|
"menuEditor.field.pageId": "ID de página",
|
||||||
"menuEditor.type.page": "Página",
|
"menuEditor.type.page": "Página",
|
||||||
"menuEditor.type.submenu": "Submenú",
|
"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.empty": "Aún no hay entradas de menú. Añade una página o un submenú para empezar.",
|
||||||
"menuEditor.newPage": "Nueva página",
|
"menuEditor.newPage": "Nueva página",
|
||||||
"menuEditor.newSubmenu": "Nuevo submenú",
|
"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.english": "Inglés",
|
||||||
"settings.language.german": "Alemán",
|
"settings.language.german": "Alemán",
|
||||||
"settings.language.french": "Francés",
|
"settings.language.french": "Francés",
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
"menuEditor.pagePicker.empty": "Aucune page correspondante trouvée.",
|
"menuEditor.pagePicker.empty": "Aucune page correspondante trouvée.",
|
||||||
"menuEditor.pagePicker.loadError": "Impossible de charger les pages",
|
"menuEditor.pagePicker.loadError": "Impossible de charger les pages",
|
||||||
"menuEditor.addPage": "Ajouter une page",
|
"menuEditor.addPage": "Ajouter une page",
|
||||||
|
"menuEditor.addCategoryArchive": "Ajouter une archive de catégorie",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
"menuEditor.addSubmenu": "Ajouter un sous-menu",
|
"menuEditor.addSubmenu": "Ajouter un sous-menu",
|
||||||
"menuEditor.addChildPage": "Ajouter une page enfant",
|
"menuEditor.addChildPage": "Ajouter une page enfant",
|
||||||
"menuEditor.addChildSubmenu": "Ajouter un sous-menu enfant",
|
"menuEditor.addChildSubmenu": "Ajouter un sous-menu enfant",
|
||||||
@@ -75,9 +77,12 @@
|
|||||||
"menuEditor.field.pageId": "ID de page",
|
"menuEditor.field.pageId": "ID de page",
|
||||||
"menuEditor.type.page": "Page",
|
"menuEditor.type.page": "Page",
|
||||||
"menuEditor.type.submenu": "Sous-menu",
|
"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.empty": "Aucune entrée de menu. Ajoutez une page ou un sous-menu pour commencer.",
|
||||||
"menuEditor.newPage": "Nouvelle page",
|
"menuEditor.newPage": "Nouvelle page",
|
||||||
"menuEditor.newSubmenu": "Nouveau sous-menu",
|
"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.english": "Anglais",
|
||||||
"settings.language.german": "Allemand",
|
"settings.language.german": "Allemand",
|
||||||
"settings.language.french": "Français",
|
"settings.language.french": "Français",
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
"menuEditor.pagePicker.empty": "Nessuna pagina corrispondente trovata.",
|
"menuEditor.pagePicker.empty": "Nessuna pagina corrispondente trovata.",
|
||||||
"menuEditor.pagePicker.loadError": "Impossibile caricare le pagine",
|
"menuEditor.pagePicker.loadError": "Impossibile caricare le pagine",
|
||||||
"menuEditor.addPage": "Aggiungi pagina",
|
"menuEditor.addPage": "Aggiungi pagina",
|
||||||
|
"menuEditor.addCategoryArchive": "Aggiungi archivio categoria",
|
||||||
|
"menuEditor.addCategoryArchiveShort": "C+",
|
||||||
"menuEditor.addSubmenu": "Aggiungi sottomenu",
|
"menuEditor.addSubmenu": "Aggiungi sottomenu",
|
||||||
"menuEditor.addChildPage": "Aggiungi pagina figlia",
|
"menuEditor.addChildPage": "Aggiungi pagina figlia",
|
||||||
"menuEditor.addChildSubmenu": "Aggiungi sottomenu figlio",
|
"menuEditor.addChildSubmenu": "Aggiungi sottomenu figlio",
|
||||||
@@ -75,9 +77,12 @@
|
|||||||
"menuEditor.field.pageId": "ID pagina",
|
"menuEditor.field.pageId": "ID pagina",
|
||||||
"menuEditor.type.page": "Pagina",
|
"menuEditor.type.page": "Pagina",
|
||||||
"menuEditor.type.submenu": "Sottomenu",
|
"menuEditor.type.submenu": "Sottomenu",
|
||||||
|
"menuEditor.type.categoryArchive": "Archivio categoria",
|
||||||
"menuEditor.empty": "Nessuna voce menu. Aggiungi una pagina o un sottomenu per iniziare.",
|
"menuEditor.empty": "Nessuna voce menu. Aggiungi una pagina o un sottomenu per iniziare.",
|
||||||
"menuEditor.newPage": "Nuova pagina",
|
"menuEditor.newPage": "Nuova pagina",
|
||||||
"menuEditor.newSubmenu": "Nuovo sottomenu",
|
"menuEditor.newSubmenu": "Nuovo sottomenu",
|
||||||
|
"menuEditor.newCategoryPlaceholder": "Digita un nome categoria",
|
||||||
|
"menuEditor.categoryPicker.loadError": "Impossibile caricare le categorie",
|
||||||
"settings.language.english": "Inglese",
|
"settings.language.english": "Inglese",
|
||||||
"settings.language.german": "Tedesco",
|
"settings.language.german": "Tedesco",
|
||||||
"settings.language.french": "Francese",
|
"settings.language.french": "Francese",
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ describe('MenuEngine', () => {
|
|||||||
it('returns an empty menu when no OPML file exists', async () => {
|
it('returns an empty menu when no OPML file exists', async () => {
|
||||||
const result = await menuEngine.getMenu();
|
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 () => {
|
it('parses nested OPML outlines into menu items', async () => {
|
||||||
@@ -64,7 +70,7 @@ describe('MenuEngine', () => {
|
|||||||
|
|
||||||
expect(result.items).toHaveLength(2);
|
expect(result.items).toHaveLength(2);
|
||||||
expect(result.items[0]).toMatchObject({
|
expect(result.items[0]).toMatchObject({
|
||||||
id: 'home',
|
id: 'menu-home',
|
||||||
title: 'Home',
|
title: 'Home',
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
pageSlug: 'home',
|
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();
|
const roundTrip = await menuEngine.getMenu();
|
||||||
expect(roundTrip).toEqual(saved);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -14,15 +14,20 @@ describe('MenuEditorView entry editor', () => {
|
|||||||
get: vi.fn().mockResolvedValue({
|
get: vi.fn().mockResolvedValue({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'root-page',
|
id: 'menu-home',
|
||||||
title: 'Home',
|
title: 'Home',
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
|
pageSlug: 'home',
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
save: vi.fn().mockResolvedValue({ items: [] }),
|
save: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
},
|
},
|
||||||
|
meta: {
|
||||||
|
...(window as any).electronAPI?.meta,
|
||||||
|
getCategories: vi.fn().mockResolvedValue(['news', 'tech']),
|
||||||
|
},
|
||||||
posts: {
|
posts: {
|
||||||
...(window as any).electronAPI?.posts,
|
...(window as any).electronAPI?.posts,
|
||||||
filter: vi.fn().mockResolvedValue([
|
filter: vi.fn().mockResolvedValue([
|
||||||
@@ -188,4 +193,28 @@ describe('MenuEditorView entry editor', () => {
|
|||||||
expect(screen.getByText('About')).toBeInTheDocument();
|
expect(screen.getByText('About')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a category archive create button (C+) in toolbar', async () => {
|
||||||
|
render(<MenuEditorView />);
|
||||||
|
|
||||||
|
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(<MenuEditorView />);
|
||||||
|
|
||||||
|
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(<MenuEditorView />);
|
||||||
|
|
||||||
|
await screen.findByText('Home');
|
||||||
|
const deleteButton = screen.getByRole('button', { name: /^delete$/i });
|
||||||
|
expect(deleteButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user