feat: tag management

This commit is contained in:
2026-02-11 14:30:57 +01:00
parent 6b9aa3fb1e
commit 325114681f
17 changed files with 2529 additions and 3 deletions

View File

@@ -22,6 +22,12 @@ const SettingsIcon = () => (
</svg>
);
const TagsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"/>
</svg>
);
const SyncIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
@@ -35,12 +41,20 @@ export const ActivityBar: React.FC = () => {
// Check if settings tab is currently active
const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId);
// Check if tags tab is currently active
const isTagsTabActive = tabs.some(t => t.type === 'tags' && t.id === activeTabId);
const handleSettingsClick = () => {
// Open settings as a dedicated (non-transient) tab
openTab({ type: 'settings', id: 'settings', isTransient: false });
};
const handleTagsClick = () => {
// Open tags as a dedicated (non-transient) tab
openTab({ type: 'tags', id: 'tags', isTransient: false });
};
return (
<div className="activity-bar">
<div className="activity-bar-top">
@@ -58,6 +72,13 @@ export const ActivityBar: React.FC = () => {
>
<MediaIcon />
</button>
<button
className={`activity-bar-item ${isTagsTabActive ? 'active' : ''}`}
onClick={handleTagsClick}
title="Tags"
>
<TagsIcon />
</button>
</div>
<div className="activity-bar-bottom">

View File

@@ -7,6 +7,7 @@ import { Lightbox, useMarkdownImages } from '../Lightbox';
import { PostLinks } from '../PostLinks';
import { ErrorModal } from '../ErrorModal';
import { SettingsView } from '../SettingsView';
import { TagsView } from '../TagsView';
import { AutoSaveManager } from '../../utils';
import './Editor.css';
@@ -1029,6 +1030,7 @@ export const Editor: React.FC = () => {
const showPost = activeTab?.type === 'post';
const showMedia = activeTab?.type === 'media';
const showSettings = activeTab?.type === 'settings' || (activeView === 'settings' && !activeTab);
const showTags = activeTab?.type === 'tags' || (activeView === 'tags' && !activeTab);
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
useEffect(() => {
@@ -1082,6 +1084,16 @@ export const Editor: React.FC = () => {
);
}
// Show tags if tags tab is active
if (showTags) {
return (
<>
<TagsView />
{renderErrorModal()}
</>
);
}
// Show post editor if a post tab is active
if (showPost && activeTabId) {
const post = posts.find(p => p.id === activeTabId);

View File

@@ -636,6 +636,50 @@ const MediaList: React.FC = () => {
};
import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView';
import { scrollToTagsSection, TagsCategory } from '../TagsView';
const TagsNav: React.FC = () => {
const [activeSection, setActiveSection] = useState<TagsCategory | null>(null);
const handleNavClick = (category: TagsCategory) => {
setActiveSection(category);
scrollToTagsSection(category);
};
return (
<div className="sidebar-content settings-panel">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>TAGS</span>
</div>
</div>
<div className="settings-nav-list">
<button
className={`settings-nav-entry ${activeSection === 'cloud' ? 'active' : ''}`}
onClick={() => handleNavClick('cloud')}
>
<span className="settings-nav-entry-icon"></span>
<span>Tag Cloud</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'manage' ? 'active' : ''}`}
onClick={() => handleNavClick('manage')}
>
<span className="settings-nav-entry-icon"></span>
<span>Create & Edit</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'merge' ? 'active' : ''}`}
onClick={() => handleNavClick('merge')}
>
<span className="settings-nav-entry-icon">🔀</span>
<span>Merge Tags</span>
</button>
</div>
</div>
);
};
const SettingsNav: React.FC = () => {
const { syncConfigured } = useAppStore();
@@ -715,6 +759,7 @@ export const Sidebar: React.FC = () => {
{activeView === 'posts' && <PostsList />}
{activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsNav />}
{activeView === 'tags' && <TagsNav />}
</div>
);
};

View File

@@ -7,6 +7,10 @@ const getTabTitle = (tab: Tab, posts: { id: string; title: string }[], media: {
return 'Settings';
}
if (tab.type === 'tags') {
return 'Tags';
}
if (tab.type === 'post') {
const post = posts.find(p => p.id === tab.id);
return post?.title || 'Untitled';
@@ -40,6 +44,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/>
</svg>
);
case 'tags':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14.28 7.72l-6-6A1 1 0 007.57 1.5H2.5A1 1 0 001.5 2.5v5.07a1 1 0 00.22.56l6 6a1 1 0 001.41 0l5.15-5a1 1 0 000-1.41zM4 5a1 1 0 110-2 1 1 0 010 2z"/>
</svg>
);
default:
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">

View File

@@ -0,0 +1,366 @@
.tags-view {
height: 100%;
overflow-y: auto;
padding: 24px;
background-color: var(--background-primary);
}
.tags-view-header {
margin-bottom: 32px;
}
.tags-view-header h2 {
margin: 0 0 8px 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
}
.tags-view-content {
max-width: 900px;
}
/* Text utilities */
.text-muted {
color: var(--text-secondary);
}
.text-small {
font-size: 0.85rem;
}
/* Section */
.tags-section {
margin-bottom: 40px;
padding-top: 8px;
}
.tags-section-header {
margin-bottom: 16px;
}
.tags-section-header h3 {
margin: 0 0 4px 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.tags-section-description {
margin: 0;
color: var(--text-secondary);
font-size: 0.9rem;
}
.tags-section-content {
padding: 16px;
background-color: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
/* Loading and Empty States */
.tags-loading,
.tags-empty {
padding: 40px;
text-align: center;
color: var(--text-secondary);
}
.tags-empty button {
margin-top: 16px;
}
/* Tag Cloud */
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 8px;
}
.tag-cloud-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
}
.tag-cloud-item:hover {
border-color: var(--accent-color);
background: var(--background-hover);
}
.tag-cloud-item.selected {
border-color: var(--accent-color);
background: var(--accent-color-transparent);
box-shadow: 0 0 0 2px var(--accent-color-transparent);
}
.tag-cloud-item.has-color {
border: none;
padding: 7px 13px;
border-radius: 16px;
}
.tag-cloud-item.has-color:hover {
opacity: 0.9;
}
.tag-cloud-item.has-color.selected {
box-shadow: 0 0 0 3px var(--accent-color);
}
.tag-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-size: 0.7rem;
font-weight: 600;
background: rgba(0, 0, 0, 0.15);
border-radius: 10px;
}
.tag-cloud-item:not(.has-color) .tag-count {
background: var(--background-tertiary);
}
/* Selection Info */
.tag-selection-info {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
padding: 12px 16px;
background: var(--accent-color-transparent);
border-radius: 6px;
font-size: 0.9rem;
}
.tag-selection-info button {
font-size: 0.85rem;
padding: 4px 12px;
}
/* Forms */
.tag-create-form,
.tag-edit-form,
.merge-form {
margin-bottom: 16px;
}
.tag-create-form h4,
.tag-edit-form h4 {
margin: 0 0 12px 0;
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
}
.tag-form-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.tag-form-row input[type="text"] {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
}
.tag-form-row input[type="text"]:focus {
outline: none;
border-color: var(--accent-color);
}
.tag-form-row select {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
}
.tag-form-row select:focus {
outline: none;
border-color: var(--accent-color);
}
/* Color picker group */
.color-picker-group {
display: flex;
align-items: center;
gap: 4px;
}
.color-picker-group input[type="color"] {
width: 36px;
height: 36px;
padding: 2px;
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
background: var(--background-primary);
}
.color-picker-group input[type="color"]::-webkit-color-swatch-wrapper {
padding: 2px;
}
.color-picker-group input[type="color"]::-webkit-color-swatch {
border-radius: 2px;
border: none;
}
.clear-color {
padding: 4px 8px !important;
font-size: 0.75rem !important;
min-width: auto !important;
background: var(--background-tertiary) !important;
border: none !important;
}
.clear-color:hover {
background: var(--background-hover) !important;
}
/* Color presets */
.color-presets {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
}
.color-preset {
width: 24px;
height: 24px;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: transform 0.1s ease, border-color 0.1s ease;
}
.color-preset:hover {
transform: scale(1.15);
border-color: var(--text-secondary);
}
/* Tag preview */
.tag-preview {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 0.95rem;
}
/* Buttons */
.tags-view button {
padding: 8px 16px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--background-primary);
color: var(--text-primary);
font-family: inherit;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.15s ease;
}
.tags-view button:hover {
background: var(--background-hover);
border-color: var(--text-secondary);
}
.tags-view button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tags-view button.primary {
background: var(--accent-color);
border-color: var(--accent-color);
color: white;
}
.tags-view button.primary:hover {
opacity: 0.9;
}
.tags-view button.danger {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.tags-view button.danger:hover {
opacity: 0.9;
}
/* Confirm Dialog */
.confirm-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.confirm-dialog {
background: var(--background-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
max-width: 450px;
width: 90%;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
}
.confirm-dialog h3 {
margin: 0 0 12px 0;
font-size: 1.2rem;
font-weight: 600;
color: var(--text-primary);
}
.confirm-dialog p {
margin: 0 0 20px 0;
color: var(--text-secondary);
line-height: 1.5;
}
.confirm-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}

View File

@@ -0,0 +1,615 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import './TagsView.css';
// Types
interface TagWithCount {
name: string;
color: string | null;
count: number;
}
interface TagData {
id: string;
projectId: string;
name: string;
color?: string;
createdAt: string;
updatedAt: string;
}
// Export category IDs for sidebar navigation
export type TagsCategory = 'cloud' | 'manage' | 'merge';
// Scroll to a tags section by category ID
export const scrollToTagsSection = (category: TagsCategory) => {
const element = document.getElementById(`tags-section-${category}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
// Get contrasting text color for background
const getContrastColor = (hex: string): string => {
// Remove # if present
const color = hex.replace('#', '');
// Parse hex to RGB
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);
}
// Calculate relative luminance
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#ffffff';
};
// Color picker presets
const COLOR_PRESETS = [
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16',
'#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9',
'#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
'#ec4899', '#f43f5e',
];
// Tag Cloud Item
const TagCloudItem: React.FC<{
tag: TagWithCount;
isSelected: boolean;
onSelect: (name: string) => void;
maxCount: number;
}> = ({ tag, isSelected, onSelect, maxCount }) => {
// Calculate font size based on count (range: 0.8rem to 2rem)
const minSize = 0.85;
const maxSize = 1.8;
const ratio = maxCount > 1 ? (tag.count - 1) / (maxCount - 1) : 0;
const fontSize = minSize + (maxSize - minSize) * ratio;
const hasColor = !!tag.color;
const style: React.CSSProperties = hasColor
? {
backgroundColor: tag.color!,
color: getContrastColor(tag.color!),
fontSize: `${fontSize}rem`,
}
: {
fontSize: `${fontSize}rem`,
};
return (
<button
className={`tag-cloud-item ${isSelected ? 'selected' : ''} ${hasColor ? 'has-color' : ''}`}
style={style}
onClick={() => onSelect(tag.name)}
title={`${tag.count} post${tag.count !== 1 ? 's' : ''}`}
>
{tag.name}
<span className="tag-count">{tag.count}</span>
</button>
);
};
// Confirm Dialog for destructive actions
const ConfirmDialog: React.FC<{
isOpen: boolean;
title: string;
message: string;
confirmText: string;
cancelText?: string;
isDestructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}> = ({ isOpen, title, message, confirmText, cancelText = 'Cancel', isDestructive, onConfirm, onCancel }) => {
if (!isOpen) return null;
return (
<div className="confirm-dialog-overlay">
<div className="confirm-dialog">
<h3>{title}</h3>
<p>{message}</p>
<div className="confirm-dialog-actions">
<button onClick={onCancel}>{cancelText}</button>
<button
className={isDestructive ? 'danger' : 'primary'}
onClick={onConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
// Section Header
const SectionHeader: React.FC<{
id?: string;
title: string;
description?: string;
children: React.ReactNode;
}> = ({ id, title, description, children }) => (
<div className="tags-section" id={id}>
<div className="tags-section-header">
<h3>{title}</h3>
{description && <p className="tags-section-description">{description}</p>}
</div>
<div className="tags-section-content">
{children}
</div>
</div>
);
export const TagsView: React.FC = () => {
const { showErrorModal } = useAppStore();
// State
const [tagsWithCounts, setTagsWithCounts] = useState<TagWithCount[]>([]);
const [allTags, setAllTags] = useState<TagData[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Create tag form
const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('');
// Edit tag state
const [editingTagId, setEditingTagId] = useState<string | null>(null);
const [editTagColor, setEditTagColor] = useState<string>('');
const [editTagName, setEditTagName] = useState('');
// Merge tags state
const [mergeTargetName, setMergeTargetName] = useState('');
// Confirm dialogs
const [deleteConfirm, setDeleteConfirm] = useState<{ tagId: string; tagName: string } | null>(null);
const [mergeConfirm, setMergeConfirm] = useState<{ sourceNames: string[]; targetName: string } | null>(null);
// Load tags
const loadTags = useCallback(async () => {
try {
setIsLoading(true);
const [tagsWithCountsResult, allTagsResult] = await Promise.all([
window.electronAPI?.tags.getWithCounts(),
window.electronAPI?.tags.getAll(),
]);
if (tagsWithCountsResult) {
setTagsWithCounts(tagsWithCountsResult as TagWithCount[]);
}
if (allTagsResult) {
setAllTags(allTagsResult as TagData[]);
}
} catch (error) {
console.error('Failed to load tags:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadTags();
}, [loadTags]);
// Listen for tag events
useEffect(() => {
const unsubscribers: Array<() => void> = [];
unsubscribers.push(
window.electronAPI?.on('tag:created', () => loadTags()) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('tag:updated', () => 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]);
// Handle tag selection
const handleTagSelect = (name: string) => {
setSelectedTags(prev => {
if (prev.includes(name)) {
return prev.filter(n => n !== name);
}
return [...prev, name];
});
};
// Create tag
const handleCreateTag = async () => {
if (!newTagName.trim()) {
showToast.error('Tag name is required');
return;
}
try {
await window.electronAPI?.tags.create({
name: newTagName.trim(),
color: newTagColor || undefined,
});
setNewTagName('');
setNewTagColor('');
showToast.success('Tag created');
loadTags();
} catch (error) {
const err = error as Error;
showToast.error(err.message);
}
};
// Delete tag (with confirmation)
const handleDeleteTag = async () => {
if (!deleteConfirm) return;
try {
const result = await window.electronAPI?.tags.delete(deleteConfirm.tagId);
if (result?.success) {
showToast.success(`Tag deleted. ${result.postsUpdated} post(s) updated.`);
setSelectedTags(prev => prev.filter(n => n !== deleteConfirm.tagName));
loadTags();
}
} catch (error) {
const err = error as Error;
showErrorModal({
title: 'Delete Failed',
message: err.message,
});
} finally {
setDeleteConfirm(null);
}
};
// Start editing tag
const handleStartEdit = (tag: TagData) => {
setEditingTagId(tag.id);
setEditTagColor(tag.color || '');
setEditTagName(tag.name);
};
// Save tag edit
const handleSaveEdit = async () => {
if (!editingTagId) return;
try {
// Update color
await window.electronAPI?.tags.update(editingTagId, {
color: editTagColor || null,
});
// If name changed, rename the tag
const originalTag = allTags.find(t => t.id === editingTagId);
if (originalTag && originalTag.name !== editTagName.trim().toLowerCase()) {
await window.electronAPI?.tags.rename(editingTagId, editTagName.trim());
}
showToast.success('Tag updated');
setEditingTagId(null);
loadTags();
} catch (error) {
const err = error as Error;
showToast.error(err.message);
}
};
// Merge tags (with confirmation)
const handleMergeTags = async () => {
if (!mergeConfirm) return;
try {
// Find target tag
const targetTag = allTags.find(t => t.name === mergeConfirm.targetName);
if (!targetTag) {
showToast.error('Target tag not found');
return;
}
// Find source tag IDs
const sourceTags = allTags.filter(t =>
mergeConfirm.sourceNames.includes(t.name) && t.id !== targetTag.id
);
if (sourceTags.length === 0) {
showToast.error('No source tags to merge');
return;
}
const result = await window.electronAPI?.tags.merge(
sourceTags.map(t => t.id),
targetTag.id
);
if (result?.success) {
showToast.success(
`Merged ${result.tagsDeleted} tag(s) into "${result.targetTag}". ${result.postsUpdated} post(s) updated.`
);
setSelectedTags([]);
setMergeTargetName('');
loadTags();
}
} catch (error) {
const err = error as Error;
showErrorModal({
title: 'Merge Failed',
message: err.message,
});
} finally {
setMergeConfirm(null);
}
};
// Sync tags from posts
const handleSyncFromPosts = async () => {
try {
const result = await window.electronAPI?.tags.syncFromPosts();
if (result) {
if (result.added.length > 0) {
showToast.success(`Discovered ${result.added.length} new tag(s)`);
} else {
showToast.info('All tags are already synced');
}
loadTags();
}
} catch (error) {
const err = error as Error;
showToast.error(err.message);
}
};
// Clear selection
const handleClearSelection = () => {
setSelectedTags([]);
};
// Get max count for sizing
const maxCount = Math.max(...tagsWithCounts.map(t => t.count), 1);
// Selected tag objects
const selectedTagObjects = allTags.filter(t => selectedTags.includes(t.name));
return (
<div className="tags-view">
<div className="tags-view-header">
<h2>Tag Management</h2>
<p className="text-muted">Manage your blog's tags, assign colors, and perform bulk operations.</p>
</div>
<div className="tags-view-content">
{/* Tag Cloud Section */}
<SectionHeader
id="tags-section-cloud"
title="Tag Cloud"
description="Click tags to select them for bulk operations. Hover to see post counts."
>
{isLoading ? (
<div className="tags-loading">Loading tags...</div>
) : tagsWithCounts.length === 0 ? (
<div className="tags-empty">
<p>No tags found</p>
<button onClick={handleSyncFromPosts}>Discover tags from posts</button>
</div>
) : (
<>
<div className="tag-cloud">
{tagsWithCounts.map(tag => (
<TagCloudItem
key={tag.name}
tag={tag}
isSelected={selectedTags.includes(tag.name)}
onSelect={handleTagSelect}
maxCount={maxCount}
/>
))}
</div>
{selectedTags.length > 0 && (
<div className="tag-selection-info">
<span>{selectedTags.length} tag(s) selected</span>
<button onClick={handleClearSelection}>Clear selection</button>
</div>
)}
</>
)}
</SectionHeader>
{/* Tag Management Section */}
<SectionHeader
id="tags-section-manage"
title="Create & Edit Tags"
description="Create new tags or edit existing ones. Assign colors to make tags visually distinct."
>
{/* Create new tag */}
<div className="tag-create-form">
<h4>Create New Tag</h4>
<div className="tag-form-row">
<input
type="text"
placeholder="Tag name"
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateTag()}
/>
<div className="color-picker-group">
<input
type="color"
value={newTagColor || '#808080'}
onChange={(e) => setNewTagColor(e.target.value)}
title="Choose color"
/>
{newTagColor && (
<button
className="clear-color"
onClick={() => setNewTagColor('')}
title="Remove color"
>
</button>
)}
</div>
<button onClick={handleCreateTag} className="primary">Create</button>
</div>
<div className="color-presets">
{COLOR_PRESETS.map(color => (
<button
key={color}
className="color-preset"
style={{ backgroundColor: color }}
onClick={() => setNewTagColor(color)}
title={color}
/>
))}
</div>
</div>
{/* Selected tag editor */}
{selectedTagObjects.length === 1 && (
<div className="tag-edit-form">
<h4>Edit Tag: {selectedTagObjects[0].name}</h4>
{editingTagId === selectedTagObjects[0].id ? (
<div className="tag-form-row">
<input
type="text"
value={editTagName}
onChange={(e) => setEditTagName(e.target.value)}
placeholder="Tag name"
/>
<div className="color-picker-group">
<input
type="color"
value={editTagColor || '#808080'}
onChange={(e) => setEditTagColor(e.target.value)}
/>
{editTagColor && (
<button
className="clear-color"
onClick={() => setEditTagColor('')}
title="Remove color"
>
</button>
)}
</div>
<button onClick={handleSaveEdit} className="primary">Save</button>
<button onClick={() => setEditingTagId(null)}>Cancel</button>
</div>
) : (
<div className="tag-form-row">
<span className="tag-preview" style={
selectedTagObjects[0].color
? { backgroundColor: selectedTagObjects[0].color, color: getContrastColor(selectedTagObjects[0].color) }
: {}
}>
{selectedTagObjects[0].name}
</span>
<button onClick={() => handleStartEdit(selectedTagObjects[0])}>
Edit
</button>
<button
className="danger"
onClick={() => setDeleteConfirm({
tagId: selectedTagObjects[0].id,
tagName: selectedTagObjects[0].name
})}
>
Delete
</button>
</div>
)}
</div>
)}
</SectionHeader>
{/* Merge Tags Section */}
<SectionHeader
id="tags-section-merge"
title="Merge Tags"
description="Select multiple tags above, then merge them into a single tag. All posts will be updated."
>
{selectedTags.length < 2 ? (
<p className="text-muted">Select 2 or more tags from the cloud above to merge them.</p>
) : (
<div className="merge-form">
<p>Merge <strong>{selectedTags.length}</strong> tags into:</p>
<div className="tag-form-row">
<select
value={mergeTargetName}
onChange={(e) => setMergeTargetName(e.target.value)}
>
<option value="">Select target tag...</option>
{selectedTags.map(name => (
<option key={name} value={name}>{name}</option>
))}
</select>
<button
className="primary"
disabled={!mergeTargetName}
onClick={() => {
if (mergeTargetName) {
setMergeConfirm({
sourceNames: selectedTags.filter(n => n !== mergeTargetName),
targetName: mergeTargetName,
});
}
}}
>
Merge Tags
</button>
</div>
<p className="text-muted text-small">
Tags to be deleted: {selectedTags.filter(n => n !== mergeTargetName).join(', ') || '(none)'}
</p>
</div>
)}
</SectionHeader>
{/* Sync Section */}
<SectionHeader
id="tags-section-sync"
title="Sync Tags"
description="Discover tags that exist in posts but not in the tag database."
>
<button onClick={handleSyncFromPosts}>
Sync Tags from Posts
</button>
</SectionHeader>
</div>
{/* Confirm Dialogs */}
<ConfirmDialog
isOpen={!!deleteConfirm}
title="Delete Tag"
message={`Are you sure you want to delete the tag "${deleteConfirm?.tagName}"? This will remove it from all posts. This action runs as a background task.`}
confirmText="Delete Tag"
isDestructive
onConfirm={handleDeleteTag}
onCancel={() => setDeleteConfirm(null)}
/>
<ConfirmDialog
isOpen={!!mergeConfirm}
title="Merge Tags"
message={`Are you sure you want to merge ${mergeConfirm?.sourceNames.length} tag(s) into "${mergeConfirm?.targetName}"? The source tags will be deleted and all posts will be updated. This runs as a background task.`}
confirmText="Merge Tags"
onConfirm={handleMergeTags}
onCancel={() => setMergeConfirm(null)}
/>
</div>
);
};

View File

@@ -0,0 +1,2 @@
export { TagsView, scrollToTagsSection } from './TagsView';
export type { TagsCategory } from './TagsView';

View File

@@ -12,5 +12,6 @@ export { TaskPopup } from './TaskPopup';
export { ResizablePanel } from './ResizablePanel';
export { CredentialsPanel } from './CredentialsPanel';
export { SettingsView } from './SettingsView';
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
export { PostLinks } from './PostLinks';
export { ErrorModal, type ErrorDetails } from './ErrorModal';

View File

@@ -5,7 +5,7 @@ import { persist } from 'zustand/middleware';
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings';
export type TabType = 'post' | 'media' | 'settings' | 'tags';
export interface Tab {
type: TabType;
@@ -88,7 +88,7 @@ interface AppState {
activeTabId: string | null;
// UI State
activeView: 'posts' | 'media' | 'settings';
activeView: 'posts' | 'media' | 'settings' | 'tags';
sidebarVisible: boolean;
panelVisible: boolean;
selectedPostId: string | null;
@@ -136,7 +136,7 @@ interface AppState {
restoreTabState: (state: TabState) => void;
// Actions
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags') => void;
toggleSidebar: () => void;
togglePanel: () => void;
setSelectedPost: (id: string | null) => void;

View File

@@ -131,6 +131,45 @@ export interface CategoryCount {
count: number;
}
export interface TagData {
id: string;
projectId: string;
name: string;
color?: string;
createdAt: string;
updatedAt: string;
}
export interface TagWithCount {
name: string;
color: string | null;
count: number;
}
export interface DeleteTagResult {
success: boolean;
postsUpdated: number;
}
export interface MergeTagsResult {
success: boolean;
postsUpdated: number;
tagsDeleted: number;
targetTag: string;
}
export interface RenameTagResult {
success: boolean;
postsUpdated: number;
oldName: string;
newName: string;
}
export interface SyncTagsResult {
discovered: number;
added: string[];
}
export interface ElectronAPI {
projects: {
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
@@ -223,6 +262,19 @@ export interface ElectronAPI {
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string }) => Promise<ProjectMetadata | null>;
};
tags: {
getAll: () => Promise<TagData[]>;
getWithCounts: () => Promise<TagWithCount[]>;
get: (id: string) => Promise<TagData | null>;
getByName: (name: string) => Promise<TagData | null>;
create: (data: { name: string; color?: string }) => Promise<TagData>;
update: (id: string, data: { name?: string; color?: string | null }) => Promise<TagData | null>;
delete: (id: string) => Promise<DeleteTagResult>;
merge: (sourceTagIds: string[], targetTagId: string) => Promise<MergeTagsResult>;
rename: (id: string, newName: string) => Promise<RenameTagResult>;
getPostsWithTag: (tagId: string) => Promise<string[]>;
syncFromPosts: () => Promise<SyncTagsResult>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
}