Tag Enhancement + bugfixes

This commit is contained in:
Stefan Hardegger
2025-08-17 17:16:40 +02:00
parent 6b83783381
commit 1a99d9830d
34 changed files with 2996 additions and 97 deletions

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { StoryWithCollectionContext } from '../../types/api';
import { storyApi } from '../../lib/api';
import Button from '../ui/Button';
import TagDisplay from '../tags/TagDisplay';
import Link from 'next/link';
interface CollectionReadingViewProps {
@@ -255,12 +256,12 @@ export default function CollectionReadingView({
{story.tags && story.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{story.tags.map((tag) => (
<span
<TagDisplay
key={tag.id}
className="inline-block px-2 py-1 text-xs rounded-full theme-accent-bg text-white"
>
{tag.name}
</span>
tag={tag}
size="sm"
clickable={false}
/>
))}
</div>
)}

View File

@@ -3,11 +3,13 @@
import { useState } from 'react';
import { Input } from '../ui/Input';
import Button from '../ui/Button';
import TagDisplay from '../tags/TagDisplay';
import { Story, Tag } from '../../types/api';
interface MinimalLayoutProps {
stories: Story[];
tags: Tag[];
totalElements: number;
searchQuery: string;
selectedTags: string[];
viewMode: 'grid' | 'list';
@@ -26,6 +28,7 @@ interface MinimalLayoutProps {
export default function MinimalLayout({
stories,
tags,
totalElements,
searchQuery,
selectedTags,
viewMode,
@@ -41,8 +44,14 @@ export default function MinimalLayout({
children
}: MinimalLayoutProps) {
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
const [tagSearch, setTagSearch] = useState('');
const popularTags = tags.slice(0, 5);
// Filter tags based on search query
const filteredTags = tagSearch
? tags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
: tags;
const getSortDisplayText = () => {
const sortLabels: Record<string, string> = {
@@ -62,7 +71,7 @@ export default function MinimalLayout({
<div className="text-center mb-10">
<h1 className="text-4xl font-light theme-header mb-2">Story Library</h1>
<p className="theme-text text-lg mb-8">
Your personal collection of {stories.length} stories
Your personal collection of {totalElements} stories
</p>
<div>
<Button variant="primary" onClick={onRandomStory}>
@@ -139,17 +148,20 @@ export default function MinimalLayout({
All
</button>
{popularTags.map((tag) => (
<button
<div
key={tag.id}
onClick={() => onTagToggle(tag.name)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
selectedTags.includes(tag.name)
? 'bg-blue-500 text-white border-blue-500'
: 'bg-white dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
className={`cursor-pointer transition-all hover:scale-105 ${
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-2' : ''
}`}
>
{tag.name}
</button>
<TagDisplay
tag={tag}
size="md"
clickable={true}
className={`${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
/>
</div>
))}
</div>
<div>
@@ -173,7 +185,10 @@ export default function MinimalLayout({
<div className="flex justify-between items-center mb-5">
<h3 className="text-xl font-semibold theme-header">Browse All Tags</h3>
<button
onClick={() => setTagBrowserOpen(false)}
onClick={() => {
setTagBrowserOpen(false);
setTagSearch('');
}}
className="text-2xl theme-text hover:theme-accent transition-colors"
>
@@ -184,31 +199,48 @@ export default function MinimalLayout({
<Input
type="text"
placeholder="Search tags..."
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
className="w-full"
/>
</div>
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">
{tags.map((tag) => (
<button
{filteredTags.length === 0 && tagSearch ? (
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
No tags match "{tagSearch}"
</div>
) : (
filteredTags.map((tag) => (
<div
key={tag.id}
onClick={() => onTagToggle(tag.name)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors border text-left ${
selectedTags.includes(tag.name)
? 'bg-blue-500 text-white border-blue-500'
: 'bg-white dark:bg-gray-700 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
className={`cursor-pointer transition-all hover:scale-105 ${
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
}`}
>
{tag.name} ({tag.storyCount})
</button>
))}
<TagDisplay
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
size="sm"
clickable={true}
className={`w-full text-left ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 hover:border-blue-500'}`}
/>
</div>
))
)}
</div>
<div className="flex justify-end gap-3 mt-6">
<Button variant="ghost" onClick={() => setTagSearch('')}>
Clear Search
</Button>
<Button variant="ghost" onClick={onClearFilters}>
Clear All
</Button>
<Button variant="primary" onClick={() => setTagBrowserOpen(false)}>
<Button variant="primary" onClick={() => {
setTagBrowserOpen(false);
setTagSearch('');
}}>
Apply Filters
</Button>
</div>

View File

@@ -3,11 +3,13 @@
import { useState } from 'react';
import { Input } from '../ui/Input';
import Button from '../ui/Button';
import TagDisplay from '../tags/TagDisplay';
import { Story, Tag } from '../../types/api';
interface SidebarLayoutProps {
stories: Story[];
tags: Tag[];
totalElements: number;
searchQuery: string;
selectedTags: string[];
viewMode: 'grid' | 'list';
@@ -26,6 +28,7 @@ interface SidebarLayoutProps {
export default function SidebarLayout({
stories,
tags,
totalElements,
searchQuery,
selectedTags,
viewMode,
@@ -40,6 +43,13 @@ export default function SidebarLayout({
onClearFilters,
children
}: SidebarLayoutProps) {
const [tagSearch, setTagSearch] = useState('');
// Filter tags based on search query
const filteredTags = tags.filter(tag =>
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
);
return (
<div className="flex min-h-screen">
{/* Left Sidebar */}
@@ -58,7 +68,7 @@ export default function SidebarLayout({
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold theme-header">Your Library</h1>
<p className="theme-text mt-1">{stories.length} stories total</p>
<p className="theme-text mt-1">{totalElements} stories total</p>
</div>
{/* Search */}
@@ -125,6 +135,8 @@ export default function SidebarLayout({
<input
type="text"
placeholder="Search tags..."
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
className="w-full px-2 py-1 text-xs border rounded theme-card border-gray-300 dark:border-gray-600"
/>
</div>
@@ -136,9 +148,9 @@ export default function SidebarLayout({
checked={selectedTags.length === 0}
onChange={() => onClearFilters()}
/>
<span className="text-xs">All Stories ({stories.length})</span>
<span className="text-xs">All Stories ({totalElements})</span>
</label>
{tags.map((tag) => (
{filteredTags.map((tag) => (
<label
key={tag.id}
className="flex items-center gap-2 py-1 cursor-pointer"
@@ -148,14 +160,27 @@ export default function SidebarLayout({
checked={selectedTags.includes(tag.name)}
onChange={() => onTagToggle(tag.name)}
/>
<span className="text-xs">
{tag.name} ({tag.storyCount})
</span>
<div className="flex items-center gap-2 flex-1 min-w-0">
<TagDisplay
tag={tag}
size="sm"
clickable={false}
className="flex-shrink-0"
/>
<span className="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">
({tag.storyCount})
</span>
</div>
</label>
))}
{tags.length > 10 && (
{filteredTags.length === 0 && tagSearch && (
<div className="text-center text-xs text-gray-500 py-2">
... and {tags.length - 10} more tags
No tags match "{tagSearch}"
</div>
)}
{filteredTags.length > 10 && !tagSearch && (
<div className="text-center text-xs text-gray-500 py-2">
... and {filteredTags.length - 10} more tags
</div>
)}
</div>

View File

@@ -3,11 +3,13 @@
import { useState } from 'react';
import { Input } from '../ui/Input';
import Button from '../ui/Button';
import TagDisplay from '../tags/TagDisplay';
import { Story, Tag } from '../../types/api';
interface ToolbarLayoutProps {
stories: Story[];
tags: Tag[];
totalElements: number;
searchQuery: string;
selectedTags: string[];
viewMode: 'grid' | 'list';
@@ -26,6 +28,7 @@ interface ToolbarLayoutProps {
export default function ToolbarLayout({
stories,
tags,
totalElements,
searchQuery,
selectedTags,
viewMode,
@@ -41,9 +44,17 @@ export default function ToolbarLayout({
children
}: ToolbarLayoutProps) {
const [tagSearchExpanded, setTagSearchExpanded] = useState(false);
const [tagSearch, setTagSearch] = useState('');
const popularTags = tags.slice(0, 6);
const remainingTagsCount = Math.max(0, tags.length - 6);
// Filter remaining tags based on search query
const remainingTags = tags.slice(6);
const filteredRemainingTags = tagSearch
? remainingTags.filter(tag => tag.name.toLowerCase().includes(tagSearch.toLowerCase()))
: remainingTags;
const remainingTagsCount = Math.max(0, remainingTags.length);
return (
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
@@ -53,7 +64,7 @@ export default function ToolbarLayout({
<div className="flex justify-between items-start mb-6 max-md:flex-col max-md:gap-4">
<div>
<h1 className="text-3xl font-bold theme-header">Your Story Library</h1>
<p className="theme-text mt-1">{stories.length} stories in your collection</p>
<p className="theme-text mt-1">{totalElements} stories in your collection</p>
</div>
<div className="max-md:self-end">
<Button variant="secondary" onClick={onRandomStory}>
@@ -142,17 +153,20 @@ export default function ToolbarLayout({
All Stories
</button>
{popularTags.map((tag) => (
<button
<div
key={tag.id}
onClick={() => onTagToggle(tag.name)}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
selectedTags.includes(tag.name)
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
className={`cursor-pointer transition-all hover:scale-105 ${
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
}`}
>
{tag.name} ({tag.storyCount})
</button>
<TagDisplay
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
size="sm"
clickable={true}
className={selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}
/>
</div>
))}
{remainingTagsCount > 0 && (
<button
@@ -163,7 +177,7 @@ export default function ToolbarLayout({
</button>
)}
<div className="ml-auto text-sm theme-text">
Showing {stories.length} stories
Showing {stories.length} of {totalElements} stories
</div>
</div>
@@ -174,9 +188,15 @@ export default function ToolbarLayout({
<Input
type="text"
placeholder="Search from all available tags..."
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
className="flex-1"
/>
<Button variant="secondary">Search</Button>
{tagSearch && (
<Button variant="ghost" onClick={() => setTagSearch('')}>
Clear
</Button>
)}
<Button
variant="ghost"
onClick={() => setTagSearchExpanded(false)}
@@ -185,19 +205,28 @@ export default function ToolbarLayout({
</Button>
</div>
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
{tags.slice(6).map((tag) => (
<button
{filteredRemainingTags.length === 0 && tagSearch ? (
<div className="col-span-4 text-center text-sm text-gray-500 py-4">
No tags match "{tagSearch}"
</div>
) : (
filteredRemainingTags.map((tag) => (
<div
key={tag.id}
onClick={() => onTagToggle(tag.name)}
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
selectedTags.includes(tag.name)
? 'bg-blue-500 text-white'
: 'bg-white dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
className={`cursor-pointer transition-all hover:scale-105 ${
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
}`}
>
{tag.name} ({tag.storyCount})
</button>
))}
<TagDisplay
tag={{...tag, name: `${tag.name} (${tag.storyCount})`}}
size="sm"
clickable={true}
className={`w-full ${selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : ''}`}
/>
</div>
))
)}
</div>
</div>
)}

View File

@@ -43,11 +43,27 @@ export default function TagInput({ tags, onChange, placeholder = 'Add tags...' }
return () => clearTimeout(debounce);
}, [inputValue, tags]);
const addTag = (tag: string) => {
const addTag = async (tag: string) => {
const trimmedTag = tag.trim().toLowerCase();
if (trimmedTag && !tags.includes(trimmedTag)) {
onChange([...tags, trimmedTag]);
if (!trimmedTag) return;
try {
// Resolve tag alias to canonical name
const resolvedTag = await tagApi.resolveTag(trimmedTag);
const finalTag = resolvedTag ? resolvedTag.name.toLowerCase() : trimmedTag;
// Only add if not already present
if (!tags.includes(finalTag)) {
onChange([...tags, finalTag]);
}
} catch (error) {
console.warn('Failed to resolve tag alias:', error);
// Fall back to original tag if resolution fails
if (!tags.includes(trimmedTag)) {
onChange([...tags, trimmedTag]);
}
}
setInputValue('');
setShowSuggestions(false);
setActiveSuggestionIndex(-1);

View File

@@ -0,0 +1,104 @@
'use client';
import { useState } from 'react';
import { Tag } from '../../types/api';
interface TagDisplayProps {
tag: Tag;
size?: 'sm' | 'md' | 'lg';
showAliasesTooltip?: boolean;
clickable?: boolean;
onClick?: (tag: Tag) => void;
className?: string;
}
export default function TagDisplay({
tag,
size = 'md',
showAliasesTooltip = true,
clickable = false,
onClick,
className = ''
}: TagDisplayProps) {
const [showTooltip, setShowTooltip] = useState(false);
const sizeClasses = {
sm: 'px-2 py-1 text-xs',
md: 'px-3 py-1 text-sm',
lg: 'px-4 py-2 text-base'
};
const baseClasses = `
inline-flex items-center gap-1 rounded-full font-medium transition-all
${sizeClasses[size]}
${clickable ? 'cursor-pointer hover:scale-105' : ''}
${className}
`;
// Determine tag styling based on color
const tagStyle = tag.color ? {
backgroundColor: tag.color + '20', // Add 20% opacity
borderColor: tag.color,
color: tag.color
} : {};
const defaultClasses = !tag.color ?
'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border border-gray-300 dark:border-gray-600' :
'border';
const handleClick = () => {
if (clickable && onClick) {
onClick(tag);
}
};
const handleMouseEnter = () => {
if (showAliasesTooltip && tag.aliases && tag.aliases.length > 0) {
setShowTooltip(true);
}
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
return (
<div className="relative inline-block">
<span
className={`${baseClasses} ${defaultClasses}`}
style={tag.color ? tagStyle : {}}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
title={tag.description || undefined}
>
{tag.name}
{(tag.aliasCount ?? 0) > 0 && (
<span className="text-xs opacity-75">+{tag.aliasCount}</span>
)}
</span>
{/* Tooltip for aliases */}
{showTooltip && showAliasesTooltip && tag.aliases && tag.aliases.length > 0 && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 z-50">
<div className="bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-xs rounded-lg px-3 py-2 max-w-xs">
<div className="font-medium mb-1">{tag.name}</div>
<div className="border-t border-gray-700 dark:border-gray-300 pt-1">
<div className="text-gray-300 dark:text-gray-600 mb-1">Aliases:</div>
{tag.aliases.map((alias, index) => (
<div key={alias.id} className="text-gray-100 dark:text-gray-800">
{alias.aliasName}
{index < tag.aliases!.length - 1 && ', '}
</div>
))}
</div>
{/* Tooltip arrow */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2">
<div className="border-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,324 @@
'use client';
import { useState, useEffect } from 'react';
import { Tag, TagAlias } from '../../types/api';
import { tagApi } from '../../lib/api';
import { Input, Textarea } from '../ui/Input';
import Button from '../ui/Button';
import ColorPicker from '../ui/ColorPicker';
interface TagEditModalProps {
tag?: Tag;
isOpen: boolean;
onClose: () => void;
onSave: (tag: Tag) => void;
onDelete?: (tag: Tag) => void;
}
export default function TagEditModal({ tag, isOpen, onClose, onSave, onDelete }: TagEditModalProps) {
const [formData, setFormData] = useState({
name: '',
color: '',
description: ''
});
const [aliases, setAliases] = useState<TagAlias[]>([]);
const [newAlias, setNewAlias] = useState('');
const [loading, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [deleteConfirm, setDeleteConfirm] = useState(false);
// Reset form when modal opens/closes or tag changes
useEffect(() => {
if (isOpen && tag) {
setFormData({
name: tag.name || '',
color: tag.color || '',
description: tag.description || ''
});
setAliases(tag.aliases || []);
} else if (isOpen && !tag) {
// New tag
setFormData({
name: '',
color: '',
description: ''
});
setAliases([]);
}
setNewAlias('');
setErrors({});
setDeleteConfirm(false);
}, [isOpen, tag]);
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
const handleAddAlias = async () => {
if (!newAlias.trim() || !tag) return;
try {
// Check if alias already exists
if (aliases.some(alias => alias.aliasName.toLowerCase() === newAlias.toLowerCase())) {
setErrors({ alias: 'This alias already exists for this tag' });
return;
}
// Create alias via API
const newAliasData = await tagApi.addAlias(tag.id, newAlias.trim());
setAliases(prev => [...prev, newAliasData]);
setNewAlias('');
setErrors(prev => ({ ...prev, alias: '' }));
} catch (error) {
setErrors({ alias: 'Failed to add alias' });
}
};
const handleRemoveAlias = async (aliasId: string) => {
if (!tag) return;
try {
await tagApi.removeAlias(tag.id, aliasId);
setAliases(prev => prev.filter(alias => alias.id !== aliasId));
} catch (error) {
console.error('Failed to remove alias:', error);
}
};
const handleSave = async () => {
setErrors({});
setSaving(true);
try {
const payload = {
name: formData.name.trim(),
color: formData.color || undefined,
description: formData.description || undefined
};
let savedTag: Tag;
if (tag) {
// Update existing tag
savedTag = await tagApi.updateTag(tag.id, payload);
} else {
// Create new tag
savedTag = await tagApi.createTag(payload);
}
// Include aliases in the saved tag
savedTag.aliases = aliases;
onSave(savedTag);
onClose();
} catch (error: any) {
setErrors({ submit: error.message });
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!tag || !onDelete) return;
try {
setSaving(true);
await tagApi.deleteTag(tag.id);
onDelete(tag);
onClose();
} catch (error: any) {
setErrors({ submit: error.message });
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b theme-border">
<h2 className="text-xl font-semibold theme-header">
{tag ? `Edit Tag: "${tag.name}"` : 'Create New Tag'}
</h2>
</div>
<div className="p-6 space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<Input
label="Tag Name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={errors.name}
disabled={loading}
placeholder="Enter tag name"
required
/>
<ColorPicker
label="Color (Optional)"
value={formData.color}
onChange={(color) => handleInputChange('color', color || '')}
disabled={loading}
/>
<Textarea
label="Description (Optional)"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
error={errors.description}
disabled={loading}
placeholder="Optional description for this tag"
rows={3}
/>
</div>
{/* Aliases Section (only for existing tags) */}
{tag && (
<div className="space-y-4">
<h3 className="text-lg font-medium theme-header">
Aliases ({aliases.length})
</h3>
{aliases.length > 0 && (
<div className="space-y-2 max-h-32 overflow-y-auto border theme-border rounded-lg p-3">
{aliases.map((alias) => (
<div key={alias.id} className="flex items-center justify-between py-1">
<span className="text-sm theme-text">
{alias.aliasName}
{alias.createdFromMerge && (
<span className="ml-2 text-xs theme-text-muted">(from merge)</span>
)}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAlias(alias.id)}
disabled={loading}
className="text-xs text-red-600 hover:text-red-800"
>
Remove
</Button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Input
value={newAlias}
onChange={(e) => setNewAlias(e.target.value)}
placeholder="Add new alias"
error={errors.alias}
disabled={loading}
onKeyPress={(e) => e.key === 'Enter' && handleAddAlias()}
/>
<Button
variant="secondary"
onClick={handleAddAlias}
disabled={loading || !newAlias.trim()}
>
Add
</Button>
</div>
</div>
)}
{/* Story Information (for existing tags) */}
{tag && (
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h3 className="text-sm font-medium theme-header mb-2">Usage Statistics</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="theme-text-muted">Stories:</span>
<span className="ml-2 font-medium">{tag.storyCount || 0}</span>
</div>
<div>
<span className="theme-text-muted">Collections:</span>
<span className="ml-2 font-medium">{tag.collectionCount || 0}</span>
</div>
</div>
{tag.storyCount && tag.storyCount > 0 && (
<Button
variant="ghost"
size="sm"
className="mt-2 text-xs"
onClick={() => window.open(`/library?tags=${encodeURIComponent(tag.name)}`, '_blank')}
>
View Stories
</Button>
)}
</div>
)}
{/* Error Display */}
{errors.submit && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{errors.submit}</p>
</div>
)}
</div>
{/* Actions */}
<div className="p-6 border-t theme-border flex justify-between">
<div className="flex gap-2">
{tag && onDelete && (
<>
{!deleteConfirm ? (
<Button
variant="ghost"
onClick={() => setDeleteConfirm(true)}
disabled={loading}
className="text-red-600 hover:text-red-800"
>
Delete Tag
</Button>
) : (
<div className="flex gap-2">
<Button
variant="ghost"
onClick={() => setDeleteConfirm(false)}
disabled={loading}
className="text-sm"
>
Cancel
</Button>
<Button
variant="ghost"
onClick={handleDelete}
disabled={loading}
className="text-sm bg-red-600 text-white hover:bg-red-700"
>
{loading ? 'Deleting...' : 'Confirm Delete'}
</Button>
</div>
)}
</>
)}
</div>
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onClose}
disabled={loading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={loading || !formData.name.trim()}
>
{loading ? 'Saving...' : (tag ? 'Save Changes' : 'Create Tag')}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,146 @@
'use client';
import { useState, useEffect } from 'react';
import { tagApi } from '../../lib/api';
import Button from '../ui/Button';
import LoadingSpinner from '../ui/LoadingSpinner';
interface TagSuggestion {
tagName: string;
confidence: number;
reason: string;
}
interface TagSuggestionsProps {
title: string;
content?: string;
summary?: string;
currentTags: string[];
onAddTag: (tagName: string) => void;
disabled?: boolean;
}
export default function TagSuggestions({
title,
content,
summary,
currentTags,
onAddTag,
disabled = false
}: TagSuggestionsProps) {
const [suggestions, setSuggestions] = useState<TagSuggestion[]>([]);
const [loading, setLoading] = useState(false);
const [lastAnalyzed, setLastAnalyzed] = useState<string>('');
useEffect(() => {
const analyzeContent = async () => {
// Only analyze if we have meaningful content and it has changed
const contentKey = `${title}|${summary}`;
if (!title.trim() || contentKey === lastAnalyzed || disabled) {
return;
}
setLoading(true);
try {
const tagSuggestions = await tagApi.suggestTags(title, content, summary, 8);
// Filter out suggestions that are already selected
const filteredSuggestions = tagSuggestions.filter(
suggestion => !currentTags.some(tag =>
tag.toLowerCase() === suggestion.tagName.toLowerCase()
)
);
setSuggestions(filteredSuggestions);
setLastAnalyzed(contentKey);
} catch (error) {
console.error('Failed to get tag suggestions:', error);
setSuggestions([]);
} finally {
setLoading(false);
}
};
// Debounce the analysis
const debounce = setTimeout(analyzeContent, 1000);
return () => clearTimeout(debounce);
}, [title, content, summary, currentTags, lastAnalyzed, disabled]);
const handleAddTag = (tagName: string) => {
onAddTag(tagName);
// Remove the added tag from suggestions
setSuggestions(prev => prev.filter(s => s.tagName !== tagName));
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 0.7) return 'text-green-600 dark:text-green-400';
if (confidence >= 0.5) return 'text-yellow-600 dark:text-yellow-400';
return 'text-gray-600 dark:text-gray-400';
};
const getConfidenceLabel = (confidence: number) => {
if (confidence >= 0.7) return 'High';
if (confidence >= 0.5) return 'Medium';
return 'Low';
};
if (disabled || (!title.trim() && !summary?.trim())) {
return null;
}
return (
<div className="mt-4">
<div className="flex items-center gap-2 mb-3">
<h3 className="text-sm font-medium theme-text">Suggested Tags</h3>
{loading && <LoadingSpinner size="sm" />}
</div>
{suggestions.length === 0 && !loading ? (
<p className="text-sm theme-text-muted">
{title.trim() ? 'No tag suggestions found for this content' : 'Enter a title to get tag suggestions'}
</p>
) : (
<div className="space-y-2">
{suggestions.map((suggestion) => (
<div
key={suggestion.tagName}
className="flex items-center justify-between p-3 border theme-border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium theme-text">{suggestion.tagName}</span>
<span className={`text-xs px-2 py-1 rounded-full border ${getConfidenceColor(suggestion.confidence)}`}>
{getConfidenceLabel(suggestion.confidence)}
</span>
</div>
<p className="text-xs theme-text-muted mt-1">{suggestion.reason}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleAddTag(suggestion.tagName)}
className="ml-3"
>
Add
</Button>
</div>
))}
</div>
)}
{suggestions.length > 0 && (
<div className="mt-3 flex justify-center">
<Button
variant="ghost"
size="sm"
onClick={() => {
suggestions.forEach(s => handleAddTag(s.tagName));
}}
>
Add All Suggestions
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import Button from './Button';
interface ColorPickerProps {
value?: string;
onChange: (color: string | undefined) => void;
disabled?: boolean;
label?: string;
}
// Theme-compatible color palette
const THEME_COLORS = [
// Primary blues
{ hex: '#3B82F6', name: 'Theme Blue' },
{ hex: '#1D4ED8', name: 'Deep Blue' },
{ hex: '#60A5FA', name: 'Light Blue' },
// Greens
{ hex: '#10B981', name: 'Emerald' },
{ hex: '#059669', name: 'Forest Green' },
{ hex: '#34D399', name: 'Light Green' },
// Purples
{ hex: '#8B5CF6', name: 'Purple' },
{ hex: '#7C3AED', name: 'Deep Purple' },
{ hex: '#A78BFA', name: 'Light Purple' },
// Warm tones
{ hex: '#F59E0B', name: 'Amber' },
{ hex: '#D97706', name: 'Orange' },
{ hex: '#F97316', name: 'Bright Orange' },
// Reds/Pinks
{ hex: '#EF4444', name: 'Red' },
{ hex: '#F472B6', name: 'Pink' },
{ hex: '#EC4899', name: 'Hot Pink' },
// Neutrals
{ hex: '#6B7280', name: 'Gray' },
{ hex: '#4B5563', name: 'Dark Gray' },
{ hex: '#9CA3AF', name: 'Light Gray' }
];
export default function ColorPicker({ value, onChange, disabled, label }: ColorPickerProps) {
const [showCustomPicker, setShowCustomPicker] = useState(false);
const [customColor, setCustomColor] = useState(value || '#3B82F6');
const handleThemeColorSelect = (color: string) => {
onChange(color);
setShowCustomPicker(false);
};
const handleCustomColorChange = (color: string) => {
setCustomColor(color);
onChange(color);
};
const handleRemoveColor = () => {
onChange(undefined);
setShowCustomPicker(false);
};
return (
<div className="space-y-3">
{label && (
<label className="block text-sm font-medium theme-header">
{label}
</label>
)}
{/* Current Color Display */}
{value && (
<div className="flex items-center gap-2 p-2 border theme-border rounded-lg">
<div
className="w-6 h-6 rounded border border-gray-300 dark:border-gray-600"
style={{ backgroundColor: value }}
/>
<span className="text-sm theme-text font-mono">{value}</span>
<Button
variant="ghost"
size="sm"
onClick={handleRemoveColor}
disabled={disabled}
className="ml-auto text-xs"
>
Remove
</Button>
</div>
)}
{/* Theme Color Palette */}
<div className="space-y-2">
<h4 className="text-sm font-medium theme-header">Theme Colors</h4>
<div className="grid grid-cols-6 gap-2 p-3 border theme-border rounded-lg">
{THEME_COLORS.map((color) => (
<button
key={color.hex}
type="button"
className={`
w-8 h-8 rounded-md border-2 transition-all hover:scale-110 focus:outline-none focus:ring-2 focus:ring-theme-accent
${value === color.hex ? 'border-gray-800 dark:border-white scale-110' : 'border-gray-300 dark:border-gray-600'}
`}
style={{ backgroundColor: color.hex }}
onClick={() => handleThemeColorSelect(color.hex)}
disabled={disabled}
title={color.name}
/>
))}
</div>
</div>
{/* Custom Color Section */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium theme-header">Custom Color</h4>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCustomPicker(!showCustomPicker)}
disabled={disabled}
className="text-xs"
>
{showCustomPicker ? 'Hide' : 'Show'} Custom
</Button>
</div>
{showCustomPicker && (
<div className="p-3 border theme-border rounded-lg space-y-3">
<div className="flex items-center gap-3">
<input
type="color"
value={customColor}
onChange={(e) => handleCustomColorChange(e.target.value)}
disabled={disabled}
className="w-12 h-8 rounded border border-gray-300 dark:border-gray-600 cursor-pointer disabled:cursor-not-allowed"
/>
<input
type="text"
value={customColor}
onChange={(e) => {
const color = e.target.value;
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
setCustomColor(color);
onChange(color);
}
}}
disabled={disabled}
className="flex-1 px-3 py-1 text-sm border theme-border rounded font-mono"
placeholder="#3B82F6"
/>
<Button
variant="primary"
size="sm"
onClick={() => onChange(customColor)}
disabled={disabled}
className="text-xs"
>
Apply
</Button>
</div>
<p className="text-xs theme-text-muted">
Enter a hex color code or use the color picker
</p>
</div>
)}
</div>
</div>
);
}