feat: categories with titles
This commit is contained in:
@@ -343,67 +343,80 @@
|
||||
}
|
||||
|
||||
/* Categories management styles */
|
||||
.categories-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
.categories-table-wrapper {
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
background-color: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
border-radius: 4px;
|
||||
.categories-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.category-name {
|
||||
.categories-table th,
|
||||
.categories-table td {
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.categories-table th {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.categories-table td input[type="text"] {
|
||||
width: 100%;
|
||||
min-width: 200px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
color: var(--vscode-input-foreground);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.categories-table td input[type="text"]:focus {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.category-name-cell {
|
||||
font-weight: 500;
|
||||
min-width: 140px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-settings-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
.category-checkbox-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-setting-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.category-setting-toggle input[type="checkbox"] {
|
||||
margin: 0;
|
||||
.category-actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.category-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--vscode-badge-foreground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
color: var(--vscode-foreground);
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
font-size: 10px;
|
||||
border-radius: 50%;
|
||||
transition: opacity 0.15s, background-color 0.15s;
|
||||
opacity: 0.8;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
transition: opacity 0.15s, background-color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.category-remove:hover {
|
||||
opacity: 1;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.category-add-form {
|
||||
|
||||
@@ -33,9 +33,10 @@ interface Credentials {
|
||||
sshKeyPath: string;
|
||||
}
|
||||
|
||||
interface CategoryRenderSettings {
|
||||
interface CategoryMetadata {
|
||||
renderInLists: boolean;
|
||||
showTitle: boolean;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const RENDER_LANGUAGE_LABEL_KEY: Record<SupportedLanguage, string> = {
|
||||
@@ -65,11 +66,11 @@ const SearchIcon = () => (
|
||||
// Default post categories based on VISION.md
|
||||
const DEFAULT_POST_CATEGORIES = ['article', 'picture', 'aside', 'page'];
|
||||
|
||||
const DEFAULT_CATEGORY_SETTINGS: Record<string, CategoryRenderSettings> = {
|
||||
article: { renderInLists: true, showTitle: true },
|
||||
picture: { renderInLists: true, showTitle: true },
|
||||
aside: { renderInLists: true, showTitle: false },
|
||||
page: { renderInLists: false, showTitle: true },
|
||||
const DEFAULT_CATEGORY_METADATA: Record<string, CategoryMetadata> = {
|
||||
article: { renderInLists: true, showTitle: true, title: 'article' },
|
||||
picture: { renderInLists: true, showTitle: true, title: 'picture' },
|
||||
aside: { renderInLists: true, showTitle: false, title: 'aside' },
|
||||
page: { renderInLists: false, showTitle: true, title: 'page' },
|
||||
};
|
||||
|
||||
// Standard categories that cannot be deleted
|
||||
@@ -142,7 +143,7 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
// Post categories management
|
||||
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
|
||||
const [categorySettings, setCategorySettings] = useState<Record<string, CategoryRenderSettings>>(DEFAULT_CATEGORY_SETTINGS);
|
||||
const [categoryMetadata, setCategoryMetadata] = useState<Record<string, CategoryMetadata>>(DEFAULT_CATEGORY_METADATA);
|
||||
const [newCategoryInput, setNewCategoryInput] = useState('');
|
||||
|
||||
// AI Assistant settings
|
||||
@@ -194,14 +195,21 @@ export const SettingsView: React.FC = () => {
|
||||
: 50;
|
||||
setProjectMaxPostsPerPage(maxPostsPerPage);
|
||||
|
||||
const incomingCategorySettings = (metadata as any)?.categorySettings as Record<string, CategoryRenderSettings> | undefined;
|
||||
setCategorySettings((current) => {
|
||||
const merged = { ...DEFAULT_CATEGORY_SETTINGS, ...current };
|
||||
if (incomingCategorySettings && typeof incomingCategorySettings === 'object') {
|
||||
for (const [category, settings] of Object.entries(incomingCategorySettings)) {
|
||||
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
|
||||
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
|
||||
setCategoryMetadata((current) => {
|
||||
const merged = { ...DEFAULT_CATEGORY_METADATA, ...current };
|
||||
const source = incomingCategoryMetadata && typeof incomingCategoryMetadata === 'object'
|
||||
? incomingCategoryMetadata
|
||||
: incomingLegacyCategorySettings;
|
||||
if (source && typeof source === 'object') {
|
||||
for (const [category, settings] of Object.entries(source)) {
|
||||
merged[category] = {
|
||||
renderInLists: settings?.renderInLists !== false,
|
||||
showTitle: settings?.showTitle !== false,
|
||||
title: typeof (settings as any)?.title === 'string' && (settings as any).title.trim().length > 0
|
||||
? (settings as any).title.trim()
|
||||
: category,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -224,11 +232,11 @@ export const SettingsView: React.FC = () => {
|
||||
const categories = await window.electronAPI?.meta.getCategories();
|
||||
if (categories && categories.length > 0) {
|
||||
setPostCategories(categories);
|
||||
setCategorySettings((current) => {
|
||||
const next = { ...DEFAULT_CATEGORY_SETTINGS, ...current };
|
||||
setCategoryMetadata((current) => {
|
||||
const next = { ...DEFAULT_CATEGORY_METADATA, ...current };
|
||||
for (const category of categories) {
|
||||
if (!next[category]) {
|
||||
next[category] = { renderInLists: true, showTitle: true };
|
||||
next[category] = { renderInLists: true, showTitle: true, title: category };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
@@ -236,7 +244,7 @@ export const SettingsView: React.FC = () => {
|
||||
} else {
|
||||
// Initialize with defaults if no categories exist
|
||||
setPostCategories(DEFAULT_POST_CATEGORIES);
|
||||
setCategorySettings(DEFAULT_CATEGORY_SETTINGS);
|
||||
setCategoryMetadata(DEFAULT_CATEGORY_METADATA);
|
||||
}
|
||||
|
||||
// Load AI settings
|
||||
@@ -318,7 +326,7 @@ export const SettingsView: React.FC = () => {
|
||||
mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage),
|
||||
defaultAuthor: projectDefaultAuthor.trim() || undefined,
|
||||
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
|
||||
categorySettings,
|
||||
categoryMetadata,
|
||||
});
|
||||
}
|
||||
showToast.success(t('settings.toast.projectSaved'));
|
||||
@@ -573,12 +581,12 @@ export const SettingsView: React.FC = () => {
|
||||
if (updatedCategories) {
|
||||
setPostCategories(updatedCategories);
|
||||
}
|
||||
const nextSettings = {
|
||||
...categorySettings,
|
||||
[trimmed]: categorySettings[trimmed] || { renderInLists: true, showTitle: true },
|
||||
const nextCategoryMetadata = {
|
||||
...categoryMetadata,
|
||||
[trimmed]: categoryMetadata[trimmed] || { renderInLists: true, showTitle: true, title: trimmed },
|
||||
};
|
||||
setCategorySettings(nextSettings);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||
setCategoryMetadata(nextCategoryMetadata);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||
setNewCategoryInput('');
|
||||
showToast.success(t('settings.toast.categoryAdded', { category: trimmed }));
|
||||
} catch (error) {
|
||||
@@ -604,10 +612,10 @@ export const SettingsView: React.FC = () => {
|
||||
if (updatedCategories) {
|
||||
setPostCategories(updatedCategories);
|
||||
}
|
||||
const nextSettings = { ...categorySettings };
|
||||
delete nextSettings[categoryToRemove];
|
||||
setCategorySettings(nextSettings);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||
const nextCategoryMetadata = { ...categoryMetadata };
|
||||
delete nextCategoryMetadata[categoryToRemove];
|
||||
setCategoryMetadata(nextCategoryMetadata);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||
showToast.success(t('settings.toast.categoryRemoved', { category: categoryToRemove }));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove category:', error);
|
||||
@@ -631,9 +639,9 @@ export const SettingsView: React.FC = () => {
|
||||
// Refresh the list
|
||||
const updatedCategories = await window.electronAPI?.meta.getCategories();
|
||||
setPostCategories(updatedCategories || DEFAULT_POST_CATEGORIES);
|
||||
const defaults = { ...DEFAULT_CATEGORY_SETTINGS };
|
||||
setCategorySettings(defaults);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: defaults });
|
||||
const defaults = { ...DEFAULT_CATEGORY_METADATA };
|
||||
setCategoryMetadata(defaults);
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: defaults });
|
||||
showToast.success(t('settings.toast.categoriesReset'));
|
||||
} catch (error) {
|
||||
console.error('Failed to reset categories:', error);
|
||||
@@ -643,21 +651,51 @@ export const SettingsView: React.FC = () => {
|
||||
|
||||
const handleCategorySettingToggle = async (
|
||||
category: string,
|
||||
field: keyof CategoryRenderSettings,
|
||||
field: keyof Pick<CategoryMetadata, 'renderInLists' | 'showTitle'>,
|
||||
value: boolean,
|
||||
) => {
|
||||
const nextSettings: Record<string, CategoryRenderSettings> = {
|
||||
...categorySettings,
|
||||
const nextCategoryMetadata: Record<string, CategoryMetadata> = {
|
||||
...categoryMetadata,
|
||||
[category]: {
|
||||
...(categorySettings[category] || { renderInLists: true, showTitle: true }),
|
||||
...(categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category }),
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
|
||||
setCategorySettings(nextSettings);
|
||||
setCategoryMetadata(nextCategoryMetadata);
|
||||
|
||||
try {
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categorySettings: nextSettings });
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||
} catch (error) {
|
||||
console.error('Failed to update category settings:', error);
|
||||
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryTitleChange = (category: string, value: string) => {
|
||||
setCategoryMetadata((current) => ({
|
||||
...current,
|
||||
[category]: {
|
||||
...(current[category] || { renderInLists: true, showTitle: true, title: category }),
|
||||
title: value,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const persistCategoryTitle = async (category: string) => {
|
||||
const current = categoryMetadata[category] || { renderInLists: true, showTitle: true, title: category };
|
||||
const nextCategoryMetadata = {
|
||||
...categoryMetadata,
|
||||
[category]: {
|
||||
...current,
|
||||
title: current.title.trim().length > 0 ? current.title.trim() : category,
|
||||
},
|
||||
};
|
||||
|
||||
setCategoryMetadata(nextCategoryMetadata);
|
||||
|
||||
try {
|
||||
await window.electronAPI?.meta.updateProjectMetadata({ categoryMetadata: nextCategoryMetadata });
|
||||
} catch (error) {
|
||||
console.error('Failed to update category settings:', error);
|
||||
showToast.error(t('settings.toast.categorySettingsUpdateFailed'));
|
||||
@@ -671,47 +709,67 @@ export const SettingsView: React.FC = () => {
|
||||
description={t('settings.content.description')}
|
||||
hidden={!sectionHasMatches(contentKeywords)}
|
||||
>
|
||||
<div className="categories-list">
|
||||
{postCategories.map((cat) => {
|
||||
const isProtected = PROTECTED_CATEGORIES.includes(cat);
|
||||
const setting = categorySettings[cat] || { renderInLists: true, showTitle: true };
|
||||
return (
|
||||
<div key={cat} className="category-item">
|
||||
<span className="category-name">{cat}{isProtected && t('settings.content.standardSuffix')}</span>
|
||||
<div className="category-settings-controls">
|
||||
<label className="category-setting-toggle" htmlFor={`category-${cat}-render-in-lists`}>
|
||||
<input
|
||||
id={`category-${cat}-render-in-lists`}
|
||||
aria-label={t('settings.content.renderInListsAria', { category: cat })}
|
||||
type="checkbox"
|
||||
checked={setting.renderInLists}
|
||||
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.content.renderInLists')}</span>
|
||||
</label>
|
||||
<label className="category-setting-toggle" htmlFor={`category-${cat}-show-title`}>
|
||||
<input
|
||||
id={`category-${cat}-show-title`}
|
||||
aria-label={t('settings.content.showTitlesAria', { category: cat })}
|
||||
type="checkbox"
|
||||
checked={setting.showTitle}
|
||||
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
||||
/>
|
||||
<span>{t('settings.content.showTitles')}</span>
|
||||
</label>
|
||||
</div>
|
||||
{!isProtected && (
|
||||
<button
|
||||
className="category-remove"
|
||||
onClick={() => handleRemoveCategory(cat)}
|
||||
title={t('settings.content.removeCategoryTitle', { category: cat })}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="categories-table-wrapper">
|
||||
<table className="categories-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('settings.content.categoryColumn')}</th>
|
||||
<th>{t('settings.content.titleColumn')}</th>
|
||||
<th>{t('settings.content.renderInLists')}</th>
|
||||
<th>{t('settings.content.showTitles')}</th>
|
||||
<th>{t('settings.content.actionsColumn')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{postCategories.map((cat) => {
|
||||
const isProtected = PROTECTED_CATEGORIES.includes(cat);
|
||||
const metadata = categoryMetadata[cat] || { renderInLists: true, showTitle: true, title: cat };
|
||||
return (
|
||||
<tr key={cat}>
|
||||
<td className="category-name-cell">{cat}{isProtected && t('settings.content.standardSuffix')}</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
value={metadata.title}
|
||||
onChange={(event) => handleCategoryTitleChange(cat, event.target.value)}
|
||||
onBlur={() => void persistCategoryTitle(cat)}
|
||||
aria-label={t('settings.content.categoryTitleAria', { category: cat })}
|
||||
/>
|
||||
</td>
|
||||
<td className="category-checkbox-cell">
|
||||
<input
|
||||
id={`category-${cat}-render-in-lists`}
|
||||
aria-label={t('settings.content.renderInListsAria', { category: cat })}
|
||||
type="checkbox"
|
||||
checked={metadata.renderInLists}
|
||||
onChange={(event) => handleCategorySettingToggle(cat, 'renderInLists', event.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="category-checkbox-cell">
|
||||
<input
|
||||
id={`category-${cat}-show-title`}
|
||||
aria-label={t('settings.content.showTitlesAria', { category: cat })}
|
||||
type="checkbox"
|
||||
checked={metadata.showTitle}
|
||||
onChange={(event) => handleCategorySettingToggle(cat, 'showTitle', event.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="category-actions-cell">
|
||||
{!isProtected && (
|
||||
<button
|
||||
className="category-remove"
|
||||
onClick={() => handleRemoveCategory(cat)}
|
||||
title={t('settings.content.removeCategoryTitle', { category: cat })}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="category-add-form">
|
||||
|
||||
Reference in New Issue
Block a user