feat: tag selection

This commit is contained in:
2026-02-11 14:57:20 +01:00
parent 325114681f
commit 525aea3420
5 changed files with 513 additions and 13 deletions

View File

@@ -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<PostEditorProps> = ({ post }) => {
const [title, setTitle] = useState(post.title);
const [content, setContent] = useState(post.content);
const [tags, setTags] = useState(post.tags.join(', '));
const [tags, setTags] = useState<string[]>(post.tags);
const [category, setCategory] = useState(post.categories[0] || 'article');
const [availableCategories, setAvailableCategories] = useState<string[]>(['article', 'picture', 'aside', 'page']);
const [isSaving, setIsSaving] = useState(false);
@@ -191,7 +192,7 @@ const PostEditor: React.FC<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostEditorProps> = ({ 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<PostData>);
markClean(post.id);
@@ -512,12 +515,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>Tags (comma-separated)</label>
<input
type="text"
<label>Tags</label>
<TagInput
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1, tag2, tag3"
onChange={setTags}
placeholder="Add tags..."
/>
</div>
<div className="editor-field">

View File

@@ -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;
}

View File

@@ -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<TagInputProps> = ({
value,
onChange,
placeholder = 'Add tags...',
disabled = false,
}) => {
const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<TagData[]>([]);
const [allTags, setAllTags] = useState<TagData[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isCreating, setIsCreating] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
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 (
<div className="tag-input-container" ref={containerRef}>
<div className="tag-input-wrapper">
{/* 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 (
<span
key={tagName}
className={`tag-chip ${hasColor ? 'has-color' : ''}`}
style={style}
>
{tagName}
{!disabled && (
<button
type="button"
className="tag-chip-remove"
onClick={() => removeTag(tagName)}
tabIndex={-1}
aria-label={`Remove ${tagName}`}
>
×
</button>
)}
</span>
);
})}
{/* Input field */}
<input
ref={inputRef}
type="text"
className="tag-input-field"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ''}
disabled={disabled || isCreating}
/>
</div>
{/* Suggestions dropdown */}
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
<div className="tag-suggestions">
{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 (
<button
key={tag.id}
type="button"
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
style={style}
onClick={() => addTag(tag.name)}
>
{hasColor && (
<span
className="tag-suggestion-color"
style={{ backgroundColor: tag.color }}
/>
)}
<span className="tag-suggestion-name">{tag.name}</span>
</button>
);
})}
{showCreateOption && (
<button
type="button"
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
onClick={() => createAndAddTag(inputValue.trim())}
>
<span className="tag-suggestion-icon">+</span>
<span>Create "{inputValue.trim()}"</span>
</button>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export { TagInput } from './TagInput';

View File

@@ -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';