fix: category handling

This commit is contained in:
2026-02-10 17:16:34 +01:00
parent 29d9308100
commit 93fc9f9cb6
4 changed files with 221 additions and 23 deletions

View File

@@ -114,6 +114,14 @@ a gear icon in the bottom of the iconbar. Also login credentials can be managed
of the icon bar directly above the gear icon. This is similar to what vscode does, separating logins and of the icon bar directly above the gear icon. This is similar to what vscode does, separating logins and
settings. settings.
Tags are something that should be mainly focus on reusing but need easy ways to add new tags. This should
not be a simple text field, but more a feature like tags in gitlab, where you can easily select multiple
available tags, but also can quickly create new tags. Tags should have a color and there should be a way
to manage tags in the preferences, too, so that users can create tags upfront to posts.
Categories are a simple selection via dropdown and a preferences panel that allows category management.
Categories are mainly used to denote different styles of posts and posts are only of one style.
## Organizing ## Organizing
Blog posts should be organized in the app in the main post view where the sidebar lists posts and the main Blog posts should be organized in the app in the main post view where the sidebar lists posts and the main

View File

@@ -57,7 +57,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const [title, setTitle] = useState(post.title); const [title, setTitle] = useState(post.title);
const [content, setContent] = useState(post.content); const [content, setContent] = useState(post.content);
const [tags, setTags] = useState(post.tags.join(', ')); const [tags, setTags] = useState(post.tags.join(', '));
const [categories, setCategories] = useState(post.categories.join(', ')); const [category, setCategory] = useState(post.categories[0] || 'article');
const [availableCategories, setAvailableCategories] = useState<string[]>(['article', 'picture', 'aside', 'page']);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [hasPublishedVersion, setHasPublishedVersion] = useState(false); const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode); const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
@@ -72,6 +73,21 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion); window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion);
}, [post.id]); }, [post.id]);
// Load available categories from localStorage
useEffect(() => {
const savedCategories = localStorage.getItem('bds-categories');
if (savedCategories) {
try {
const parsed = JSON.parse(savedCategories);
if (Array.isArray(parsed) && parsed.length > 0) {
setAvailableCategories(parsed);
}
} catch {
// Keep defaults
}
}
}, []);
// Extract images from content for lightbox // Extract images from content for lightbox
const images = useMarkdownImages(content); const images = useMarkdownImages(content);
@@ -80,7 +96,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
title: string; title: string;
content: string; content: string;
tags: string; tags: string;
categories: string; category: string;
postId: string; postId: string;
isDirty: boolean; isDirty: boolean;
} | null>(null); } | null>(null);
@@ -91,11 +107,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
title, title,
content, content,
tags, tags,
categories, category,
postId: post.id, postId: post.id,
isDirty, isDirty,
}; };
}, [title, content, tags, categories, post.id, isDirty]); }, [title, content, tags, category, post.id, isDirty]);
// Auto-save when switching away from a post or unmounting // Auto-save when switching away from a post or unmounting
useEffect(() => { useEffect(() => {
@@ -111,7 +127,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
title: pending.title, title: pending.title,
content: pending.content, content: pending.content,
tags: pending.tags.split(',').map(t => t.trim()).filter(t => t.length > 0), tags: pending.tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
categories: pending.categories.split(',').map(c => c.trim()).filter(c => c.length > 0), categories: pending.category ? [pending.category] : ['article'],
}).then((updated) => { }).then((updated) => {
if (updated) { if (updated) {
useAppStore.getState().updatePost(pending.postId, updated as Partial<PostData>); useAppStore.getState().updatePost(pending.postId, updated as Partial<PostData>);
@@ -129,24 +145,25 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
setTitle(post.title); setTitle(post.title);
setContent(post.content); setContent(post.content);
setTags(post.tags.join(', ')); setTags(post.tags.join(', '));
setCategories(post.categories.join(', ')); setCategory(post.categories[0] || 'article');
markClean(post.id); markClean(post.id);
}, [post.id, post.title, post.content, post.tags, post.categories, markClean]); }, [post.id, post.title, post.content, post.tags, post.categories, markClean]);
// Track changes // Track changes
useEffect(() => { useEffect(() => {
const currentCategory = post.categories[0] || 'article';
const hasChanges = const hasChanges =
title !== post.title || title !== post.title ||
content !== post.content || content !== post.content ||
tags !== post.tags.join(', ') || tags !== post.tags.join(', ') ||
categories !== post.categories.join(', '); category !== currentCategory;
if (hasChanges) { if (hasChanges) {
markDirty(post.id); markDirty(post.id);
} else { } else {
markClean(post.id); markClean(post.id);
} }
}, [title, content, tags, categories, post, markDirty, markClean]); }, [title, content, tags, category, post, markDirty, markClean]);
// Handle editor mode change and persist preference // Handle editor mode change and persist preference
const handleEditorModeChange = (mode: EditorMode) => { const handleEditorModeChange = (mode: EditorMode) => {
@@ -163,7 +180,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
title, title,
content, content,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0), tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0), categories: category ? [category] : ['article'],
}); });
if (updated) { if (updated) {
@@ -182,7 +199,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [post.id, title, content, tags, categories, isDirty, isSaving, updatePost, markClean, showErrorModal]); }, [post.id, title, content, tags, category, isDirty, isSaving, updatePost, markClean, showErrorModal]);
const handlePublish = async () => { const handlePublish = async () => {
await handleSave(); await handleSave();
@@ -240,7 +257,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
setTitle(reverted.title); setTitle(reverted.title);
setContent(reverted.content); setContent(reverted.content);
setTags(reverted.tags.join(', ')); setTags(reverted.tags.join(', '));
setCategories(reverted.categories.join(', ')); setCategory(reverted.categories[0] || 'article');
updatePost(post.id, reverted as Partial<PostData>); updatePost(post.id, reverted as Partial<PostData>);
markClean(post.id); markClean(post.id);
showToast.success('Reverted to last published version'); showToast.success('Reverted to last published version');
@@ -396,13 +413,15 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
/> />
</div> </div>
<div className="editor-field"> <div className="editor-field">
<label>Categories (comma-separated)</label> <label>Category</label>
<input <select
type="text" value={category}
value={categories} onChange={(e) => setCategory(e.target.value)}
onChange={(e) => setCategories(e.target.value)} >
placeholder="category1, category2" {availableCategories.map((cat) => (
/> <option key={cat} value={cat}>{cat}</option>
))}
</select>
</div> </div>
</div> </div>

View File

@@ -359,6 +359,78 @@
background-color: var(--vscode-button-secondaryHoverBackground); background-color: var(--vscode-button-secondaryHoverBackground);
} }
/* Categories management styles */
.categories-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
}
.category-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background-color: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
border-radius: 4px;
font-size: 13px;
}
.category-name {
font-weight: 500;
}
.category-remove {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
background: transparent;
border: none;
color: var(--vscode-badge-foreground);
cursor: pointer;
opacity: 0.6;
font-size: 10px;
border-radius: 50%;
transition: opacity 0.15s, background-color 0.15s;
}
.category-remove:hover {
opacity: 1;
background-color: rgba(255, 255, 255, 0.1);
}
.category-add-form {
display: flex;
gap: 8px;
padding: 12px 16px;
align-items: center;
}
.category-add-form input {
flex: 1;
max-width: 300px;
padding: 6px 12px;
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;
}
.category-add-form input:focus {
border-color: var(--vscode-focusBorder);
}
.category-add-form input::placeholder {
color: var(--vscode-input-placeholderForeground);
}
/* Responsive - narrow sidebar */ /* Responsive - narrow sidebar */
@media (max-width: 600px) { @media (max-width: 600px) {
.settings-nav { .settings-nav {

View File

@@ -4,7 +4,7 @@ import { showToast } from '../Toast';
import './SettingsView.css'; import './SettingsView.css';
// Settings categories matching VS Code style // Settings categories matching VS Code style
type SettingsCategory = 'editor' | 'sync' | 'publishing' | 'data'; type SettingsCategory = 'editor' | 'content' | 'sync' | 'publishing' | 'data';
interface Credentials { interface Credentials {
// Turso Cloud Sync // Turso Cloud Sync
@@ -45,9 +45,13 @@ const SearchIcon = () => (
</svg> </svg>
); );
// Default post categories based on VISION.md
const DEFAULT_POST_CATEGORIES = ['article', 'picture', 'aside', 'page'];
// Category definitions // Category definitions
const categories: { id: SettingsCategory; label: string; icon: string }[] = [ const categories: { id: SettingsCategory; label: string; icon: string }[] = [
{ id: 'editor', label: 'Editor', icon: '📝' }, { id: 'editor', label: 'Editor', icon: '📝' },
{ id: 'content', label: 'Content', icon: '📋' },
{ id: 'sync', label: 'Sync', icon: '🔄' }, { id: 'sync', label: 'Sync', icon: '🔄' },
{ id: 'publishing', label: 'Publishing', icon: '🚀' }, { id: 'publishing', label: 'Publishing', icon: '🚀' },
{ id: 'data', label: 'Data Management', icon: '🗄️' }, { id: 'data', label: 'Data Management', icon: '🗄️' },
@@ -97,15 +101,28 @@ export const SettingsView: React.FC = () => {
const [dropboxConfigured, setDropboxConfigured] = useState(false); const [dropboxConfigured, setDropboxConfigured] = useState(false);
const [dropboxLastSync, setDropboxLastSync] = useState<string | null>(null); const [dropboxLastSync, setDropboxLastSync] = useState<string | null>(null);
// Load saved credentials // Post categories management
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
const [newCategoryInput, setNewCategoryInput] = useState('');
// Load saved credentials and categories
useEffect(() => { useEffect(() => {
const loadCredentials = async () => { const loadSettings = async () => {
try { try {
const savedCreds = localStorage.getItem('bds-credentials'); const savedCreds = localStorage.getItem('bds-credentials');
if (savedCreds) { if (savedCreds) {
setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) }); setCredentials({ ...defaultCredentials, ...JSON.parse(savedCreds) });
} }
// Load saved post categories
const savedCategories = localStorage.getItem('bds-categories');
if (savedCategories) {
const categories = JSON.parse(savedCategories);
if (Array.isArray(categories) && categories.length > 0) {
setPostCategories(categories);
}
}
// Check Dropbox status // Check Dropbox status
const dbxConfigured = await window.electronAPI?.dropbox?.isConfigured(); const dbxConfigured = await window.electronAPI?.dropbox?.isConfigured();
setDropboxConfigured(dbxConfigured || false); setDropboxConfigured(dbxConfigured || false);
@@ -115,10 +132,10 @@ export const SettingsView: React.FC = () => {
setDropboxLastSync(lastSync || null); setDropboxLastSync(lastSync || null);
} }
} catch (error) { } catch (error) {
console.error('Failed to load credentials:', error); console.error('Failed to load settings:', error);
} }
}; };
loadCredentials(); loadSettings();
}, []); }, []);
// Save credentials and configure backends // Save credentials and configure backends
@@ -274,6 +291,85 @@ export const SettingsView: React.FC = () => {
</> </>
); );
// Handlers for post categories management
const handleAddCategory = () => {
const trimmed = newCategoryInput.trim().toLowerCase();
if (trimmed && !postCategories.includes(trimmed)) {
const updated = [...postCategories, trimmed];
setPostCategories(updated);
localStorage.setItem('bds-categories', JSON.stringify(updated));
setNewCategoryInput('');
showToast.success(`Category "${trimmed}" added`);
} else if (postCategories.includes(trimmed)) {
showToast.error('Category already exists');
}
};
const handleRemoveCategory = (categoryToRemove: string) => {
if (postCategories.length <= 1) {
showToast.error('Must have at least one category');
return;
}
const updated = postCategories.filter(c => c !== categoryToRemove);
setPostCategories(updated);
localStorage.setItem('bds-categories', JSON.stringify(updated));
showToast.success(`Category "${categoryToRemove}" removed`);
};
const handleResetCategories = () => {
setPostCategories(DEFAULT_POST_CATEGORIES);
localStorage.setItem('bds-categories', JSON.stringify(DEFAULT_POST_CATEGORIES));
showToast.success('Categories reset to defaults');
};
const renderContentSettings = () => (
<>
<SettingSection
title="Post Categories"
description="Manage the available categories for blog posts. Each post can have one category that determines its display template."
>
<div className="categories-list">
{postCategories.map((cat) => (
<div key={cat} className="category-item">
<span className="category-name">{cat}</span>
<button
className="category-remove"
onClick={() => handleRemoveCategory(cat)}
title={`Remove "${cat}" category`}
>
</button>
</div>
))}
</div>
<div className="category-add-form">
<input
type="text"
placeholder="New category name..."
value={newCategoryInput}
onChange={(e) => setNewCategoryInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCategory();
}
}}
/>
<button className="primary" onClick={handleAddCategory}>
Add Category
</button>
</div>
<div className="setting-actions">
<button className="secondary" onClick={handleResetCategories}>
Reset to Defaults
</button>
</div>
</SettingSection>
</>
);
const renderSyncSettings = () => ( const renderSyncSettings = () => (
<> <>
<SettingSection <SettingSection
@@ -647,6 +743,7 @@ export const SettingsView: React.FC = () => {
return ( return (
<> <>
{renderEditorSettings()} {renderEditorSettings()}
{renderContentSettings()}
{renderSyncSettings()} {renderSyncSettings()}
{renderPublishingSettings()} {renderPublishingSettings()}
{renderDataSettings()} {renderDataSettings()}
@@ -657,6 +754,8 @@ export const SettingsView: React.FC = () => {
switch (activeCategory) { switch (activeCategory) {
case 'editor': case 'editor':
return renderEditorSettings(); return renderEditorSettings();
case 'content':
return renderContentSettings();
case 'sync': case 'sync':
return renderSyncSettings(); return renderSyncSettings();
case 'publishing': case 'publishing':