Tag Enhancement + bugfixes
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
519
frontend/src/app/settings/tag-maintenance/page.tsx
Normal file
519
frontend/src/app/settings/tag-maintenance/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user