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

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { searchApi, storyApi, tagApi } from '../../lib/api';
import { Story, Tag, FacetCount } from '../../types/api';
import AppLayout from '../../components/layout/AppLayout';
@@ -20,6 +20,7 @@ type SortOption = 'createdAt' | 'title' | 'authorName' | 'rating' | 'wordCount'
export default function LibraryPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { layout } = useLibraryLayout();
const [stories, setStories] = useState<Story[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
@@ -35,27 +36,95 @@ export default function LibraryPage() {
const [totalPages, setTotalPages] = useState(1);
const [totalElements, setTotalElements] = useState(0);
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [urlParamsProcessed, setUrlParamsProcessed] = useState(false);
// Convert facet counts to Tag objects for the UI
// Initialize filters from URL parameters
useEffect(() => {
const tagsParam = searchParams.get('tags');
if (tagsParam) {
console.log('URL tag filter detected:', tagsParam);
// Use functional updates to ensure all state changes happen together
setSelectedTags([tagsParam]);
setPage(0); // Reset to first page when applying URL filter
}
setUrlParamsProcessed(true);
}, [searchParams]);
// Convert facet counts to Tag objects for the UI, enriched with full tag data
const [fullTags, setFullTags] = useState<Tag[]>([]);
// Fetch full tag data for enrichment
useEffect(() => {
const fetchFullTags = async () => {
try {
const result = await tagApi.getTags({ size: 1000 }); // Get all tags
setFullTags(result.content || []);
} catch (error) {
console.error('Failed to fetch full tag data:', error);
setFullTags([]);
}
};
fetchFullTags();
}, []);
const convertFacetsToTags = (facets?: Record<string, FacetCount[]>): Tag[] => {
if (!facets || !facets.tagNames) {
return [];
}
return facets.tagNames.map(facet => ({
id: facet.value, // Use tag name as ID since we don't have actual IDs from search results
name: facet.value,
storyCount: facet.count
}));
return facets.tagNames.map(facet => {
// Find the full tag data by name
const fullTag = fullTags.find(tag => tag.name.toLowerCase() === facet.value.toLowerCase());
return {
id: fullTag?.id || facet.value, // Use actual ID if available, fallback to name
name: facet.value,
storyCount: facet.count,
// Include color and other metadata from the full tag data
color: fullTag?.color,
description: fullTag?.description,
aliasCount: fullTag?.aliasCount,
createdAt: fullTag?.createdAt,
aliases: fullTag?.aliases
};
});
};
// Enrich existing tags when fullTags are loaded
useEffect(() => {
if (fullTags.length > 0 && tags.length > 0) {
// Check if tags already have color data to avoid infinite loops
const hasColors = tags.some(tag => tag.color);
if (!hasColors) {
// Re-enrich existing tags with color data
const enrichedTags = tags.map(tag => {
const fullTag = fullTags.find(ft => ft.name.toLowerCase() === tag.name.toLowerCase());
return {
...tag,
color: fullTag?.color,
description: fullTag?.description,
aliasCount: fullTag?.aliasCount,
createdAt: fullTag?.createdAt,
aliases: fullTag?.aliases,
id: fullTag?.id || tag.id
};
});
setTags(enrichedTags);
}
}
}, [fullTags, tags]); // Run when fullTags or tags change
// Debounce search to avoid too many API calls
useEffect(() => {
// Don't run search until URL parameters have been processed
if (!urlParamsProcessed) return;
const debounceTimer = setTimeout(() => {
const performSearch = async () => {
try {
// Use searchLoading for background search, loading only for initial load
const isInitialLoad = stories.length === 0 && !searchQuery && selectedTags.length === 0;
const isInitialLoad = stories.length === 0 && !searchQuery;
if (isInitialLoad) {
setLoading(true);
} else {
@@ -63,7 +132,7 @@ export default function LibraryPage() {
}
// Always use search API for consistency - use '*' for match-all when no query
const result = await searchApi.search({
const apiParams = {
query: searchQuery.trim() || '*',
page: page, // Use 0-based pagination consistently
size: 20,
@@ -71,7 +140,10 @@ export default function LibraryPage() {
sortBy: sortOption,
sortDir: sortDirection,
facetBy: ['tagNames'], // Request tag facets for the filter UI
});
};
console.log('Performing search with params:', apiParams);
const result = await searchApi.search(apiParams);
const currentStories = result?.results || [];
setStories(currentStories);
@@ -96,7 +168,7 @@ export default function LibraryPage() {
}, searchQuery ? 500 : 0); // Debounce search queries, but load immediately for filters/pagination
return () => clearTimeout(debounceTimer);
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger]);
}, [searchQuery, selectedTags, sortOption, sortDirection, page, refreshTrigger, urlParamsProcessed]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
@@ -167,6 +239,7 @@ export default function LibraryPage() {
const layoutProps = {
stories,
tags,
totalElements,
searchQuery,
selectedTags,
viewMode,

View File

@@ -774,6 +774,21 @@ export default function SettingsPage() {
</div>
</div>
{/* Tag Management */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-xl font-semibold theme-header mb-4">Tag Management</h2>
<p className="theme-text mb-6">
Manage your story tags with colors, descriptions, and aliases. Use the Tag Maintenance page to organize and customize your tags.
</p>
<Button
href="/settings/tag-maintenance"
variant="secondary"
className="w-full sm:w-auto"
>
🏷️ Open Tag Maintenance
</Button>
</div>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button

View File

@@ -0,0 +1,519 @@
'use client';
import { useState, useEffect } from 'react';
import AppLayout from '../../../components/layout/AppLayout';
import { tagApi } from '../../../lib/api';
import { Tag } from '../../../types/api';
import Button from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input';
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
import TagDisplay from '../../../components/tags/TagDisplay';
import TagEditModal from '../../../components/tags/TagEditModal';
export default function TagMaintenancePage() {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<'name' | 'storyCount' | 'createdAt'>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
const [isMergeModalOpen, setIsMergeModalOpen] = useState(false);
const [mergeTargetTagId, setMergeTargetTagId] = useState<string>('');
const [mergePreview, setMergePreview] = useState<any>(null);
const [merging, setMerging] = useState(false);
useEffect(() => {
loadTags();
}, []);
const loadTags = async () => {
try {
setLoading(true);
const result = await tagApi.getTags({
page: 0,
size: 1000, // Load all tags for maintenance
sortBy,
sortDir: sortDirection
});
setTags(result.content || []);
} catch (error) {
console.error('Failed to load tags:', error);
setTags([]);
} finally {
setLoading(false);
}
};
const handleTagSave = (updatedTag: Tag) => {
if (selectedTag) {
// Update existing tag
setTags(prev => prev.map(tag =>
tag.id === updatedTag.id ? updatedTag : tag
));
} else {
// Add new tag
setTags(prev => [...prev, updatedTag]);
}
setSelectedTag(null);
setIsEditModalOpen(false);
setIsCreateModalOpen(false);
};
const handleTagDelete = (deletedTag: Tag) => {
setTags(prev => prev.filter(tag => tag.id !== deletedTag.id));
setSelectedTag(null);
setIsEditModalOpen(false);
};
const handleEditTag = (tag: Tag) => {
setSelectedTag(tag);
setIsEditModalOpen(true);
};
const handleCreateTag = () => {
setSelectedTag(null);
setIsCreateModalOpen(true);
};
const handleSortChange = (newSortBy: typeof sortBy) => {
if (newSortBy === sortBy) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(newSortBy);
setSortDirection('asc');
}
};
const handleTagSelection = (tagId: string, selected: boolean) => {
setSelectedTagIds(prev => {
const newSet = new Set(prev);
if (selected) {
newSet.add(tagId);
} else {
newSet.delete(tagId);
}
return newSet;
});
};
const handleSelectAll = (selected: boolean) => {
if (selected) {
setSelectedTagIds(new Set(filteredTags.map(tag => tag.id)));
} else {
setSelectedTagIds(new Set());
}
};
const handleMergeSelected = () => {
if (selectedTagIds.size < 2) {
alert('Please select at least 2 tags to merge');
return;
}
setIsMergeModalOpen(true);
};
const handleMergePreview = async () => {
if (!mergeTargetTagId || selectedTagIds.size < 2) return;
try {
const sourceTagIds = Array.from(selectedTagIds).filter(id => id !== mergeTargetTagId);
const preview = await tagApi.previewMerge(sourceTagIds, mergeTargetTagId);
setMergePreview(preview);
} catch (error) {
console.error('Failed to preview merge:', error);
alert('Failed to preview merge');
}
};
const handleConfirmMerge = async () => {
if (!mergeTargetTagId || selectedTagIds.size < 2) return;
try {
setMerging(true);
const sourceTagIds = Array.from(selectedTagIds).filter(id => id !== mergeTargetTagId);
await tagApi.mergeTags(sourceTagIds, mergeTargetTagId);
// Reload tags and reset state
await loadTags();
setSelectedTagIds(new Set());
setMergeTargetTagId('');
setMergePreview(null);
setIsMergeModalOpen(false);
} catch (error) {
console.error('Failed to merge tags:', error);
alert('Failed to merge tags');
} finally {
setMerging(false);
}
};
// Filter and sort tags
const filteredTags = tags
.filter(tag =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(tag.description && tag.description.toLowerCase().includes(searchQuery.toLowerCase()))
)
.sort((a, b) => {
let aValue, bValue;
switch (sortBy) {
case 'name':
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
break;
case 'storyCount':
aValue = a.storyCount || 0;
bValue = b.storyCount || 0;
break;
case 'createdAt':
aValue = new Date(a.createdAt || 0).getTime();
bValue = new Date(b.createdAt || 0).getTime();
break;
default:
return 0;
}
if (sortDirection === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
const getSortIcon = (column: typeof sortBy) => {
if (sortBy !== column) return '↕️';
return sortDirection === 'asc' ? '↑' : '↓';
};
const tagStats = {
total: tags.length,
withColors: tags.filter(tag => tag.color).length,
withDescriptions: tags.filter(tag => tag.description).length,
withAliases: tags.filter(tag => tag.aliasCount && tag.aliasCount > 0).length,
unused: tags.filter(tag => !tag.storyCount || tag.storyCount === 0).length
};
if (loading) {
return (
<AppLayout>
<div className="flex items-center justify-center py-20">
<LoadingSpinner size="lg" />
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold theme-header">Tag Maintenance</h1>
<p className="theme-text mt-2">
Manage tag colors, descriptions, and aliases for better organization
</p>
</div>
<div className="flex gap-3">
<Button href="/settings" variant="ghost">
Back to Settings
</Button>
<Button onClick={handleCreateTag} variant="primary">
+ Create Tag
</Button>
</div>
</div>
{/* Statistics */}
<div className="theme-card theme-shadow rounded-lg p-6">
<h2 className="text-lg font-semibold theme-header mb-4">Tag Statistics</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-center">
<div>
<div className="text-2xl font-bold theme-accent">{tagStats.total}</div>
<div className="text-sm theme-text">Total Tags</div>
</div>
<div>
<div className="text-2xl font-bold text-blue-600">{tagStats.withColors}</div>
<div className="text-sm theme-text">With Colors</div>
</div>
<div>
<div className="text-2xl font-bold text-green-600">{tagStats.withDescriptions}</div>
<div className="text-sm theme-text">With Descriptions</div>
</div>
<div>
<div className="text-2xl font-bold text-purple-600">{tagStats.withAliases}</div>
<div className="text-sm theme-text">With Aliases</div>
</div>
<div>
<div className="text-2xl font-bold text-gray-500">{tagStats.unused}</div>
<div className="text-sm theme-text">Unused</div>
</div>
</div>
</div>
{/* Controls */}
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex-1">
<Input
type="search"
placeholder="Search tags by name or description..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => handleSortChange('name')}
className="px-3 py-2 text-sm border theme-border rounded-lg theme-card theme-text hover:theme-accent transition-colors"
>
Name {getSortIcon('name')}
</button>
<button
onClick={() => handleSortChange('storyCount')}
className="px-3 py-2 text-sm border theme-border rounded-lg theme-card theme-text hover:theme-accent transition-colors"
>
Usage {getSortIcon('storyCount')}
</button>
<button
onClick={() => handleSortChange('createdAt')}
className="px-3 py-2 text-sm border theme-border rounded-lg theme-card theme-text hover:theme-accent transition-colors"
>
Date {getSortIcon('createdAt')}
</button>
</div>
</div>
</div>
{/* Tags List */}
<div className="theme-card theme-shadow rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold theme-header">
Tags ({filteredTags.length})
</h2>
<div className="flex gap-2">
{selectedTagIds.size > 0 && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedTagIds(new Set())}
>
Clear Selection ({selectedTagIds.size})
</Button>
<Button
variant="primary"
size="sm"
onClick={handleMergeSelected}
disabled={selectedTagIds.size < 2}
>
Merge Selected
</Button>
</>
)}
</div>
</div>
{filteredTags.length > 0 && (
<div className="mb-4 flex items-center gap-2">
<input
type="checkbox"
checked={filteredTags.length > 0 && selectedTagIds.size === filteredTags.length}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded"
/>
<label className="text-sm theme-text">Select All</label>
</div>
)}
{filteredTags.length === 0 ? (
<div className="text-center py-12">
<p className="theme-text text-lg mb-4">
{searchQuery ? 'No tags match your search.' : 'No tags found.'}
</p>
{!searchQuery && (
<Button onClick={handleCreateTag} variant="primary">
Create Your First Tag
</Button>
)}
</div>
) : (
<div className="space-y-3">
{filteredTags.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between p-4 border theme-border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="flex items-center gap-4 min-w-0 flex-1">
<input
type="checkbox"
checked={selectedTagIds.has(tag.id)}
onChange={(e) => handleTagSelection(tag.id, e.target.checked)}
className="rounded"
/>
<TagDisplay
tag={tag}
size="md"
showAliasesTooltip={true}
clickable={false}
/>
<div className="min-w-0 flex-1">
{tag.description && (
<p className="text-sm theme-text-muted mt-1 truncate">
{tag.description}
</p>
)}
<div className="flex gap-4 text-xs theme-text-muted mt-1">
<span>{tag.storyCount || 0} stories</span>
{tag.aliasCount && tag.aliasCount > 0 && (
<span>{tag.aliasCount} aliases</span>
)}
{tag.createdAt && (
<span>Created {new Date(tag.createdAt).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 ml-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTag(tag)}
>
Edit
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Edit Modal */}
<TagEditModal
tag={selectedTag || undefined}
isOpen={isEditModalOpen}
onClose={() => {
setIsEditModalOpen(false);
setSelectedTag(null);
}}
onSave={handleTagSave}
onDelete={handleTagDelete}
/>
{/* Create Modal */}
<TagEditModal
tag={undefined}
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSave={handleTagSave}
/>
{/* Merge Modal */}
{isMergeModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<h2 className="text-2xl font-bold theme-header mb-4">Merge Tags</h2>
<div className="space-y-4">
<div>
<p className="theme-text mb-2">
You have selected {selectedTagIds.size} tags to merge.
</p>
<p className="text-sm theme-text-muted mb-4">
Choose which tag should become the canonical name. All other tags will become aliases.
</p>
</div>
{/* Target Tag Selection */}
<div>
<label className="block text-sm font-medium theme-text mb-2">
Canonical Tag (keep this name):
</label>
<select
value={mergeTargetTagId}
onChange={(e) => {
setMergeTargetTagId(e.target.value);
setMergePreview(null);
}}
className="w-full p-2 border theme-border rounded-lg theme-card theme-text"
>
<option value="">Select canonical tag...</option>
{Array.from(selectedTagIds).map(tagId => {
const tag = tags.find(t => t.id === tagId);
return tag ? (
<option key={tagId} value={tagId}>
{tag.name} ({tag.storyCount || 0} stories)
</option>
) : null;
})}
</select>
</div>
{/* Preview Button */}
{mergeTargetTagId && (
<Button
onClick={handleMergePreview}
variant="secondary"
className="w-full"
>
Preview Merge
</Button>
)}
{/* Merge Preview */}
{mergePreview && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg p-4">
<h3 className="font-medium theme-header mb-2">Merge Preview</h3>
<div className="space-y-2 text-sm theme-text">
<p>
<strong>Result:</strong> "{mergePreview.targetTagName}" with {mergePreview.totalResultStoryCount} stories
</p>
{mergePreview.aliasesToCreate && mergePreview.aliasesToCreate.length > 0 && (
<div>
<strong>Aliases to create:</strong>
<ul className="ml-4 mt-1 list-disc">
{mergePreview.aliasesToCreate.map((alias: string) => (
<li key={alias}>{alias}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button
onClick={() => {
setIsMergeModalOpen(false);
setMergeTargetTagId('');
setMergePreview(null);
}}
variant="ghost"
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleConfirmMerge}
variant="primary"
disabled={!mergePreview || merging}
className="flex-1"
>
{merging ? 'Merging...' : 'Confirm Merge'}
</Button>
</div>
</div>
</div>
</div>
)}
</AppLayout>
);
}

View File

@@ -9,6 +9,7 @@ import { Story, Collection } from '../../../../types/api';
import AppLayout from '../../../../components/layout/AppLayout';
import Button from '../../../../components/ui/Button';
import LoadingSpinner from '../../../../components/ui/LoadingSpinner';
import TagDisplay from '../../../../components/tags/TagDisplay';
import { calculateReadingTime } from '../../../../lib/settings';
export default function StoryDetailPage() {
@@ -371,12 +372,12 @@ export default function StoryDetailPage() {
<h3 className="font-semibold theme-header mb-3">Tags</h3>
<div className="flex flex-wrap gap-2">
{story.tags.map((tag) => (
<span
<TagDisplay
key={tag.id}
className="px-3 py-1 text-sm rounded-full theme-accent-bg text-white"
>
{tag.name}
</span>
tag={tag}
size="md"
clickable={false}
/>
))}
</div>
</div>

View File

@@ -6,6 +6,7 @@ import AppLayout from '../../../../components/layout/AppLayout';
import { Input, Textarea } from '../../../../components/ui/Input';
import Button from '../../../../components/ui/Button';
import TagInput from '../../../../components/stories/TagInput';
import TagSuggestions from '../../../../components/tags/TagSuggestions';
import RichTextEditor from '../../../../components/stories/RichTextEditor';
import ImageUpload from '../../../../components/ui/ImageUpload';
import AuthorSelector from '../../../../components/stories/AuthorSelector';
@@ -94,6 +95,15 @@ export default function EditStoryPage() {
setFormData(prev => ({ ...prev, tags }));
};
const handleAddSuggestedTag = (tagName: string) => {
if (!formData.tags.includes(tagName.toLowerCase())) {
setFormData(prev => ({
...prev,
tags: [...prev.tags, tagName.toLowerCase()]
}));
}
};
const handleAuthorChange = (authorName: string, authorId?: string) => {
setFormData(prev => ({
...prev,
@@ -150,8 +160,8 @@ export default function EditStoryPage() {
summary: formData.summary || undefined,
contentHtml: formData.contentHtml,
sourceUrl: formData.sourceUrl || undefined,
volume: formData.seriesName ? parseInt(formData.volume) : undefined,
seriesName: formData.seriesName || undefined,
volume: formData.seriesName && formData.volume ? parseInt(formData.volume) : undefined,
seriesName: formData.seriesName, // Send empty string to explicitly clear series
// Send authorId if we have it (existing author), otherwise send authorName (new/changed author)
...(formData.authorId ? { authorId: formData.authorId } : { authorName: formData.authorName }),
tagNames: formData.tags,
@@ -301,6 +311,16 @@ export default function EditStoryPage() {
onChange={handleTagsChange}
placeholder="Edit tags to categorize your story..."
/>
{/* Tag Suggestions */}
<TagSuggestions
title={formData.title}
content={formData.contentHtml}
summary={formData.summary}
currentTags={formData.tags}
onAddTag={handleAddSuggestedTag}
disabled={saving}
/>
</div>
{/* Series and Volume */}

View File

@@ -8,6 +8,7 @@ import { Story } from '../../../types/api';
import LoadingSpinner from '../../../components/ui/LoadingSpinner';
import Button from '../../../components/ui/Button';
import StoryRating from '../../../components/stories/StoryRating';
import TagDisplay from '../../../components/tags/TagDisplay';
import { sanitizeHtml, preloadSanitizationConfig } from '../../../lib/sanitization';
export default function StoryReadingPage() {
@@ -314,12 +315,12 @@ export default function StoryReadingPage() {
{story.tags && story.tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mt-4">
{story.tags.map((tag) => (
<span
<TagDisplay
key={tag.id}
className="px-3 py-1 text-sm theme-accent-bg text-white rounded-full"
>
{tag.name}
</span>
tag={tag}
size="md"
clickable={false}
/>
))}
</div>
)}

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

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
import { AuthResponse, Story, Author, Tag, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
import { AuthResponse, Story, Author, Tag, TagAlias, Series, SearchResult, PagedResult, Collection, CollectionSearchResult, StoryWithCollectionContext, CollectionStatistics } from '../types/api';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
@@ -303,6 +303,33 @@ export const tagApi = {
return response.data;
},
getTag: async (id: string): Promise<Tag> => {
const response = await api.get(`/tags/${id}`);
return response.data;
},
createTag: async (tagData: {
name: string;
color?: string;
description?: string;
}): Promise<Tag> => {
const response = await api.post('/tags', tagData);
return response.data;
},
updateTag: async (id: string, tagData: {
name?: string;
color?: string;
description?: string;
}): Promise<Tag> => {
const response = await api.put(`/tags/${id}`, tagData);
return response.data;
},
deleteTag: async (id: string): Promise<void> => {
await api.delete(`/tags/${id}`);
},
getTagAutocomplete: async (query: string): Promise<string[]> => {
const response = await api.get('/tags/autocomplete', { params: { query } });
// Backend returns TagDto[], extract just the names
@@ -313,6 +340,76 @@ export const tagApi = {
const response = await api.get('/tags/collections');
return response.data;
},
// Alias operations
addAlias: async (tagId: string, aliasName: string): Promise<TagAlias> => {
const response = await api.post(`/tags/${tagId}/aliases`, { aliasName });
return response.data;
},
removeAlias: async (tagId: string, aliasId: string): Promise<void> => {
await api.delete(`/tags/${tagId}/aliases/${aliasId}`);
},
resolveTag: async (name: string): Promise<Tag | null> => {
try {
const response = await api.get(`/tags/resolve/${encodeURIComponent(name)}`);
return response.data;
} catch (error: any) {
if (error.response?.status === 404) {
return null;
}
throw error;
}
},
// Batch resolve multiple tag names to their canonical forms
resolveTags: async (names: string[]): Promise<string[]> => {
const resolved = await Promise.all(
names.map(async (name) => {
const tag = await tagApi.resolveTag(name);
return tag ? tag.name : name; // Return canonical name or original if not found
})
);
return resolved;
},
// Merge operations
previewMerge: async (sourceTagIds: string[], targetTagId: string): Promise<{
targetTagName: string;
targetStoryCount: number;
totalResultStoryCount: number;
aliasesToCreate: string[];
}> => {
const response = await api.post('/tags/merge/preview', {
sourceTagIds,
targetTagId
});
return response.data;
},
mergeTags: async (sourceTagIds: string[], targetTagId: string): Promise<Tag> => {
const response = await api.post('/tags/merge', {
sourceTagIds,
targetTagId
});
return response.data;
},
// Tag suggestions
suggestTags: async (title: string, content?: string, summary?: string, limit?: number): Promise<{
tagName: string;
confidence: number;
reason: string;
}[]> => {
const response = await api.post('/tags/suggest', {
title,
content,
summary,
limit: limit || 10
});
return response.data;
},
};
// Series endpoints
@@ -347,6 +444,18 @@ export const searchApi = {
sortDir?: string;
facetBy?: string[];
}): Promise<SearchResult> => {
// Resolve tag aliases to canonical names for expanded search
let resolvedTags = params.tags;
if (params.tags && params.tags.length > 0) {
try {
resolvedTags = await tagApi.resolveTags(params.tags);
} catch (error) {
console.warn('Failed to resolve tag aliases during search:', error);
// Fall back to original tags if resolution fails
resolvedTags = params.tags;
}
}
// Create URLSearchParams to properly handle array parameters
const searchParams = new URLSearchParams();
@@ -363,8 +472,8 @@ export const searchApi = {
if (params.authors && params.authors.length > 0) {
params.authors.forEach(author => searchParams.append('authors', author));
}
if (params.tags && params.tags.length > 0) {
params.tags.forEach(tag => searchParams.append('tags', tag));
if (resolvedTags && resolvedTags.length > 0) {
resolvedTags.forEach(tag => searchParams.append('tags', tag));
}
if (params.facetBy && params.facetBy.length > 0) {
params.facetBy.forEach(facet => searchParams.append('facetBy', facet));

View File

@@ -82,9 +82,11 @@ export class StoryScraper {
if (siteConfig.story.tags) {
const tagsResult = await this.extractTags($, siteConfig.story.tags, html, siteConfig.story.tagsAttribute);
if (Array.isArray(tagsResult)) {
story.tags = tagsResult;
// Resolve tag aliases to canonical names
story.tags = await this.resolveTagAliases(tagsResult);
} else if (typeof tagsResult === 'string' && tagsResult) {
story.tags = [tagsResult];
// Resolve tag aliases to canonical names
story.tags = await this.resolveTagAliases([tagsResult]);
}
}
@@ -379,4 +381,21 @@ export class StoryScraper {
return text;
}
private async resolveTagAliases(tags: string[]): Promise<string[]> {
try {
// Import the tagApi dynamically to avoid circular dependencies
const { tagApi } = await import('../api');
// Resolve all tags to their canonical names
const resolvedTags = await tagApi.resolveTags(tags);
// Filter out empty tags
return resolvedTags.filter(tag => tag && tag.trim().length > 0);
} catch (error) {
console.warn('Failed to resolve tag aliases during scraping:', error);
// Fall back to original tags if resolution fails
return tags.filter(tag => tag && tag.trim().length > 0);
}
}
}

View File

@@ -43,12 +43,25 @@ export interface AuthorUrl {
export interface Tag {
id: string;
name: string;
color?: string; // hex color like #3B82F6
description?: string;
storyCount?: number;
collectionCount?: number;
aliasCount?: number;
aliases?: TagAlias[];
createdAt?: string;
updatedAt?: string;
}
export interface TagAlias {
id: string;
aliasName: string;
canonicalTagId: string;
canonicalTag?: Tag;
createdFromMerge: boolean;
createdAt: string;
}
export interface Series {
id: string;
name: string;