feat: align post category input with tag widget behavior
Co-authored-by: rfc1437 <774975+rfc1437@users.noreply.github.com>
This commit is contained in:
@@ -1021,128 +1021,3 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -162,9 +162,6 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
const [author, setAuthor] = useState('');
|
const [author, setAuthor] = useState('');
|
||||||
const [tags, setTags] = useState<string[]>([]);
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
|
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 [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);
|
||||||
@@ -194,34 +191,6 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion);
|
window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion);
|
||||||
}, [postId]);
|
}, [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
|
// Resolve media URLs in content for display
|
||||||
const resolvedContent = useMemo(() => resolveMediaUrls(content, media), [content, media]);
|
const resolvedContent = useMemo(() => resolveMediaUrls(content, media), [content, media]);
|
||||||
|
|
||||||
@@ -770,66 +739,14 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="editor-field">
|
<div className="editor-field">
|
||||||
<label>Categories</label>
|
<label>Categories</label>
|
||||||
<div className="multi-select-dropdown" ref={categoriesDropdownRef}>
|
<TagInput
|
||||||
<button
|
value={selectedCategories}
|
||||||
type="button"
|
onChange={(categories) => {
|
||||||
className="multi-select-trigger"
|
setSelectedCategories(categories.length > 0 ? categories : ['article']);
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
placeholder="Add categories..."
|
||||||
|
mode="category"
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ interface TagInputProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** Whether the input is disabled */
|
/** Whether the input is disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Input mode (tags or categories) */
|
||||||
|
mode?: 'tag' | 'category';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagInput: React.FC<TagInputProps> = ({
|
export const TagInput: React.FC<TagInputProps> = ({
|
||||||
@@ -26,6 +28,7 @@ export const TagInput: React.FC<TagInputProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Add tags...',
|
placeholder = 'Add tags...',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
mode = 'tag',
|
||||||
}) => {
|
}) => {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [suggestions, setSuggestions] = useState<TagData[]>([]);
|
const [suggestions, setSuggestions] = useState<TagData[]>([]);
|
||||||
@@ -40,14 +43,21 @@ export const TagInput: React.FC<TagInputProps> = ({
|
|||||||
// Load all available tags
|
// Load all available tags
|
||||||
const loadTags = useCallback(async () => {
|
const loadTags = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
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();
|
const tags = await window.electronAPI?.tags.getAll();
|
||||||
if (tags) {
|
if (tags) {
|
||||||
setAllTags(tags as TagData[]);
|
setAllTags(tags as TagData[]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load tags:', error);
|
|
||||||
}
|
}
|
||||||
}, []);
|
} catch (error) {
|
||||||
|
console.error(`Failed to load ${mode}s:`, error);
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTags();
|
loadTags();
|
||||||
@@ -55,8 +65,11 @@ export const TagInput: React.FC<TagInputProps> = ({
|
|||||||
|
|
||||||
// Listen for tag changes
|
// Listen for tag changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (mode !== 'tag') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return subscribeToTagEvents(window.electronAPI?.on, loadTags);
|
return subscribeToTagEvents(window.electronAPI?.on, loadTags);
|
||||||
}, [loadTags]);
|
}, [loadTags, mode]);
|
||||||
|
|
||||||
// Filter suggestions based on input
|
// Filter suggestions based on input
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,16 +137,20 @@ export const TagInput: React.FC<TagInputProps> = ({
|
|||||||
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
|
if (mode === 'category') {
|
||||||
|
await window.electronAPI?.meta.addCategory(normalized);
|
||||||
|
} else {
|
||||||
await window.electronAPI?.tags.create({ name: normalized });
|
await window.electronAPI?.tags.create({ name: normalized });
|
||||||
|
}
|
||||||
addTag(normalized);
|
addTag(normalized);
|
||||||
showToast.success(`Tag "${normalized}" created`);
|
showToast.success(`${mode === 'category' ? 'Category' : 'Tag'} "${normalized}" created`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
showToast.error(err.message);
|
showToast.error(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
}, [allTags, addTag]);
|
}, [allTags, addTag, mode]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
@@ -285,7 +302,7 @@ export const TagInput: React.FC<TagInputProps> = ({
|
|||||||
onClick={() => createAndAddTag(inputValue.trim())}
|
onClick={() => createAndAddTag(inputValue.trim())}
|
||||||
>
|
>
|
||||||
<span className="tag-suggestion-icon">+</span>
|
<span className="tag-suggestion-icon">+</span>
|
||||||
<span>Create "{inputValue.trim()}"</span>
|
<span>Create {mode === 'category' ? 'category' : 'tag'} "{inputValue.trim()}"</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,3 +49,40 @@ describe('TagInput subscriptions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TagInput category mode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const onMock = vi.fn((_channel: string, _callback: (...args: unknown[]) => void) => vi.fn());
|
||||||
|
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
tags: {
|
||||||
|
getAll: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
...(window as any).electronAPI?.meta,
|
||||||
|
getCategories: vi.fn().mockResolvedValue(['article']),
|
||||||
|
},
|
||||||
|
on: onMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads categories from meta API in category mode', async () => {
|
||||||
|
render(
|
||||||
|
<TagInput
|
||||||
|
value={[]}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
placeholder="Add categories..."
|
||||||
|
mode="category"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.electronAPI.meta.getCategories).toHaveBeenCalledTimes(1);
|
||||||
|
expect(window.electronAPI.on).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user