New Switchable Library Layout

This commit is contained in:
Stefan Hardegger
2025-08-14 19:46:50 +02:00
parent 1d14d3d7aa
commit 460ec358ca
14 changed files with 1384 additions and 806 deletions

View File

@@ -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

View File

@@ -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') {

View 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>
);
}

View 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>
);
}

View 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>
);
}