Files
storycove/frontend/src/components/library/ToolbarLayout.tsx
Stefan Hardegger 1f41974208 ff
2025-09-22 12:43:05 +02:00

362 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState } from 'react';
import { Input } from '../ui/Input';
import Button from '../ui/Button';
import TagDisplay from '../tags/TagDisplay';
import AdvancedFilters from './AdvancedFilters';
import { Story, Tag, AdvancedFilters as AdvancedFiltersType } from '../../types/api';
interface ToolbarLayoutProps {
stories: Story[];
tags: Tag[];
totalElements: number;
searchQuery: string;
selectedTags: string[];
viewMode: 'grid' | 'list';
sortOption: string;
sortDirection: 'asc' | 'desc';
advancedFilters?: AdvancedFiltersType;
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onTagToggle: (tagName: string) => void;
onViewModeChange: (mode: 'grid' | 'list') => void;
onSortChange: (option: string) => void;
onSortDirectionToggle: () => void;
onAdvancedFiltersChange?: (filters: AdvancedFiltersType) => void;
onRandomStory: () => void;
onClearFilters: () => void;
children: React.ReactNode;
}
export default function ToolbarLayout({
stories,
tags,
totalElements,
searchQuery,
selectedTags,
viewMode,
sortOption,
sortDirection,
advancedFilters = {},
onSearchChange,
onTagToggle,
onViewModeChange,
onSortChange,
onSortDirectionToggle,
onAdvancedFiltersChange,
onRandomStory,
onClearFilters,
children
}: ToolbarLayoutProps) {
const [filterExpanded, setFilterExpanded] = useState(false);
const [activeTab, setActiveTab] = useState<'tags' | 'advanced'>('tags');
const [tagSearch, setTagSearch] = useState('');
const popularTags = tags.slice(0, 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);
// Count active advanced filters
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
value !== undefined && value !== '' && value !== 'all' && value !== false
).length;
return (
<div className="max-w-7xl mx-auto p-6 max-md:p-4">
{/* Integrated Header */}
<div className="theme-card theme-shadow rounded-xl p-6 mb-6 relative max-md:p-4">
{/* Title and Random Story Button */}
<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">{totalElements} stories in your collection</p>
</div>
<div className="max-md:self-end">
<Button variant="secondary" onClick={onRandomStory}>
🎲 Random Story
</Button>
</div>
</div>
{/* Integrated Toolbar */}
<div className="grid grid-cols-4 gap-5 items-center mb-5 max-md:grid-cols-1 max-md:gap-3">
{/* Search */}
<div className="col-span-2 max-md:col-span-1">
<Input
type="search"
placeholder="Search by title, author, or tags..."
value={searchQuery}
onChange={onSearchChange}
className="w-full"
/>
</div>
{/* Sort */}
<div className="max-md:order-3">
<select
value={`${sortOption}_${sortDirection}`}
onChange={(e) => {
const [option, direction] = e.target.value.split('_');
onSortChange(option);
if (sortDirection !== direction) {
onSortDirectionToggle();
}
}}
className="w-full px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600 max-md:text-sm"
>
<option value="lastReadAt_desc">Sort: Last Read </option>
<option value="lastReadAt_asc">Sort: Last Read </option>
<option value="createdAt_desc">Sort: Date Added </option>
<option value="createdAt_asc">Sort: Date Added </option>
<option value="title_asc">Sort: Title </option>
<option value="title_desc">Sort: Title </option>
<option value="authorName_asc">Sort: Author </option>
<option value="authorName_desc">Sort: Author </option>
<option value="rating_desc">Sort: Rating </option>
<option value="rating_asc">Sort: Rating </option>
</select>
</div>
{/* View Toggle, Advanced Filters & Clear */}
<div className="flex gap-2 max-md:order-2">
<div className="flex border theme-border rounded-lg overflow-hidden">
<Button
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
onClick={() => onViewModeChange('grid')}
className="rounded-none border-0 max-md:px-2 max-md:text-sm"
>
<span className="max-md:hidden"> Grid</span>
<span className="md:hidden"></span>
</Button>
<Button
variant={viewMode === 'list' ? 'primary' : 'ghost'}
onClick={() => onViewModeChange('list')}
className="rounded-none border-0 max-md:px-2 max-md:text-sm"
>
<span className="max-md:hidden"> List</span>
<span className="md:hidden"></span>
</Button>
</div>
{/* Advanced Filters Button */}
<Button
variant={filterExpanded && activeTab === 'advanced' ? 'primary' : 'ghost'}
onClick={() => {
if (!filterExpanded) {
// Panel closed → open and switch to advanced tab
setFilterExpanded(true);
setActiveTab('advanced');
} else if (activeTab !== 'advanced') {
// Panel open but wrong tab → just switch to advanced tab
setActiveTab('advanced');
} else {
// Panel open and on advanced tab → close panel
setFilterExpanded(false);
}
}}
className="max-md:text-sm max-md:px-2"
>
<span className="max-md:hidden"> Advanced</span>
<span className="md:hidden"></span>
{activeAdvancedFiltersCount > 0 && (
<span className="ml-1 text-xs bg-blue-500 text-white px-1.5 py-0.5 rounded font-bold max-md:ml-0.5 max-md:px-1">
{activeAdvancedFiltersCount}
</span>
)}
</Button>
{(searchQuery || selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
<Button variant="ghost" onClick={onClearFilters} className="max-md:text-sm max-md:px-2">
<span className="max-md:hidden">Clear</span>
<span className="md:hidden"></span>
</Button>
)}
</div>
</div>
{/* Filter Section */}
<div className="border-t theme-border pt-5">
{/* Top row - Popular tags and expand button */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="font-medium theme-text text-sm">Popular Tags:</span>
<button
onClick={() => onClearFilters()}
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
selectedTags.length === 0 && activeAdvancedFiltersCount === 0
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
}`}
>
All Stories
</button>
{popularTags.map((tag) => (
<div
key={tag.id}
onClick={() => onTagToggle(tag.name)}
className={`cursor-pointer transition-all hover:scale-105 ${
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
}`}
>
<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>
))}
{/* More Tags Button */}
{remainingTagsCount > 0 && (
<button
onClick={() => {
if (!filterExpanded) {
// Panel closed → open and switch to tags tab
setFilterExpanded(true);
setActiveTab('tags');
} else if (activeTab !== 'tags') {
// Panel open but wrong tab → just switch to tags tab
setActiveTab('tags');
} else {
// Panel open and on tags tab → close panel
setFilterExpanded(false);
}
}}
className={`px-3 py-1 rounded-full text-xs font-medium border-2 transition-colors ${
filterExpanded && activeTab === 'tags'
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-500 text-blue-700 dark:text-blue-300'
: 'border-dashed bg-gray-50 dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500'
}`}
>
+{remainingTagsCount} more tags
</button>
)}
<div className="ml-auto text-sm theme-text">
Showing {stories.length} of {totalElements} stories
</div>
</div>
{/* Expandable Filter Panel */}
{filterExpanded && (
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border theme-border">
{/* Tab Navigation */}
<div className="flex gap-1 mb-4">
<button
onClick={() => setActiveTab('advanced')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'advanced'
? 'bg-blue-500 text-white shadow-sm'
: 'theme-text hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
}`}
>
Advanced Filters
{activeAdvancedFiltersCount > 0 && (
<span className="ml-1 text-xs bg-white text-blue-500 px-1.5 py-0.5 rounded font-bold">
{activeAdvancedFiltersCount}
</span>
)}
</button>
<button
onClick={() => setActiveTab('tags')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'tags'
? 'bg-blue-500 text-white shadow-sm'
: 'theme-text hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
}`}
>
📋 More Tags
{remainingTagsCount > 0 && (
<span className={`ml-1 text-xs px-1.5 py-0.5 rounded font-bold ${
activeTab === 'tags'
? 'bg-white text-blue-500'
: 'bg-gray-200 dark:bg-gray-600'
}`}>
{remainingTagsCount}
</span>
)}
</button>
</div>
{/* Tab Content */}
{activeTab === 'tags' && (
<div className="space-y-3">
<div className="flex gap-3">
<Input
type="text"
placeholder="Search from all available tags..."
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
className="flex-1"
/>
{tagSearch && (
<Button variant="ghost" onClick={() => setTagSearch('')}>
Clear
</Button>
)}
</div>
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2 max-sm:grid-cols-1">
{filteredRemainingTags.length === 0 && tagSearch ? (
<div className="col-span-4 max-md:col-span-2 max-sm:col-span-1 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={`cursor-pointer transition-all hover:scale-105 ${
selectedTags.includes(tag.name) ? 'ring-2 ring-blue-500 ring-offset-1' : ''
}`}
>
<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>
)}
{activeTab === 'advanced' && onAdvancedFiltersChange && (
<AdvancedFilters
filters={advancedFilters}
onChange={onAdvancedFiltersChange}
onReset={() => onAdvancedFiltersChange({})}
/>
)}
{/* Action buttons */}
<div className="flex justify-end gap-3 mt-4 pt-3 border-t theme-border">
<Button
variant="ghost"
onClick={() => setFilterExpanded(false)}
>
Close
</Button>
{(selectedTags.length > 0 || activeAdvancedFiltersCount > 0) && (
<Button variant="ghost" onClick={onClearFilters}>
Clear All Filters
</Button>
)}
</div>
</div>
)}
</div>
</div>
{/* Content */}
{children}
</div>
);
}