fix: menu editor crashed category meta data
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { CategoryInput } from './CategoryInput';
|
||||
export type { CategoryOption } from './CategoryInput';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user