feat: made category in the UI multi-select-capable
This commit is contained in:
@@ -1082,3 +1082,128 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Multi-select dropdown for categories */
|
||||||
|
.multi-select-dropdown {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--vscode-input-border, #3c3c3c);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger:hover {
|
||||||
|
border-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-trigger:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-value {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 8px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: var(--vscode-dropdown-background, #3c3c3c);
|
||||||
|
border: 1px solid var(--vscode-dropdown-border, #3c3c3c);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--vscode-dropdown-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground, #2a2d2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-option span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pills showing selected categories */
|
||||||
|
.multi-select-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 6px 2px 8px;
|
||||||
|
background: var(--vscode-badge-background, #4d4d4d);
|
||||||
|
color: var(--vscode-badge-foreground, #ffffff);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-pill-remove {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-badge-foreground, #ffffff);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.multi-select-pill-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,9 +39,8 @@ const autoSaveManager = new AutoSaveManager({
|
|||||||
const tagsStr = changes.tags as string;
|
const tagsStr = changes.tags as string;
|
||||||
update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||||
}
|
}
|
||||||
if ('category' in changes) {
|
if ('categories' in changes) {
|
||||||
const cat = changes.category as string;
|
update.categories = changes.categories as string[];
|
||||||
update.categories = cat ? [cat] : ['article'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await window.electronAPI?.posts.update(id, update);
|
const updated = await window.electronAPI?.posts.update(id, update);
|
||||||
@@ -645,8 +644,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
const [category, setCategory] = useState('article');
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
|
||||||
const [availableCategories, setAvailableCategories] = useState<string[]>(['article', 'picture', 'aside', 'page']);
|
const [availableCategories, setAvailableCategories] = useState<string[]>(['article', 'picture', 'aside', 'page']);
|
||||||
|
const [categoriesDropdownOpen, setCategoriesDropdownOpen] = useState(false);
|
||||||
|
const categoriesDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
|
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
|
||||||
const hydrationOverlayRef = useRef<HTMLDivElement>(null);
|
const hydrationOverlayRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -695,6 +696,19 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
loadCategories();
|
loadCategories();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Close categories dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (categoriesDropdownRef.current && !categoriesDropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setCategoriesDropdownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (categoriesDropdownOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [categoriesDropdownOpen]);
|
||||||
|
|
||||||
// Resolve media URLs in content for display
|
// Resolve media URLs in content for display
|
||||||
const resolvedContent = useMemo(() => resolveMediaUrls(content, media), [content, media]);
|
const resolvedContent = useMemo(() => resolveMediaUrls(content, media), [content, media]);
|
||||||
|
|
||||||
@@ -767,7 +781,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
category: string;
|
categories: string[];
|
||||||
postId: string;
|
postId: string;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -778,11 +792,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
tags,
|
tags,
|
||||||
category,
|
categories: selectedCategories,
|
||||||
postId,
|
postId,
|
||||||
isDirty,
|
isDirty,
|
||||||
};
|
};
|
||||||
}, [title, content, tags, category, postId, isDirty]);
|
}, [title, content, tags, selectedCategories, postId, isDirty]);
|
||||||
|
|
||||||
// Auto-save when switching away from a post or unmounting
|
// Auto-save when switching away from a post or unmounting
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -798,7 +812,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
title: pending.title,
|
title: pending.title,
|
||||||
content: pending.content,
|
content: pending.content,
|
||||||
tags: pending.tags,
|
tags: pending.tags,
|
||||||
categories: pending.category ? [pending.category] : ['article'],
|
categories: pending.categories.length > 0 ? pending.categories : ['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>);
|
||||||
@@ -818,7 +832,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
setTitle(post.title);
|
setTitle(post.title);
|
||||||
setContent(post.content);
|
setContent(post.content);
|
||||||
setTags(post.tags);
|
setTags(post.tags);
|
||||||
setCategory(post.categories[0] || 'article');
|
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
|
||||||
markClean(postId);
|
markClean(postId);
|
||||||
// Mark as initialized AFTER setting local state
|
// Mark as initialized AFTER setting local state
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
@@ -829,13 +843,13 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
// Only run after form has been initialized from post data
|
// Only run after form has been initialized from post data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!post || !isInitialized) return;
|
if (!post || !isInitialized) return;
|
||||||
const currentCategory = post.categories[0] || 'article';
|
|
||||||
const tagsChanged = JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort());
|
const tagsChanged = JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort());
|
||||||
|
const categoriesChanged = JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
title !== post.title ||
|
title !== post.title ||
|
||||||
content !== post.content ||
|
content !== post.content ||
|
||||||
tagsChanged ||
|
tagsChanged ||
|
||||||
category !== currentCategory;
|
categoriesChanged;
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
markDirty(postId);
|
markDirty(postId);
|
||||||
@@ -845,12 +859,12 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
tags: tags.join(', '),
|
tags: tags.join(', '),
|
||||||
category,
|
categories: selectedCategories,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
markClean(postId);
|
markClean(postId);
|
||||||
}
|
}
|
||||||
}, [title, content, tags, category, post, postId, isInitialized, markDirty, markClean]);
|
}, [title, content, tags, selectedCategories, post, postId, isInitialized, markDirty, markClean]);
|
||||||
|
|
||||||
// Handle editor mode change and persist preference
|
// Handle editor mode change and persist preference
|
||||||
const handleEditorModeChange = (mode: EditorMode) => {
|
const handleEditorModeChange = (mode: EditorMode) => {
|
||||||
@@ -930,7 +944,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
setTitle(reverted.title);
|
setTitle(reverted.title);
|
||||||
setContent(reverted.content);
|
setContent(reverted.content);
|
||||||
setTags(reverted.tags);
|
setTags(reverted.tags);
|
||||||
setCategory(reverted.categories[0] || 'article');
|
setSelectedCategories(reverted.categories.length > 0 ? reverted.categories : ['article']);
|
||||||
// Update local post state so UI reflects the published status
|
// Update local post state so UI reflects the published status
|
||||||
setPost(reverted as PostData);
|
setPost(reverted as PostData);
|
||||||
updatePost(postId, reverted as Partial<PostData>);
|
updatePost(postId, reverted as Partial<PostData>);
|
||||||
@@ -1264,15 +1278,67 @@ const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-field">
|
<div className="editor-field">
|
||||||
<label>Category</label>
|
<label>Categories</label>
|
||||||
<select
|
<div className="multi-select-dropdown" ref={categoriesDropdownRef}>
|
||||||
value={category}
|
<button
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
type="button"
|
||||||
|
className="multi-select-trigger"
|
||||||
|
onClick={() => setCategoriesDropdownOpen(!categoriesDropdownOpen)}
|
||||||
>
|
>
|
||||||
|
<span className="multi-select-value">
|
||||||
|
{selectedCategories.length === 0
|
||||||
|
? 'Select categories...'
|
||||||
|
: selectedCategories.length === 1
|
||||||
|
? selectedCategories[0]
|
||||||
|
: `${selectedCategories.length} categories`}
|
||||||
|
</span>
|
||||||
|
<span className="multi-select-arrow">{categoriesDropdownOpen ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{categoriesDropdownOpen && (
|
||||||
|
<div className="multi-select-menu">
|
||||||
{availableCategories.map((cat) => (
|
{availableCategories.map((cat) => (
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
<label key={cat} className="multi-select-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedCategories.includes(cat)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedCategories([...selectedCategories, cat]);
|
||||||
|
} else {
|
||||||
|
// Don't allow unchecking if it's the last category
|
||||||
|
if (selectedCategories.length > 1) {
|
||||||
|
setSelectedCategories(selectedCategories.filter(c => c !== cat));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{cat}</span>
|
||||||
|
</label>
|
||||||
))}
|
))}
|
||||||
</select>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{selectedCategories.length > 1 && (
|
||||||
|
<div className="multi-select-pills">
|
||||||
|
{selectedCategories.map((cat) => (
|
||||||
|
<span key={cat} className="multi-select-pill">
|
||||||
|
{cat}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="multi-select-pill-remove"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedCategories.length > 1) {
|
||||||
|
setSelectedCategories(selectedCategories.filter(c => c !== cat));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Remove category"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user