From 525aea3420f56661e69ee0ba39c9ebe53f90f6ba Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 11 Feb 2026 14:57:20 +0100 Subject: [PATCH] feat: tag selection --- src/renderer/components/Editor/Editor.tsx | 28 +- src/renderer/components/TagInput/TagInput.css | 169 +++++++++ src/renderer/components/TagInput/TagInput.tsx | 327 ++++++++++++++++++ src/renderer/components/TagInput/index.ts | 1 + src/renderer/components/index.ts | 1 + 5 files changed, 513 insertions(+), 13 deletions(-) create mode 100644 src/renderer/components/TagInput/TagInput.css create mode 100644 src/renderer/components/TagInput/TagInput.tsx create mode 100644 src/renderer/components/TagInput/index.ts diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 3a39ad1..ece33d2 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -8,6 +8,7 @@ import { PostLinks } from '../PostLinks'; import { ErrorModal } from '../ErrorModal'; import { SettingsView } from '../SettingsView'; import { TagsView } from '../TagsView'; +import { TagInput } from '../TagInput'; import { AutoSaveManager } from '../../utils'; import './Editor.css'; @@ -149,7 +150,7 @@ const PostEditor: React.FC = ({ post }) => { const [title, setTitle] = useState(post.title); const [content, setContent] = useState(post.content); - const [tags, setTags] = useState(post.tags.join(', ')); + const [tags, setTags] = useState(post.tags); const [category, setCategory] = useState(post.categories[0] || 'article'); const [availableCategories, setAvailableCategories] = useState(['article', 'picture', 'aside', 'page']); const [isSaving, setIsSaving] = useState(false); @@ -191,7 +192,7 @@ const PostEditor: React.FC = ({ post }) => { const pendingChangesRef = useRef<{ title: string; content: string; - tags: string; + tags: string[]; category: string; postId: string; isDirty: boolean; @@ -225,7 +226,7 @@ const PostEditor: React.FC = ({ post }) => { window.electronAPI?.posts.update(pending.postId, { title: pending.title, content: pending.content, - tags: pending.tags.split(',').map(t => t.trim()).filter(t => t.length > 0), + tags: pending.tags, categories: pending.category ? [pending.category] : ['article'], }).then((updated) => { if (updated) { @@ -243,7 +244,7 @@ const PostEditor: React.FC = ({ post }) => { useEffect(() => { setTitle(post.title); setContent(post.content); - setTags(post.tags.join(', ')); + setTags(post.tags); setCategory(post.categories[0] || 'article'); markClean(post.id); }, [post.id, post.title, post.content, post.tags, post.categories, markClean]); @@ -251,19 +252,21 @@ const PostEditor: React.FC = ({ post }) => { // Track changes and notify auto-save manager useEffect(() => { const currentCategory = post.categories[0] || 'article'; + const tagsChanged = JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort()); const hasChanges = title !== post.title || content !== post.content || - tags !== post.tags.join(', ') || + tagsChanged || category !== currentCategory; if (hasChanges) { markDirty(post.id); // Notify auto-save manager with accumulated changes + // Convert tags array to comma-separated string for auto-save compatibility autoSaveManager.notifyChange(post.id, { title, content, - tags, + tags: tags.join(', '), category, }); } else { @@ -288,7 +291,7 @@ const PostEditor: React.FC = ({ post }) => { const updated = await window.electronAPI?.posts.update(post.id, { title, content, - tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0), + tags, categories: category ? [category] : ['article'], }); @@ -364,7 +367,7 @@ const PostEditor: React.FC = ({ post }) => { if (reverted) { setTitle(reverted.title); setContent(reverted.content); - setTags(reverted.tags.join(', ')); + setTags(reverted.tags); setCategory(reverted.categories[0] || 'article'); updatePost(post.id, reverted as Partial); markClean(post.id); @@ -512,12 +515,11 @@ const PostEditor: React.FC = ({ post }) => {
- - Tags + setTags(e.target.value)} - placeholder="tag1, tag2, tag3" + onChange={setTags} + placeholder="Add tags..." />
diff --git a/src/renderer/components/TagInput/TagInput.css b/src/renderer/components/TagInput/TagInput.css new file mode 100644 index 0000000..7f6382e --- /dev/null +++ b/src/renderer/components/TagInput/TagInput.css @@ -0,0 +1,169 @@ +.tag-input-container { + position: relative; + width: 100%; +} + +.tag-input-wrapper { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 6px 8px; + min-height: 38px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background-primary); + cursor: text; +} + +.tag-input-wrapper:focus-within { + border-color: var(--accent-color); + outline: none; +} + +/* Tag chips */ +.tag-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + font-size: 0.85rem; + background: var(--background-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-primary); + white-space: nowrap; +} + +.tag-chip.has-color { + border-radius: 12px; + padding: 3px 10px; +} + +.tag-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + margin-left: 2px; + border: none; + background: transparent; + color: inherit; + font-size: 1rem; + line-height: 1; + cursor: pointer; + opacity: 0.6; + border-radius: 50%; + transition: opacity 0.15s, background 0.15s; +} + +.tag-chip-remove:hover { + opacity: 1; + background: rgba(0, 0, 0, 0.1); +} + +.tag-chip.has-color .tag-chip-remove:hover { + background: rgba(0, 0, 0, 0.2); +} + +/* Input field */ +.tag-input-field { + flex: 1; + min-width: 120px; + padding: 2px 4px; + border: none; + background: transparent; + color: var(--text-primary); + font-family: inherit; + font-size: 0.9rem; + outline: none; +} + +.tag-input-field::placeholder { + color: var(--text-secondary); +} + +.tag-input-field:disabled { + cursor: not-allowed; +} + +/* Suggestions dropdown */ +.tag-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + padding: 4px; + background: var(--background-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 100; + max-height: 240px; + overflow-y: auto; +} + +.tag-suggestion { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text-primary); + font-family: inherit; + font-size: 0.9rem; + text-align: left; + cursor: pointer; + border-radius: 4px; + transition: background 0.1s; +} + +.tag-suggestion:hover, +.tag-suggestion.selected { + background: var(--background-hover); +} + +.tag-suggestion-color { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.tag-suggestion-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Create new tag option */ +.tag-suggestion.create-new { + border-top: 1px solid var(--border-color); + margin-top: 4px; + padding-top: 12px; + color: var(--accent-color); +} + +.tag-suggestion.create-new:first-child { + border-top: none; + margin-top: 0; + padding-top: 8px; +} + +.tag-suggestion-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 1px dashed currentColor; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 600; +} diff --git a/src/renderer/components/TagInput/TagInput.tsx b/src/renderer/components/TagInput/TagInput.tsx new file mode 100644 index 0000000..f08c758 --- /dev/null +++ b/src/renderer/components/TagInput/TagInput.tsx @@ -0,0 +1,327 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { showToast } from '../Toast'; +import './TagInput.css'; + +interface TagData { + id: string; + name: string; + color?: string; +} + +interface TagInputProps { + /** Current tags assigned to the item */ + value: string[]; + /** Callback when tags change */ + onChange: (tags: string[]) => void; + /** Placeholder text */ + placeholder?: string; + /** Whether the input is disabled */ + disabled?: boolean; +} + +// Get contrasting text color for background +const getContrastColor = (hex: string): string => { + const color = hex.replace('#', ''); + let r: number, g: number, b: number; + if (color.length === 3) { + r = parseInt(color[0] + color[0], 16); + g = parseInt(color[1] + color[1], 16); + b = parseInt(color[2] + color[2], 16); + } else { + r = parseInt(color.substring(0, 2), 16); + g = parseInt(color.substring(2, 4), 16); + b = parseInt(color.substring(4, 6), 16); + } + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? '#000000' : '#ffffff'; +}; + +export const TagInput: React.FC = ({ + value, + onChange, + placeholder = 'Add tags...', + disabled = false, +}) => { + const [inputValue, setInputValue] = useState(''); + const [suggestions, setSuggestions] = useState([]); + const [allTags, setAllTags] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const [isCreating, setIsCreating] = useState(false); + + const inputRef = useRef(null); + const containerRef = useRef(null); + + // Load all available tags + const loadTags = useCallback(async () => { + try { + const tags = await window.electronAPI?.tags.getAll(); + if (tags) { + setAllTags(tags as TagData[]); + } + } catch (error) { + console.error('Failed to load tags:', error); + } + }, []); + + useEffect(() => { + loadTags(); + }, [loadTags]); + + // Listen for tag changes + useEffect(() => { + const unsubscribers: Array<() => void> = []; + + unsubscribers.push( + window.electronAPI?.on('tag:created', () => loadTags()) || (() => {}) + ); + unsubscribers.push( + window.electronAPI?.on('tag:deleted', () => loadTags()) || (() => {}) + ); + unsubscribers.push( + window.electronAPI?.on('tag:renamed', () => loadTags()) || (() => {}) + ); + unsubscribers.push( + window.electronAPI?.on('tags:merged', () => loadTags()) || (() => {}) + ); + + return () => { + unsubscribers.forEach(unsub => unsub()); + }; + }, [loadTags]); + + // Filter suggestions based on input + useEffect(() => { + if (!inputValue.trim()) { + setSuggestions([]); + return; + } + + const query = inputValue.toLowerCase().trim(); + const filtered = allTags + .filter(tag => + tag.name.toLowerCase().includes(query) && + !value.includes(tag.name) + ) + .slice(0, 8); // Limit to 8 suggestions + + setSuggestions(filtered); + setSelectedIndex(-1); + }, [inputValue, allTags, value]); + + // Close suggestions when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowSuggestions(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Add a tag + const addTag = useCallback((tagName: string) => { + const normalized = tagName.trim().toLowerCase(); + if (!normalized) return; + + if (value.includes(normalized)) { + showToast.info('Tag already added'); + return; + } + + onChange([...value, normalized]); + setInputValue(''); + setShowSuggestions(false); + inputRef.current?.focus(); + }, [value, onChange]); + + // Remove a tag + const removeTag = useCallback((tagName: string) => { + onChange(value.filter(t => t !== tagName)); + }, [value, onChange]); + + // Create a new tag and add it + const createAndAddTag = useCallback(async (tagName: string) => { + const normalized = tagName.trim().toLowerCase(); + if (!normalized) return; + + // Check if it already exists + const exists = allTags.some(t => t.name === normalized); + if (exists) { + addTag(normalized); + return; + } + + setIsCreating(true); + try { + await window.electronAPI?.tags.create({ name: normalized }); + addTag(normalized); + showToast.success(`Tag "${normalized}" created`); + } catch (error) { + const err = error as Error; + showToast.error(err.message); + } finally { + setIsCreating(false); + } + }, [allTags, addTag]); + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + const maxIndex = suggestions.length + (inputValue.trim() && !suggestions.some(s => s.name === inputValue.trim().toLowerCase()) ? 0 : -1); + setSelectedIndex(prev => Math.min(prev + 1, maxIndex)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex(prev => Math.max(prev - 1, -1)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + addTag(suggestions[selectedIndex].name); + } else if (selectedIndex === suggestions.length && inputValue.trim()) { + // Create new tag option selected + createAndAddTag(inputValue.trim()); + } else if (inputValue.trim()) { + // No selection, but there's input - check if exact match exists + const exactMatch = allTags.find(t => t.name === inputValue.trim().toLowerCase()); + if (exactMatch) { + addTag(exactMatch.name); + } else { + createAndAddTag(inputValue.trim()); + } + } + } else if (e.key === 'Escape') { + setShowSuggestions(false); + setInputValue(''); + } else if (e.key === 'Backspace' && !inputValue && value.length > 0) { + // Remove last tag when backspace on empty input + removeTag(value[value.length - 1]); + } else if (e.key === ',') { + e.preventDefault(); + if (inputValue.trim()) { + const exactMatch = allTags.find(t => t.name === inputValue.trim().toLowerCase()); + if (exactMatch) { + addTag(exactMatch.name); + } else { + createAndAddTag(inputValue.trim()); + } + } + } + }; + + // Get tag data for display + const getTagData = (tagName: string): TagData | undefined => { + return allTags.find(t => t.name === tagName); + }; + + // Check if input matches an existing tag exactly + const exactMatchExists = inputValue.trim() && + allTags.some(t => t.name === inputValue.trim().toLowerCase()); + + // Should show "Create new tag" option + const showCreateOption = inputValue.trim() && + !exactMatchExists && + !value.includes(inputValue.trim().toLowerCase()); + + return ( +
+
+ {/* Current tags */} + {value.map(tagName => { + const tagData = getTagData(tagName); + const hasColor = !!tagData?.color; + const style: React.CSSProperties = hasColor + ? { + backgroundColor: tagData!.color, + color: getContrastColor(tagData!.color!), + borderColor: tagData!.color, + } + : {}; + + return ( + + {tagName} + {!disabled && ( + + )} + + ); + })} + + {/* Input field */} + { + setInputValue(e.target.value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onKeyDown={handleKeyDown} + placeholder={value.length === 0 ? placeholder : ''} + disabled={disabled || isCreating} + /> +
+ + {/* Suggestions dropdown */} + {showSuggestions && (suggestions.length > 0 || showCreateOption) && ( +
+ {suggestions.map((tag, index) => { + const hasColor = !!tag.color; + const style: React.CSSProperties = hasColor + ? { + '--tag-color': tag.color, + '--tag-text-color': getContrastColor(tag.color!), + } as React.CSSProperties + : {}; + + return ( + + ); + })} + + {showCreateOption && ( + + )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/TagInput/index.ts b/src/renderer/components/TagInput/index.ts new file mode 100644 index 0000000..2321a09 --- /dev/null +++ b/src/renderer/components/TagInput/index.ts @@ -0,0 +1 @@ +export { TagInput } from './TagInput'; diff --git a/src/renderer/components/index.ts b/src/renderer/components/index.ts index bf4572a..a0cfba6 100644 --- a/src/renderer/components/index.ts +++ b/src/renderer/components/index.ts @@ -13,5 +13,6 @@ export { ResizablePanel } from './ResizablePanel'; export { CredentialsPanel } from './CredentialsPanel'; export { SettingsView } from './SettingsView'; export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView'; +export { TagInput } from './TagInput'; export { PostLinks } from './PostLinks'; export { ErrorModal, type ErrorDetails } from './ErrorModal';