362 lines
15 KiB
TypeScript
362 lines
15 KiB
TypeScript
'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>
|
||
);
|
||
} |