feat: align post category input with tag widget behavior

Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-17 12:45:19 +00:00
parent beb951db97
commit 2f963df9d2
4 changed files with 72 additions and 226 deletions

View File

@@ -1021,128 +1021,3 @@
font-size: 11px;
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);
}

View File

@@ -162,9 +162,6 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
const [author, setAuthor] = useState('');
const [tags, setTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
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 [hasPublishedVersion, setHasPublishedVersion] = useState(false);
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
@@ -194,34 +191,6 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion);
}, [postId]);
// Load available categories from backend (project-scoped)
useEffect(() => {
const loadCategories = async () => {
try {
const categories = await window.electronAPI?.meta.getCategories();
if (categories && categories.length > 0) {
setAvailableCategories(categories);
}
} catch {
// Keep defaults
}
};
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
const resolvedContent = useMemo(() => resolveMediaUrls(content, media), [content, media]);
@@ -770,66 +739,14 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
</div>
<div className="editor-field">
<label>Categories</label>
<div className="multi-select-dropdown" ref={categoriesDropdownRef}>
<button
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) => (
<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>
))}
</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>
)}
<TagInput
value={selectedCategories}
onChange={(categories) => {
setSelectedCategories(categories.length > 0 ? categories : ['article']);
}}
placeholder="Add categories..."
mode="category"
/>
</div>
</div>

View File

@@ -19,6 +19,8 @@ interface TagInputProps {
placeholder?: string;
/** Whether the input is disabled */
disabled?: boolean;
/** Input mode (tags or categories) */
mode?: 'tag' | 'category';
}
export const TagInput: React.FC<TagInputProps> = ({
@@ -26,6 +28,7 @@ export const TagInput: React.FC<TagInputProps> = ({
onChange,
placeholder = 'Add tags...',
disabled = false,
mode = 'tag',
}) => {
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<TagData[]>([]);
@@ -40,14 +43,21 @@ export const TagInput: React.FC<TagInputProps> = ({
// Load all available tags
const loadTags = useCallback(async () => {
try {
const tags = await window.electronAPI?.tags.getAll();
if (tags) {
setAllTags(tags as TagData[]);
if (mode === 'category') {
const categories = await window.electronAPI?.meta.getCategories();
if (categories) {
setAllTags(categories.map((name) => ({ id: name, name })));
}
} else {
const tags = await window.electronAPI?.tags.getAll();
if (tags) {
setAllTags(tags as TagData[]);
}
}
} catch (error) {
console.error('Failed to load tags:', error);
console.error(`Failed to load ${mode}s:`, error);
}
}, []);
}, [mode]);
useEffect(() => {
loadTags();
@@ -55,8 +65,11 @@ export const TagInput: React.FC<TagInputProps> = ({
// Listen for tag changes
useEffect(() => {
if (mode !== 'tag') {
return;
}
return subscribeToTagEvents(window.electronAPI?.on, loadTags);
}, [loadTags]);
}, [loadTags, mode]);
// Filter suggestions based on input
useEffect(() => {
@@ -124,16 +137,20 @@ export const TagInput: React.FC<TagInputProps> = ({
setIsCreating(true);
try {
await window.electronAPI?.tags.create({ name: normalized });
if (mode === 'category') {
await window.electronAPI?.meta.addCategory(normalized);
} else {
await window.electronAPI?.tags.create({ name: normalized });
}
addTag(normalized);
showToast.success(`Tag "${normalized}" created`);
showToast.success(`${mode === 'category' ? 'Category' : 'Tag'} "${normalized}" created`);
} catch (error) {
const err = error as Error;
showToast.error(err.message);
} finally {
setIsCreating(false);
}
}, [allTags, addTag]);
}, [allTags, addTag, mode]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -285,7 +302,7 @@ export const TagInput: React.FC<TagInputProps> = ({
onClick={() => createAndAddTag(inputValue.trim())}
>
<span className="tag-suggestion-icon">+</span>
<span>Create "{inputValue.trim()}"</span>
<span>Create {mode === 'category' ? 'category' : 'tag'} "{inputValue.trim()}"</span>
</button>
)}
</div>