fix: category handling
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: '🗄️' },
|
||||||
@@ -96,16 +100,29 @@ export const SettingsView: React.FC = () => {
|
|||||||
const [showSecrets, setShowSecrets] = useState(false);
|
const [showSecrets, setShowSecrets] = useState(false);
|
||||||
const [dropboxConfigured, setDropboxConfigured] = useState(false);
|
const [dropboxConfigured, setDropboxConfigured] = useState(false);
|
||||||
const [dropboxLastSync, setDropboxLastSync] = useState<string | null>(null);
|
const [dropboxLastSync, setDropboxLastSync] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Post categories management
|
||||||
|
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
|
||||||
|
const [newCategoryInput, setNewCategoryInput] = useState('');
|
||||||
|
|
||||||
// Load saved credentials
|
// 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':
|
||||||
|
|||||||
Reference in New Issue
Block a user