New Switchable Library Layout
This commit is contained in:
@@ -17,12 +17,12 @@ export default function Header() {
|
||||
|
||||
const addStoryItems = [
|
||||
{
|
||||
href: '/import',
|
||||
href: '/add-story',
|
||||
label: 'Manual Entry',
|
||||
description: 'Add a story by manually entering details'
|
||||
},
|
||||
{
|
||||
href: '/import?mode=url',
|
||||
href: '/import',
|
||||
label: 'Import from URL',
|
||||
description: 'Import a single story from a website'
|
||||
},
|
||||
@@ -156,34 +156,16 @@ export default function Header() {
|
||||
<div className="px-2 py-1">
|
||||
<div className="font-medium theme-text mb-1">Add Story</div>
|
||||
<div className="pl-4 space-y-1">
|
||||
<Link
|
||||
href="/import"
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Manual Entry
|
||||
</Link>
|
||||
<Link
|
||||
href="/import?mode=url"
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Import from URL
|
||||
</Link>
|
||||
<Link
|
||||
href="/import/epub"
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Import EPUB
|
||||
</Link>
|
||||
<Link
|
||||
href="/import/bulk"
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Bulk Import
|
||||
</Link>
|
||||
{addStoryItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block theme-text hover:theme-accent transition-colors text-sm py-1"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
|
||||
@@ -22,13 +22,13 @@ const importTabs: ImportTab[] = [
|
||||
{
|
||||
id: 'manual',
|
||||
label: 'Manual Entry',
|
||||
href: '/import',
|
||||
href: '/add-story',
|
||||
description: 'Add a story by manually entering details'
|
||||
},
|
||||
{
|
||||
id: 'url',
|
||||
label: 'Import from URL',
|
||||
href: '/import?mode=url',
|
||||
href: '/import',
|
||||
description: 'Import a single story from a website'
|
||||
},
|
||||
{
|
||||
@@ -52,8 +52,10 @@ export default function ImportLayout({ children, title, description }: ImportLay
|
||||
|
||||
// Determine which tab is active
|
||||
const getActiveTab = () => {
|
||||
if (pathname === '/import') {
|
||||
return mode === 'url' ? 'url' : 'manual';
|
||||
if (pathname === '/add-story') {
|
||||
return 'manual';
|
||||
} else if (pathname === '/import') {
|
||||
return 'url';
|
||||
} else if (pathname === '/import/epub') {
|
||||
return 'epub';
|
||||
} else if (pathname === '/import/bulk') {
|
||||
|
||||
220
frontend/src/components/library/MinimalLayout.tsx
Normal file
220
frontend/src/components/library/MinimalLayout.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface MinimalLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
sortOption: string;
|
||||
sortDirection: 'asc' | 'desc';
|
||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onTagToggle: (tagName: string) => void;
|
||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||
onSortChange: (option: string) => void;
|
||||
onSortDirectionToggle: () => void;
|
||||
onRandomStory: () => void;
|
||||
onClearFilters: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function MinimalLayout({
|
||||
stories,
|
||||
tags,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
sortOption,
|
||||
sortDirection,
|
||||
onSearchChange,
|
||||
onTagToggle,
|
||||
onViewModeChange,
|
||||
onSortChange,
|
||||
onSortDirectionToggle,
|
||||
onRandomStory,
|
||||
onClearFilters,
|
||||
children
|
||||
}: MinimalLayoutProps) {
|
||||
const [tagBrowserOpen, setTagBrowserOpen] = useState(false);
|
||||
|
||||
const popularTags = tags.slice(0, 5);
|
||||
|
||||
const getSortDisplayText = () => {
|
||||
const sortLabels: Record<string, string> = {
|
||||
lastRead: 'Last Read',
|
||||
createdAt: 'Date Added',
|
||||
title: 'Title',
|
||||
authorName: 'Author',
|
||||
rating: 'Rating',
|
||||
};
|
||||
const direction = sortDirection === 'asc' ? '↑' : '↓';
|
||||
return `Sort: ${sortLabels[sortOption] || sortOption} ${direction}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-10 max-md:p-5">
|
||||
{/* Minimal Header */}
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-4xl font-light theme-header mb-2">Story Library</h1>
|
||||
<p className="theme-text text-lg mb-8">
|
||||
Your personal collection of {stories.length} stories
|
||||
</p>
|
||||
<div>
|
||||
<Button variant="primary" onClick={onRandomStory}>
|
||||
🎲 Random Story
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Control Bar */}
|
||||
<div className="sticky top-5 z-10 mb-8">
|
||||
<div className="bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm border theme-border rounded-xl p-4 shadow-lg">
|
||||
<div className="grid grid-cols-3 gap-6 items-center max-md:grid-cols-1 max-md:gap-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search stories, authors, tags..."
|
||||
value={searchQuery}
|
||||
onChange={onSearchChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort & Clear */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onSortDirectionToggle}
|
||||
className="text-sm theme-text hover:theme-accent transition-colors border-none bg-transparent"
|
||||
>
|
||||
{getSortDisplayText()}
|
||||
</button>
|
||||
<span className="text-gray-300 dark:text-gray-600">|</span>
|
||||
{(searchQuery || selectedTags.length > 0) && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="justify-self-end max-md:justify-self-auto">
|
||||
<div className="flex border theme-border rounded-lg overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className="rounded-none border-0 px-3 py-2"
|
||||
>
|
||||
☰ List
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className="rounded-none border-0 px-3 py-2"
|
||||
>
|
||||
⊞ Grid
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Filter */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex flex-wrap gap-2 justify-center mb-3">
|
||||
<button
|
||||
onClick={() => onClearFilters()}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
|
||||
selectedTags.length === 0
|
||||
? 'bg-blue-500 text-white border-blue-500'
|
||||
: 'bg-white dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{popularTags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors border ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white border-blue-500'
|
||||
: 'bg-white dark:bg-gray-800 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
|
||||
}`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setTagBrowserOpen(true)}
|
||||
>
|
||||
Browse All Tags ({tags.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{children}
|
||||
|
||||
{/* Tag Browser Modal */}
|
||||
{tagBrowserOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-5">
|
||||
<h3 className="text-xl font-semibold theme-header">Browse All Tags</h3>
|
||||
<button
|
||||
onClick={() => setTagBrowserOpen(false)}
|
||||
className="text-2xl theme-text hover:theme-accent transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search tags..."
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 max-md:grid-cols-2">
|
||||
{tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors border text-left ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white border-blue-500'
|
||||
: 'bg-white dark:bg-gray-700 theme-text border-gray-300 dark:border-gray-600 hover:border-blue-500 hover:text-blue-500'
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button variant="ghost" onClick={onClearFilters}>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => setTagBrowserOpen(false)}>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
frontend/src/components/library/SidebarLayout.tsx
Normal file
181
frontend/src/components/library/SidebarLayout.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface SidebarLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
sortOption: string;
|
||||
sortDirection: 'asc' | 'desc';
|
||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onTagToggle: (tagName: string) => void;
|
||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||
onSortChange: (option: string) => void;
|
||||
onSortDirectionToggle: () => void;
|
||||
onRandomStory: () => void;
|
||||
onClearFilters: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function SidebarLayout({
|
||||
stories,
|
||||
tags,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
sortOption,
|
||||
sortDirection,
|
||||
onSearchChange,
|
||||
onTagToggle,
|
||||
onViewModeChange,
|
||||
onSortChange,
|
||||
onSortDirectionToggle,
|
||||
onRandomStory,
|
||||
onClearFilters,
|
||||
children
|
||||
}: SidebarLayoutProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Left Sidebar */}
|
||||
<div className="w-80 bg-white dark:bg-gray-800 p-4 border-r theme-border sticky top-0 h-screen overflow-y-auto max-md:w-full max-md:h-auto max-md:static max-md:border-r-0 max-md:border-b max-md:max-h-96">
|
||||
{/* 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">{stories.length} 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="lastRead">Last Read</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..."
|
||||
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 ({stories.length})</span>
|
||||
</label>
|
||||
{tags.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)}
|
||||
/>
|
||||
<span className="text-xs">
|
||||
{tag.name} ({tag.storyCount})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{tags.length > 10 && (
|
||||
<div className="text-center text-xs text-gray-500 py-2">
|
||||
... and {tags.length - 10} more tags
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onClearFilters}
|
||||
className="w-full text-xs py-1"
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 p-4 max-md:p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
frontend/src/components/library/ToolbarLayout.tsx
Normal file
211
frontend/src/components/library/ToolbarLayout.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '../ui/Input';
|
||||
import Button from '../ui/Button';
|
||||
import { Story, Tag } from '../../types/api';
|
||||
|
||||
interface ToolbarLayoutProps {
|
||||
stories: Story[];
|
||||
tags: Tag[];
|
||||
searchQuery: string;
|
||||
selectedTags: string[];
|
||||
viewMode: 'grid' | 'list';
|
||||
sortOption: string;
|
||||
sortDirection: 'asc' | 'desc';
|
||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onTagToggle: (tagName: string) => void;
|
||||
onViewModeChange: (mode: 'grid' | 'list') => void;
|
||||
onSortChange: (option: string) => void;
|
||||
onSortDirectionToggle: () => void;
|
||||
onRandomStory: () => void;
|
||||
onClearFilters: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ToolbarLayout({
|
||||
stories,
|
||||
tags,
|
||||
searchQuery,
|
||||
selectedTags,
|
||||
viewMode,
|
||||
sortOption,
|
||||
sortDirection,
|
||||
onSearchChange,
|
||||
onTagToggle,
|
||||
onViewModeChange,
|
||||
onSortChange,
|
||||
onSortDirectionToggle,
|
||||
onRandomStory,
|
||||
onClearFilters,
|
||||
children
|
||||
}: ToolbarLayoutProps) {
|
||||
const [tagSearchExpanded, setTagSearchExpanded] = useState(false);
|
||||
|
||||
const popularTags = tags.slice(0, 6);
|
||||
const remainingTagsCount = Math.max(0, tags.length - 6);
|
||||
|
||||
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">{stories.length} 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>
|
||||
<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"
|
||||
>
|
||||
<option value="lastRead_desc">Sort: Last Read ↓</option>
|
||||
<option value="lastRead_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 & Clear */}
|
||||
<div className="flex gap-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"
|
||||
>
|
||||
⊞ Grid
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className="rounded-none border-0"
|
||||
>
|
||||
☰ List
|
||||
</Button>
|
||||
</div>
|
||||
{(searchQuery || selectedTags.length > 0) && (
|
||||
<Button variant="ghost" onClick={onClearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Filter Bar */}
|
||||
<div className="border-t theme-border pt-5">
|
||||
<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
|
||||
? '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) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
))}
|
||||
{remainingTagsCount > 0 && (
|
||||
<button
|
||||
onClick={() => setTagSearchExpanded(!tagSearchExpanded)}
|
||||
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-50 dark:bg-gray-800 theme-text border-2 border-dashed 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} stories
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Tag Search */}
|
||||
{tagSearchExpanded && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border theme-border">
|
||||
<div className="flex gap-3 mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search from all available tags..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="secondary">Search</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setTagSearchExpanded(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 max-h-40 overflow-y-auto max-md:grid-cols-2">
|
||||
{tags.slice(6).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => onTagToggle(tag.name)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
selectedTags.includes(tag.name)
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-700 theme-text hover:bg-blue-100 dark:hover:bg-blue-900'
|
||||
}`}
|
||||
>
|
||||
{tag.name} ({tag.storyCount})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user