438 lines
16 KiB
TypeScript
438 lines
16 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 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>
|
||
);
|
||
} |