Files
storycove/frontend/src/components/library/SidebarLayout.tsx
2026-06-18 12:53:22 +02:00

438 lines
16 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 type { Story, Tag, AdvancedFilters as AdvancedFiltersType } from '../../types/api';
interface SidebarLayoutProps {
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 SidebarLayout({
stories,
tags,
totalElements,
searchQuery,
selectedTags,
viewMode,
sortOption,
sortDirection,
advancedFilters = {},
onSearchChange,
onTagToggle,
onViewModeChange,
onSortChange,
onSortDirectionToggle,
onAdvancedFiltersChange,
onRandomStory,
onClearFilters,
children
}: SidebarLayoutProps) {
const [tagSearch, setTagSearch] = useState('');
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
// Filter tags based on search query
const filteredTags = tags.filter(tag =>
tag.name.toLowerCase().includes(tagSearch.toLowerCase())
);
// Count active advanced filters
const activeAdvancedFiltersCount = Object.values(advancedFilters).filter(value =>
value !== undefined && value !== '' && value !== 'all' && value !== false
).length;
return (
<div className="flex min-h-screen max-md:flex-col">
{/* Mobile Header - Only shown on mobile */}
<div className="hidden max-md:block bg-white dark:bg-gray-800 p-4 border-b theme-border">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-xl font-bold theme-header">Your Library</h1>
<p className="theme-text text-sm">{totalElements} stories total</p>
</div>
<Button
onClick={onRandomStory}
variant="primary"
size="sm"
>
🎲 Random
</Button>
</div>
{/* Mobile Search */}
<div className="mb-4">
<Input
type="search"
placeholder="Search stories..."
value={searchQuery}
onChange={onSearchChange}
className="w-full"
/>
</div>
{/* Mobile Controls Row */}
<div className="grid grid-cols-3 gap-2">
{/* View Toggle */}
<div className="flex border theme-border rounded-lg overflow-hidden">
<Button
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
onClick={() => onViewModeChange('grid')}
className="rounded-none border-0 flex-1 px-2 py-1 text-xs"
>
</Button>
<Button
variant={viewMode === 'list' ? 'primary' : 'ghost'}
onClick={() => onViewModeChange('list')}
className="rounded-none border-0 flex-1 px-2 py-1 text-xs"
>
</Button>
</div>
{/* Sort */}
<select
value={`${sortOption}_${sortDirection}`}
onChange={(e) => {
const [option, direction] = e.target.value.split('_');
onSortChange(option);
if (sortDirection !== direction) {
onSortDirectionToggle();
}
}}
className="px-2 py-1 border rounded-lg theme-card border-gray-300 dark:border-gray-600 text-xs"
>
<option value="lastReadAt_desc">Last Read </option>
<option value="lastReadAt_asc">Last Read </option>
<option value="lastCompletedAt_desc">Last Completed </option>
<option value="createdAt_desc">Date Added </option>
<option value="createdAt_asc">Date Added </option>
<option value="title_asc">Title </option>
<option value="title_desc">Title </option>
<option value="authorName_asc">Author </option>
<option value="authorName_desc">Author </option>
<option value="rating_desc">Rating </option>
<option value="rating_asc">Rating </option>
</select>
{/* Filter Toggle */}
<Button
variant={showAdvancedFilters || selectedTags.length > 0 || activeAdvancedFiltersCount > 0 ? "primary" : "ghost"}
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
className="text-xs px-2 py-1"
>
Filters
{(selectedTags.length + activeAdvancedFiltersCount) > 0 && (
<span className="ml-1 bg-white text-blue-500 px-1 rounded text-xs">
{selectedTags.length + activeAdvancedFiltersCount}
</span>
)}
</Button>
</div>
{/* Mobile Tag Pills - Show selected tags */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{selectedTags.slice(0, 3).map((tagName) => {
const tag = tags.find(t => t.name === tagName);
return tag ? (
<div key={tag.id} onClick={() => onTagToggle(tag.name)} className="cursor-pointer">
<TagDisplay tag={tag} size="sm" clickable={true} className="bg-blue-500 text-white" />
</div>
) : null;
})}
{selectedTags.length > 3 && (
<span className="text-xs text-gray-500 px-2 py-1">+{selectedTags.length - 3} more</span>
)}
</div>
)}
</div>
{/* Left Sidebar - Hidden on mobile by default */}
<div className="w-80 min-w-80 max-w-80 bg-white dark:bg-gray-800 p-4 border-r theme-border sticky top-0 h-screen overflow-y-auto overflow-x-hidden max-md:hidden">
{/* Random Story Button */}
<div className="mb-6">
<Button
onClick={onRandomStory}
variant="primary"
className="w-full"
>
🎲 Random Story
</Button>
</div>
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold theme-header">Your Library</h1>
<p className="theme-text mt-1">{totalElements} stories total</p>
</div>
{/* Search */}
<div className="mb-6">
<Input
type="search"
placeholder="Search stories..."
value={searchQuery}
onChange={onSearchChange}
className="w-full"
/>
</div>
{/* View Toggle */}
<div className="mb-6">
<div className="flex gap-2">
<Button
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
onClick={() => onViewModeChange('grid')}
className="flex-1"
>
Grid
</Button>
<Button
variant={viewMode === 'list' ? 'primary' : 'ghost'}
onClick={() => onViewModeChange('list')}
className="flex-1"
>
List
</Button>
</div>
</div>
{/* Sort Controls */}
<div className="mb-6 theme-card p-4 rounded-lg">
<h3 className="text-sm font-medium theme-header mb-3">Sort By</h3>
<div className="flex gap-2 items-center">
<select
value={sortOption}
onChange={(e) => onSortChange(e.target.value)}
className="flex-1 px-3 py-2 border rounded-lg theme-card border-gray-300 dark:border-gray-600"
>
<option value="lastReadAt">Last Read</option>
<option value="lastCompletedAt">Last Completed</option>
<option value="createdAt">Date Added</option>
<option value="title">Title</option>
<option value="authorName">Author</option>
<option value="rating">Rating</option>
</select>
<Button
variant="ghost"
onClick={onSortDirectionToggle}
className="px-3 py-2"
title={`Toggle sort direction (currently ${sortDirection === 'asc' ? 'ascending' : 'descending'})`}
>
{sortDirection === 'asc' ? '↑' : '↓'}
</Button>
</div>
</div>
{/* Tag Filters */}
<div className="theme-card p-4 rounded-lg">
<h3 className="text-sm font-medium theme-header mb-3">Filter by Tags</h3>
<div className="mb-3">
<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>
<div className="max-h-48 overflow-y-auto border theme-border rounded p-2">
<div className="space-y-1">
<label className="flex items-center gap-2 py-1 cursor-pointer">
<input
type="checkbox"
checked={selectedTags.length === 0}
onChange={() => onClearFilters()}
/>
<span className="text-xs">All Stories ({totalElements})</span>
</label>
{filteredTags.map((tag) => (
<label
key={tag.id}
className="flex items-center gap-2 py-1 cursor-pointer"
>
<input
type="checkbox"
checked={selectedTags.includes(tag.name)}
onChange={() => onTagToggle(tag.name)}
/>
<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>
))}
{filteredTags.length === 0 && tagSearch && (
<div className="text-center text-xs text-gray-500 py-2">
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>
</div>
<div className="mt-2 space-y-2">
<Button
variant="ghost"
onClick={onClearFilters}
className="w-full text-xs py-1"
>
Clear All
</Button>
{/* Advanced Filters Toggle */}
{onAdvancedFiltersChange && (
<Button
variant={showAdvancedFilters || activeAdvancedFiltersCount > 0 ? "primary" : "ghost"}
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
className={`w-full text-xs py-1 ${showAdvancedFilters || activeAdvancedFiltersCount > 0 ? '' : 'border-dashed border-2'}`}
>
Advanced Filters
{activeAdvancedFiltersCount > 0 && (
<span className="ml-1 bg-white text-blue-500 px-1 rounded text-xs">
{activeAdvancedFiltersCount}
</span>
)}
</Button>
)}
</div>
{/* Advanced Filters Section */}
{showAdvancedFilters && onAdvancedFiltersChange && (
<div className="mt-4 pt-4 border-t theme-border">
<AdvancedFilters
filters={advancedFilters}
onChange={onAdvancedFiltersChange}
onReset={() => onAdvancedFiltersChange({})}
className="space-y-3 max-w-full overflow-hidden"
/>
</div>
)}
</div>
</div>
{/* Mobile Filter Panel - Shows when filters expanded */}
{showAdvancedFilters && (
<div className="hidden max-md:block bg-white dark:bg-gray-800 border-b theme-border">
<div className="p-4">
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium theme-header">Filters</h3>
<Button
variant="ghost"
onClick={() => setShowAdvancedFilters(false)}
size="sm"
>
Close
</Button>
</div>
{/* Tag Grid */}
<div className="mb-4">
<h4 className="text-sm font-medium theme-header mb-2">Tags</h4>
<div className="mb-2">
<input
type="text"
placeholder="Search tags..."
value={tagSearch}
onChange={(e) => setTagSearch(e.target.value)}
className="w-full px-2 py-1 text-sm border rounded theme-card border-gray-300 dark:border-gray-600"
/>
</div>
<div className="max-h-32 overflow-y-auto">
<div className="grid grid-cols-2 gap-1">
<button
onClick={() => onClearFilters()}
className={`px-2 py-1 text-xs border rounded text-left ${
selectedTags.length === 0 ? 'bg-blue-500 text-white border-blue-500' : 'theme-card border-gray-300 dark:border-gray-600'
}`}
>
All ({totalElements})
</button>
{filteredTags.slice(0, 19).map((tag) => (
<button
key={tag.id}
onClick={() => onTagToggle(tag.name)}
className={`px-2 py-1 text-xs border rounded text-left truncate ${
selectedTags.includes(tag.name) ? 'bg-blue-500 text-white border-blue-500' : 'theme-card border-gray-300 dark:border-gray-600'
}`}
>
{tag.name} ({tag.storyCount})
</button>
))}
</div>
</div>
</div>
{/* Advanced Filters */}
{onAdvancedFiltersChange && (
<div>
<h4 className="text-sm font-medium theme-header mb-2">Advanced Filters</h4>
<AdvancedFilters
filters={advancedFilters}
onChange={onAdvancedFiltersChange}
onReset={() => onAdvancedFiltersChange({})}
className="space-y-3"
/>
</div>
)}
<div className="flex gap-2 mt-4">
<Button
variant="ghost"
onClick={onClearFilters}
size="sm"
className="flex-1"
>
Clear All
</Button>
<Button
variant="primary"
onClick={() => setShowAdvancedFilters(false)}
size="sm"
className="flex-1"
>
Apply
</Button>
</div>
</div>
</div>
)}
{/* Main Content */}
<div className="flex-1 p-4">
{children}
</div>
</div>
);
}