fix: menu editor crashed category meta data

This commit is contained in:
2026-02-22 07:36:27 +01:00
parent 9dacd6fca5
commit 0d86fe1c9d
7 changed files with 196 additions and 44 deletions

View File

@@ -172,7 +172,11 @@ function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
: rawType === 'home'
? 'home'
: 'page';
const title = normalizeNonEmptyString(node['@_text']) || normalizeNonEmptyString(node['@_title']) || 'Untitled';
const textTitle = normalizeNonEmptyString(node['@_text']);
const explicitTitle = normalizeNonEmptyString(node['@_title']);
const title = kind === 'category-archive'
? explicitTitle || textTitle || 'Untitled'
: textTitle || explicitTitle || 'Untitled';
return {
id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(),

View File

@@ -2,11 +2,17 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import '../TagInput/TagInput.css';
import './CategoryInput.css';
export interface CategoryOption {
name: string;
title: string;
}
interface CategoryInputProps {
categories: string[];
onSelectCategory: (categoryName: string) => void;
categories: CategoryOption[];
onSelectCategory: (category: CategoryOption) => void;
placeholder?: string;
createCategoryArchiveLabel: string;
allowCreate?: boolean;
disabled?: boolean;
autoFocus?: boolean;
inlinePlain?: boolean;
@@ -17,6 +23,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
onSelectCategory,
placeholder = '',
createCategoryArchiveLabel,
allowCreate = true,
disabled = false,
autoFocus = false,
inlinePlain = false,
@@ -34,7 +41,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
const query = inputValue.toLowerCase().trim();
return categories
.filter((categoryName) => categoryName.toLowerCase().includes(query))
.filter((category) => category.title.toLowerCase().includes(query) || category.name.toLowerCase().includes(query))
.slice(0, 8);
}, [categories, inputValue]);
@@ -62,23 +69,33 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
return () => clearTimeout(timer);
}, [autoFocus, disabled]);
const createArchive = (label: string): void => {
const trimmed = label.trim();
if (!trimmed) {
const selectCategory = (category: CategoryOption): void => {
onSelectCategory(category);
setInputValue('');
setShowSuggestions(false);
setSelectedIndex(-1);
};
const createArchiveFromInput = (label: string): void => {
const trimmedName = label.trim();
if (!trimmedName) {
return;
}
onSelectCategory(trimmed);
onSelectCategory({
name: trimmedName,
title: trimmedName,
});
setInputValue('');
setShowSuggestions(false);
setSelectedIndex(-1);
};
const exactMatchExists = inputValue.trim()
? suggestions.some((item) => item.toLowerCase() === inputValue.trim().toLowerCase())
? suggestions.some((item) => item.title.toLowerCase() === inputValue.trim().toLowerCase() || item.name.toLowerCase() === inputValue.trim().toLowerCase())
: false;
const showCreateOption = inputValue.trim() && !exactMatchExists;
const showCreateOption = allowCreate && Boolean(inputValue.trim()) && !exactMatchExists;
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'ArrowDown') {
@@ -97,15 +114,18 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
if (event.key === 'Enter') {
event.preventDefault();
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
createArchive(suggestions[selectedIndex]);
selectCategory(suggestions[selectedIndex]);
} else if (selectedIndex === suggestions.length && showCreateOption) {
createArchive(inputValue);
createArchiveFromInput(inputValue);
} else {
const exactMatch = categories.find((categoryName) => categoryName.toLowerCase() === inputValue.trim().toLowerCase());
const exactMatch = categories.find((category) => {
const query = inputValue.trim().toLowerCase();
return category.name.toLowerCase() === query || category.title.toLowerCase() === query;
});
if (exactMatch) {
createArchive(exactMatch);
} else if (inputValue.trim()) {
createArchive(inputValue);
selectCategory(exactMatch);
} else if (allowCreate && inputValue.trim()) {
createArchiveFromInput(inputValue);
}
}
return;
@@ -144,14 +164,14 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
<div className="tag-suggestions">
{suggestions.map((categoryName, index) => (
{suggestions.map((category, index) => (
<button
key={categoryName}
key={category.name}
type="button"
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
onClick={() => createArchive(categoryName)}
onClick={() => selectCategory(category)}
>
<span className="tag-suggestion-name">{categoryName}</span>
<span className="tag-suggestion-name">{category.title}</span>
</button>
))}
@@ -159,7 +179,7 @@ export const CategoryInput: React.FC<CategoryInputProps> = ({
<button
type="button"
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
onClick={() => createArchive(inputValue)}
onClick={() => createArchiveFromInput(inputValue)}
>
<span className="tag-suggestion-icon">+</span>
<span>{createCategoryArchiveLabel}</span>

View File

@@ -1 +1,2 @@
export { CategoryInput } from './CategoryInput';
export type { CategoryOption } from './CategoryInput';

View File

@@ -1,10 +1,13 @@
.menu-editor-view {
padding: 1rem;
height: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
position: relative;
overflow: hidden;
}
.menu-editor-header {
@@ -33,6 +36,7 @@
flex-direction: column;
min-height: 0;
flex: 1;
overflow: hidden;
}
.menu-editor-tree-wrap {

View File

@@ -4,7 +4,7 @@ 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 { CategoryInput, type CategoryOption } from '../CategoryInput';
import { createAutoExpandController } from './menuAutoExpand';
import { resolveInsertTarget } from './menuInsertTarget';
import { isPickerCloseKey } from './menuPagePicker';
@@ -191,7 +191,7 @@ export const MenuEditorView: React.FC = () => {
const [isSaving, setIsSaving] = useState(false);
const [isLoadingPages, setIsLoadingPages] = useState(false);
const [pagePosts, setPagePosts] = useState<PostData[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [categories, setCategories] = useState<CategoryOption[]>([]);
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
const [editingEntryType, setEditingEntryType] = useState<'page' | 'category' | null>(null);
@@ -354,8 +354,16 @@ export const MenuEditorView: React.FC = () => {
setIsLoadingCategories(true);
try {
const nextCategories = await window.electronAPI.meta.getCategories();
setCategories(nextCategories);
const [nextCategories, projectMetadata] = await Promise.all([
window.electronAPI.meta.getCategories(),
window.electronAPI.meta.getProjectMetadata().catch(() => null),
]);
const categoryMetadata = projectMetadata?.categoryMetadata ?? {};
setCategories(nextCategories.map((categoryName) => ({
name: categoryName,
title: categoryMetadata[categoryName]?.title?.trim() || categoryName,
})));
} catch (error) {
console.error('Failed to load categories:', error);
showToast.error(tr('menuEditor.categoryPicker.loadError'));
@@ -414,33 +422,60 @@ export const MenuEditorView: React.FC = () => {
setEditingEntryType(null);
};
const setDraftAsCategoryArchive = (categoryName: string): void => {
const setDraftAsCategoryArchive = (category: CategoryOption): void => {
if (!editingEntryId) {
return;
}
const trimmed = categoryName.trim();
if (!trimmed) {
return;
}
const draftEntryId = editingEntryId;
setItems((previous) => mapItems(previous, (item) => {
if (item.id !== editingEntryId) {
return item;
void (async () => {
const trimmedName = category.name.trim();
const trimmedTitle = category.title.trim();
if (!trimmedName) {
return;
}
return {
...item,
title: trimmed,
kind: 'category-archive',
pageId: undefined,
pageSlug: undefined,
categoryName: trimmed,
};
}));
let nextCategoryName = trimmedName;
const exists = categories.some((item) => item.name.toLowerCase() === trimmedName.toLowerCase());
setEditingEntryId(null);
setEditingEntryType(null);
if (!exists) {
try {
const updatedCategories = await window.electronAPI.meta.addCategory(trimmedName);
const matched = updatedCategories.find((item) => item.toLowerCase() === trimmedName.toLowerCase());
nextCategoryName = matched || trimmedName;
setCategories((previous) => {
if (previous.some((item) => item.name.toLowerCase() === nextCategoryName.toLowerCase())) {
return previous;
}
return [...previous, {
name: nextCategoryName,
title: trimmedTitle || nextCategoryName,
}];
});
} catch (error) {
console.error('Failed to create category from menu editor:', error);
}
}
setItems((previous) => mapItems(previous, (item) => {
if (item.id !== draftEntryId) {
return item;
}
return {
...item,
title: trimmedTitle || nextCategoryName,
kind: 'category-archive',
pageId: undefined,
pageSlug: undefined,
categoryName: nextCategoryName,
};
}));
setEditingEntryId(null);
setEditingEntryType(null);
})();
};
const startCreateEntry = async (): Promise<void> => {