feat: refactor UI components to utilize new Container, Section, and StatusBadge components for improved layout and styling consistency across the application

This commit is contained in:
Julien Froidefond
2025-10-17 11:49:28 +02:00
parent 4f28df6818
commit 482bd9b0d2
23 changed files with 669 additions and 469 deletions

View File

@@ -9,6 +9,8 @@ import { OptimizedSkeleton } from "@/components/skeletons/OptimizedSkeletons";
import type { LibraryResponse } from "@/types/library"; import type { LibraryResponse } from "@/types/library";
import type { KomgaSeries, KomgaLibrary } from "@/types/komga"; import type { KomgaSeries, KomgaLibrary } from "@/types/komga";
import type { UserPreferences } from "@/types/preferences"; import type { UserPreferences } from "@/types/preferences";
import { Container } from "@/components/ui/container";
import { Section } from "@/components/ui/section";
interface ClientLibraryPageProps { interface ClientLibraryPageProps {
currentPage: number; currentPage: number;
@@ -148,7 +150,7 @@ export function ClientLibraryPage({
if (loading) { if (loading) {
return ( return (
<div className="container py-8 space-y-8"> <Container>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<OptimizedSkeleton className="h-10 w-64" /> <OptimizedSkeleton className="h-10 w-64" />
<OptimizedSkeleton className="h-10 w-10 rounded-full" /> <OptimizedSkeleton className="h-10 w-10 rounded-full" />
@@ -158,49 +160,49 @@ export function ClientLibraryPage({
<OptimizedSkeleton key={i} className="aspect-[3/4] w-full rounded" /> <OptimizedSkeleton key={i} className="aspect-[3/4] w-full rounded" />
))} ))}
</div> </div>
</div> </Container>
); );
} }
if (error) { if (error) {
return ( return (
<div className="container py-8 space-y-8"> <Container>
<div className="flex items-center justify-between"> <Section
<h1 className="text-3xl font-bold"> title={library?.name || t("series.empty")}
{library?.name || t("series.empty")} actions={<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />}
</h1> />
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
</div>
<ErrorMessage errorCode={error} onRetry={handleRetry} /> <ErrorMessage errorCode={error} onRetry={handleRetry} />
</div> </Container>
); );
} }
if (!library || !series) { if (!library || !series) {
return ( return (
<div className="container py-8 space-y-8"> <Container>
<ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} /> <ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} />
</div> </Container>
); );
} }
return ( return (
<div className="container py-8 space-y-8"> <Container>
<div className="flex items-center justify-between"> <Section
<h1 className="text-3xl font-bold">{library.name}</h1> title={library.name}
<div className="flex items-center gap-2"> actions={
{series.totalElements > 0 && ( <div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground"> {series.totalElements > 0 && (
{t("series.display.showing", { <p className="text-sm text-muted-foreground">
start: ((currentPage - 1) * effectivePageSize) + 1, {t("series.display.showing", {
end: Math.min(currentPage * effectivePageSize, series.totalElements), start: ((currentPage - 1) * effectivePageSize) + 1,
total: series.totalElements, end: Math.min(currentPage * effectivePageSize, series.totalElements),
})} total: series.totalElements,
</p> })}
)} </p>
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} /> )}
</div> <RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
</div> </div>
}
/>
<PaginatedSeriesGrid <PaginatedSeriesGrid
series={series.content || []} series={series.content || []}
currentPage={currentPage} currentPage={currentPage}
@@ -209,6 +211,6 @@ export function ClientLibraryPage({
defaultShowOnlyUnread={preferences.showOnlyUnread} defaultShowOnlyUnread={preferences.showOnlyUnread}
showOnlyUnread={unreadOnly} showOnlyUnread={unreadOnly}
/> />
</div> </Container>
); );
} }

View File

@@ -16,6 +16,7 @@ import type { AdminUserData } from "@/lib/services/admin.service";
import { EditUserDialog } from "./EditUserDialog"; import { EditUserDialog } from "./EditUserDialog";
import { DeleteUserDialog } from "./DeleteUserDialog"; import { DeleteUserDialog } from "./DeleteUserDialog";
import { ResetPasswordDialog } from "./ResetPasswordDialog"; import { ResetPasswordDialog } from "./ResetPasswordDialog";
import { StatusBadge } from "@/components/ui/status-badge";
interface UsersTableProps { interface UsersTableProps {
users: AdminUserData[]; users: AdminUserData[];
@@ -29,7 +30,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
return ( return (
<> <>
<div className="rounded-md border"> <div className="rounded-lg border bg-card/70 backdrop-blur-md shadow-sm">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -67,28 +68,24 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
</TableCell> </TableCell>
<TableCell> <TableCell>
{user.hasKomgaConfig ? ( {user.hasKomgaConfig ? (
<div className="flex items-center gap-2"> <StatusBadge status="success" icon={Check}>
<Check className="h-4 w-4 text-green-500" /> Configuré
<span className="text-xs text-green-600 dark:text-green-400">Configuré</span> </StatusBadge>
</div>
) : ( ) : (
<div className="flex items-center gap-2"> <StatusBadge status="error" icon={X}>
<X className="h-4 w-4 text-red-500" /> Non configuré
<span className="text-xs text-red-600 dark:text-red-400">Non configuré</span> </StatusBadge>
</div>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{user.hasPreferences ? ( {user.hasPreferences ? (
<div className="flex items-center gap-2"> <StatusBadge status="success" icon={Check}>
<Check className="h-4 w-4 text-green-500" /> Oui
<span className="text-xs text-green-600 dark:text-green-400">Oui</span> </StatusBadge>
</div>
) : ( ) : (
<div className="flex items-center gap-2"> <StatusBadge status="error" icon={X}>
<X className="h-4 w-4 text-red-500" /> Non
<span className="text-xs text-red-600 dark:text-red-400">Non</span> </StatusBadge>
</div>
)} )}
</TableCell> </TableCell>
<TableCell>{user._count?.favorites || 0}</TableCell> <TableCell>{user._count?.favorites || 0}</TableCell>
@@ -134,7 +131,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
<EditUserDialog <EditUserDialog
user={editingUser} user={editingUser}
open={!!editingUser} open={!!editingUser}
onOpenChange={(open) => !open && setEditingUser(null)} onOpenChange={(open: boolean) => !open && setEditingUser(null)}
onSuccess={() => { onSuccess={() => {
setEditingUser(null); setEditingUser(null);
onUserUpdated(); onUserUpdated();
@@ -146,7 +143,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
<ResetPasswordDialog <ResetPasswordDialog
user={resettingPasswordUser} user={resettingPasswordUser}
open={!!resettingPasswordUser} open={!!resettingPasswordUser}
onOpenChange={(open) => !open && setResettingPasswordUser(null)} onOpenChange={(open: boolean) => !open && setResettingPasswordUser(null)}
onSuccess={() => { onSuccess={() => {
setResettingPasswordUser(null); setResettingPasswordUser(null);
}} }}
@@ -157,7 +154,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
<DeleteUserDialog <DeleteUserDialog
user={deletingUser} user={deletingUser}
open={!!deletingUser} open={!!deletingUser}
onOpenChange={(open) => !open && setDeletingUser(null)} onOpenChange={(open: boolean) => !open && setDeletingUser(null)}
onSuccess={() => { onSuccess={() => {
setDeletingUser(null); setDeletingUser(null);
onUserUpdated(); onUserUpdated();

View File

@@ -4,6 +4,10 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
interface LoginFormProps { interface LoginFormProps {
from?: string; from?: string;
@@ -49,66 +53,37 @@ export function LoginForm({ from }: LoginFormProps) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label <Label htmlFor="email">{t("login.form.email")}</Label>
htmlFor="email" <Input
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("login.form.email")}
</label>
<input
id="email" id="email"
name="email" name="email"
type="email" type="email"
autoComplete="email" autoComplete="email"
required required
defaultValue="demo@stripstream.local" defaultValue="demo@stripstream.local"
className="flex h-10 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label <Label htmlFor="password">{t("login.form.password")}</Label>
htmlFor="password" <Input
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("login.form.password")}
</label>
<input
id="password" id="password"
name="password" name="password"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
required required
defaultValue="fft$VSD96dis" defaultValue="fft$VSD96dis"
className="flex h-10 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/> />
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input <Checkbox id="remember" name="remember" defaultChecked />
id="remember" <Label htmlFor="remember" className="cursor-pointer">
name="remember"
type="checkbox"
defaultChecked
className="h-4 w-4 rounded border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<label
htmlFor="remember"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("login.form.remember")} {t("login.form.remember")}
</label> </Label>
</div> </div>
{error && ( {error && <div className="text-sm text-destructive">{error}</div>}
<div className="text-red-600 text-sm"> <Button type="submit" disabled={isLoading} className="w-full">
{error}
</div>
)}
<button
type="submit"
disabled={isLoading}
className="bg-[#4F46E5] inline-flex w-full items-center justify-center rounded-md bg-primary/90 backdrop-blur-md px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
{isLoading ? t("login.form.submit.loading.login") : t("login.form.submit.login")} {isLoading ? t("login.form.submit.loading.login") : t("login.form.submit.login")}
</button> </Button>
</form> </form>
); );
} }

View File

@@ -6,6 +6,9 @@ import { signIn } from "next-auth/react";
import { ErrorMessage } from "@/components/ui/ErrorMessage"; import { ErrorMessage } from "@/components/ui/ErrorMessage";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import type { AppErrorType } from "@/types/global"; import type { AppErrorType } from "@/types/global";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
interface RegisterFormProps { interface RegisterFormProps {
from?: string; from?: string;
@@ -88,61 +91,33 @@ export function RegisterForm({ from: _from }: RegisterFormProps) {
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label <Label htmlFor="email">{t("login.form.email")}</Label>
htmlFor="email" <Input id="email" name="email" type="email" autoComplete="email" required />
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("login.form.email")}
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="flex h-10 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label <Label htmlFor="password">{t("login.form.password")}</Label>
htmlFor="password" <Input
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("login.form.password")}
</label>
<input
id="password" id="password"
name="password" name="password"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
className="flex h-10 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label <Label htmlFor="confirmPassword">{t("login.form.confirmPassword")}</Label>
htmlFor="confirmPassword" <Input
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t("login.form.confirmPassword")}
</label>
<input
id="confirmPassword" id="confirmPassword"
name="confirmPassword" name="confirmPassword"
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
required required
className="flex h-10 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/> />
</div> </div>
{error && <ErrorMessage errorCode={error.code} variant="form" />} {error && <ErrorMessage errorCode={error.code} variant="form" />}
<button <Button type="submit" disabled={isLoading} className="w-full">
type="submit"
disabled={isLoading}
className="bg-[#4F46E5] inline-flex w-full items-center justify-center rounded-md bg-primary/90 backdrop-blur-md px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
{isLoading ? t("login.form.submit.loading.register") : t("login.form.submit.register")} {isLoading ? t("login.form.submit.loading.register") : t("login.form.submit.register")}
</button> </Button>
</form> </form>
); );
} }

View File

@@ -1,6 +1,7 @@
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences"; import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { LayoutGrid, LayoutTemplate } from "lucide-react"; import { LayoutGrid, LayoutTemplate } from "lucide-react";
import { Button } from "@/components/ui/button";
interface CompactModeButtonProps { interface CompactModeButtonProps {
onToggle?: (isCompact: boolean) => void; onToggle?: (isCompact: boolean) => void;
@@ -16,16 +17,19 @@ export function CompactModeButton({ onToggle }: CompactModeButtonProps) {
onToggle?.(newCompactState); onToggle?.(newCompactState);
}; };
const Icon = isCompact ? LayoutTemplate : LayoutGrid;
const label = isCompact ? t("series.filters.normal") : t("series.filters.compact");
return ( return (
<button <Button
variant="ghost"
size="sm"
onClick={handleClick} onClick={handleClick}
className="inline-flex items-center gap-2 px-2 py-1.5 text-sm font-medium rounded-lg hover:bg-accent/80 hover:backdrop-blur-md hover:text-accent-foreground whitespace-nowrap" title={label}
title={isCompact ? t("series.filters.normal") : t("series.filters.compact")} className="whitespace-nowrap"
> >
{isCompact ? <LayoutTemplate className="h-4 w-4" /> : <LayoutGrid className="h-4 w-4" />} <Icon className="h-4 w-4" />
<span className="hidden sm:inline"> <span className="hidden sm:inline ml-2">{label}</span>
{isCompact ? t("series.filters.normal") : t("series.filters.compact")} </Button>
</span>
</button>
); );
} }

View File

@@ -2,6 +2,7 @@
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { Filter } from "lucide-react"; import { Filter } from "lucide-react";
import { Button } from "@/components/ui/button";
interface UnreadFilterButtonProps { interface UnreadFilterButtonProps {
showOnlyUnread: boolean; showOnlyUnread: boolean;
@@ -11,16 +12,18 @@ interface UnreadFilterButtonProps {
export function UnreadFilterButton({ showOnlyUnread, onToggle }: UnreadFilterButtonProps) { export function UnreadFilterButton({ showOnlyUnread, onToggle }: UnreadFilterButtonProps) {
const { t } = useTranslate(); const { t } = useTranslate();
const label = showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread");
return ( return (
<button <Button
variant="ghost"
size="sm"
onClick={onToggle} onClick={onToggle}
className="inline-flex items-center gap-2 px-2 py-1.5 text-sm font-medium rounded-lg hover:bg-accent/80 hover:backdrop-blur-md hover:text-accent-foreground whitespace-nowrap" title={label}
title={showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread")} className="whitespace-nowrap"
> >
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
<span className="hidden sm:inline"> <span className="hidden sm:inline ml-2">{label}</span>
{showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread")} </Button>
</span>
</button>
); );
} }

View File

@@ -81,7 +81,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
<MediaRow <MediaRow
title={t("home.sections.continue_series")} title={t("home.sections.continue_series")}
items={optimizeSeriesData(data.ongoing)} items={optimizeSeriesData(data.ongoing)}
icon={<LibraryBig className="w-6 h-6" />} icon={LibraryBig}
/> />
)} )}
@@ -89,7 +89,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
<MediaRow <MediaRow
title={t("home.sections.continue_reading")} title={t("home.sections.continue_reading")}
items={optimizeBookData(data.ongoingBooks)} items={optimizeBookData(data.ongoingBooks)}
icon={<BookOpen className="w-6 h-6" />} icon={BookOpen}
/> />
)} )}
@@ -97,7 +97,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
<MediaRow <MediaRow
title={t("home.sections.up_next")} title={t("home.sections.up_next")}
items={optimizeBookData(data.onDeck)} items={optimizeBookData(data.onDeck)}
icon={<Clock className="w-6 h-6" />} icon={Clock}
/> />
)} )}
@@ -105,7 +105,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
<MediaRow <MediaRow
title={t("home.sections.latest_series")} title={t("home.sections.latest_series")}
items={optimizeSeriesData(data.latestSeries)} items={optimizeSeriesData(data.latestSeries)}
icon={<Sparkles className="w-6 h-6" />} icon={Sparkles}
/> />
)} )}
@@ -113,7 +113,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
<MediaRow <MediaRow
title={t("home.sections.recently_added")} title={t("home.sections.recently_added")}
items={optimizeBookData(data.recentlyRead)} items={optimizeBookData(data.recentlyRead)}
icon={<History className="w-6 h-6" />} icon={History}
/> />
)} )}
</div> </div>

View File

@@ -1,12 +1,14 @@
"use client"; "use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { KomgaBook, KomgaSeries } from "@/types/komga"; import type { KomgaBook, KomgaSeries } from "@/types/komga";
import { BookCover } from "../ui/book-cover"; import { BookCover } from "../ui/book-cover";
import { SeriesCover } from "../ui/series-cover"; import { SeriesCover } from "../ui/series-cover";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { ScrollContainer } from "@/components/ui/scroll-container";
import { Section } from "@/components/ui/section";
import type { LucideIcon } from "lucide-react";
import { Card } from "@/components/ui/card";
interface BaseItem { interface BaseItem {
id: string; id: string;
@@ -36,13 +38,10 @@ interface OptimizedBook extends BaseItem {
interface MediaRowProps { interface MediaRowProps {
title: string; title: string;
items: (OptimizedSeries | OptimizedBook)[]; items: (OptimizedSeries | OptimizedBook)[];
icon?: React.ReactNode; icon?: LucideIcon;
} }
export function MediaRow({ title, items, icon }: MediaRowProps) { export function MediaRow({ title, items, icon }: MediaRowProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(true);
const router = useRouter(); const router = useRouter();
const { t } = useTranslate(); const { t } = useTranslate();
@@ -51,64 +50,21 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
router.push(path); router.push(path);
}; };
const handleScroll = () => {
if (!scrollContainerRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
setShowLeftArrow(scrollLeft > 0);
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10);
};
const scroll = (direction: "left" | "right") => {
if (!scrollContainerRef.current) return;
const scrollAmount = direction === "left" ? -400 : 400;
scrollContainerRef.current.scrollBy({ left: scrollAmount, behavior: "smooth" });
};
if (!items.length) return null; if (!items.length) return null;
return ( return (
<div className="space-y-4"> <Section title={title} icon={icon}>
<div className="flex items-center gap-2"> <ScrollContainer
{icon} showArrows={true}
<h2 className="text-2xl font-bold tracking-tight">{title}</h2> scrollAmount={400}
</div> arrowLeftLabel={t("navigation.scrollLeft")}
<div className="relative"> arrowRightLabel={t("navigation.scrollRight")}
{/* Bouton de défilement gauche */} >
{showLeftArrow && ( {items.map((item) => (
<button <MediaCard key={item.id} item={item} onClick={() => onItemClick?.(item)} />
onClick={() => scroll("left")} ))}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-background/90 shadow-md border transition-opacity" </ScrollContainer>
aria-label={t("navigation.scrollLeft")} </Section>
>
<ChevronLeft className="h-6 w-6" />
</button>
)}
{/* Conteneur défilant */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4"
>
{items.map((item) => (
<MediaCard key={item.id} item={item} onClick={() => onItemClick?.(item)} />
))}
</div>
{/* Bouton de défilement droit */}
{showRightArrow && (
<button
onClick={() => scroll("right")}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-background/90 shadow-md border transition-opacity"
aria-label={t("navigation.scrollRight")}
>
<ChevronRight className="h-6 w-6" />
</button>
)}
</div>
</div>
); );
} }
@@ -128,9 +84,9 @@ function MediaCard({ item, onClick }: MediaCardProps) {
: ""); : "");
return ( return (
<div <Card
onClick={onClick} onClick={onClick}
className="flex-shrink-0 w-[200px] relative flex flex-col rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm hover:bg-accent/80 hover:backdrop-blur-md hover:text-accent-foreground transition-colors overflow-hidden cursor-pointer" className="flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden cursor-pointer"
> >
<div className="relative aspect-[2/3] bg-muted"> <div className="relative aspect-[2/3] bg-muted">
{isSeries ? ( {isSeries ? (
@@ -154,6 +110,6 @@ function MediaCard({ item, onClick }: MediaCardProps) {
</> </>
)} )}
</div> </div>
</div> </Card>
); );
} }

View File

@@ -2,6 +2,7 @@ import { Menu, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import LanguageSelector from "@/components/LanguageSelector"; import LanguageSelector from "@/components/LanguageSelector";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconButton } from "@/components/ui/icon-button";
interface HeaderProps { interface HeaderProps {
onToggleSidebar: () => void; onToggleSidebar: () => void;
@@ -18,16 +19,15 @@ export function Header({ onToggleSidebar }: HeaderProps) {
return ( return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/50 pt-safe"> <header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/50 pt-safe">
<div className="container flex h-14 max-w-screen-2xl items-center"> <div className="container flex h-14 max-w-screen-2xl items-center">
<button <IconButton
variant="ghost"
size="icon"
icon={Menu}
onClick={onToggleSidebar} onClick={onToggleSidebar}
className="mr-2 px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md" tooltip={t("header.toggleSidebar")}
aria-label={t("header.toggleSidebar")} className="mr-2"
id="sidebar-toggle" id="sidebar-toggle"
> />
<div className="flex items-center justify-center w-5 h-5">
<Menu className="h-[1.2rem] w-[1.2rem]" />
</div>
</button>
<div className="mr-4 hidden md:flex"> <div className="mr-4 hidden md:flex">
<a className="mr-6 flex items-center space-x-2" href="/"> <a className="mr-6 flex items-center space-x-2" href="/">

View File

@@ -12,6 +12,8 @@ import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { NavButton } from "@/components/ui/nav-button";
import { IconButton } from "@/components/ui/icon-button";
interface SidebarProps { interface SidebarProps {
isOpen: boolean; isOpen: boolean;
@@ -180,17 +182,13 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
{t("sidebar.navigation")} {t("sidebar.navigation")}
</h2> </h2>
{mainNavItems.map((item) => ( {mainNavItems.map((item) => (
<button <NavButton
key={item.href} key={item.href}
icon={item.icon}
label={item.title}
active={pathname === item.href}
onClick={() => handleLinkClick(item.href)} onClick={() => handleLinkClick(item.href)}
className={cn( />
"w-full flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
pathname === item.href ? "bg-accent" : "transparent"
)}
>
<item.icon className="mr-2 h-4 w-4" />
{item.title}
</button>
))} ))}
</div> </div>
</div> </div>
@@ -213,17 +211,14 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
</div> </div>
) : ( ) : (
favorites.map((series) => ( favorites.map((series) => (
<button <NavButton
key={series.id} key={series.id}
icon={Star}
label={series.metadata.title}
active={pathname === `/series/${series.id}`}
onClick={() => handleLinkClick(`/series/${series.id}`)} onClick={() => handleLinkClick(`/series/${series.id}`)}
className={cn( className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400"
"w-full flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground", />
pathname === `/series/${series.id}` ? "bg-accent" : "transparent"
)}
>
<Star className="mr-2 h-4 w-4 fill-yellow-400 text-yellow-400" />
<span className="truncate">{series.metadata.title}</span>
</button>
)) ))
)} )}
</div> </div>
@@ -235,14 +230,16 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
<h2 className="text-lg font-semibold tracking-tight"> <h2 className="text-lg font-semibold tracking-tight">
{t("sidebar.libraries.title")} {t("sidebar.libraries.title")}
</h2> </h2>
<button <IconButton
variant="ghost"
size="icon"
icon={RefreshCw}
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing} disabled={isRefreshing}
className="p-1 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors" tooltip={t("sidebar.libraries.refresh")}
aria-label={t("sidebar.libraries.refresh")} iconClassName={cn(isRefreshing && "animate-spin")}
> className="h-8 w-8"
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} /> />
</button>
</div> </div>
{isRefreshing ? ( {isRefreshing ? (
<div className="px-3 py-2 text-sm text-muted-foreground"> <div className="px-3 py-2 text-sm text-muted-foreground">
@@ -254,17 +251,13 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
</div> </div>
) : ( ) : (
libraries.map((library) => ( libraries.map((library) => (
<button <NavButton
key={library.id} key={library.id}
icon={Library}
label={library.name}
active={pathname === `/libraries/${library.id}`}
onClick={() => handleLinkClick(`/libraries/${library.id}`)} onClick={() => handleLinkClick(`/libraries/${library.id}`)}
className={cn( />
"w-full flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
pathname === `/libraries/${library.id}` ? "bg-accent" : "transparent"
)}
>
<Library className="mr-2 h-4 w-4" />
{library.name}
</button>
)) ))
)} )}
</div> </div>
@@ -275,50 +268,37 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight"> <h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
{t("sidebar.settings.title")} {t("sidebar.settings.title")}
</h2> </h2>
<button <NavButton
icon={User}
label={t("sidebar.account")}
active={pathname === "/account"}
onClick={() => handleLinkClick("/account")} onClick={() => handleLinkClick("/account")}
className={cn( />
"w-full flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground", <NavButton
pathname === "/account" ? "bg-accent" : "transparent" icon={Settings}
)} label={t("sidebar.settings.preferences")}
> active={pathname === "/settings"}
<User className="mr-2 h-4 w-4" />
{t("sidebar.account")}
</button>
<button
onClick={() => handleLinkClick("/settings")} onClick={() => handleLinkClick("/settings")}
className={cn( />
"w-full flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
pathname === "/settings" ? "bg-accent" : "transparent"
)}
>
<Settings className="mr-2 h-4 w-4" />
{t("sidebar.settings.preferences")}
</button>
{userIsAdmin && ( {userIsAdmin && (
<button <NavButton
icon={Shield}
label={t("sidebar.admin")}
active={pathname === "/admin"}
onClick={() => handleLinkClick("/admin")} onClick={() => handleLinkClick("/admin")}
className={cn( />
"w-full flex items-center rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground",
pathname === "/admin" ? "bg-accent" : "transparent"
)}
>
<Shield className="mr-2 h-4 w-4" />
{t("sidebar.admin")}
</button>
)} )}
</div> </div>
</div> </div>
</div> </div>
<div className="p-3 border-t border-border/40"> <div className="p-3 border-t border-border/40">
<button <NavButton
icon={LogOut}
label={t("sidebar.logout")}
onClick={handleLogout} onClick={handleLogout}
className="flex w-full items-center rounded-lg px-3 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 hover:text-destructive" className="text-destructive hover:bg-destructive/10 hover:text-destructive"
> />
<LogOut className="mr-2 h-4 w-4" />
{t("sidebar.logout")}
</button>
</div> </div>
</aside> </aside>
); );

View File

@@ -11,6 +11,7 @@ import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
import { PageSizeSelect } from "@/components/common/PageSizeSelect"; import { PageSizeSelect } from "@/components/common/PageSizeSelect";
import { CompactModeButton } from "@/components/common/CompactModeButton"; import { CompactModeButton } from "@/components/common/CompactModeButton";
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton"; import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
import { Container } from "@/components/ui/container";
interface PaginatedSeriesGridProps { interface PaginatedSeriesGridProps {
series: KomgaSeries[]; series: KomgaSeries[];
@@ -97,7 +98,7 @@ export function PaginatedSeriesGrid({
}; };
return ( return (
<div className="space-y-8"> <Container spacing="none" className="space-y-8">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-col sm:flex-row sm:items-center gap-4"> <div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="w-full"> <div className="w-full">
@@ -124,6 +125,6 @@ export function PaginatedSeriesGrid({
className="order-1 sm:order-2" className="order-1 sm:order-2"
/> />
</div> </div>
</div> </Container>
); );
} }

View File

@@ -14,6 +14,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageInput } from "./PageInput"; import { PageInput } from "./PageInput";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IconButton } from "@/components/ui/icon-button";
export const ControlButtons = ({ export const ControlButtons = ({
showControls, showControls,
@@ -40,7 +41,7 @@ export const ControlButtons = ({
{/* Boutons de contrôle */} {/* Boutons de contrôle */}
<div <div
className={cn( className={cn(
"absolute top-4 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 transition-all duration-300", "absolute top-4 left-1/2 -translate-x-1/2 z-30 flex items-center gap-2 transition-all duration-300 p-2 rounded-full bg-background/70 backdrop-blur-md",
showControls ? "opacity-100" : "opacity-0 pointer-events-none" showControls ? "opacity-100" : "opacity-0 pointer-events-none"
)} )}
onClick={(e) => { onClick={(e) => {
@@ -48,72 +49,69 @@ export const ControlButtons = ({
onToggleControls(); onToggleControls();
}} }}
> >
<button <IconButton
variant="ghost"
size="icon"
icon={isDoublePage ? LayoutTemplate : SplitSquareVertical}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleDoublePage(); onToggleDoublePage();
}} }}
className="p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-colors" tooltip={t(
aria-label={t(
isDoublePage isDoublePage
? "reader.controls.doublePage.disable" ? "reader.controls.doublePage.disable"
: "reader.controls.doublePage.enable" : "reader.controls.doublePage.enable"
)} )}
> iconClassName="h-6 w-6"
{isDoublePage ? ( className="rounded-full"
<LayoutTemplate className="h-6 w-6" /> />
) : ( <IconButton
<SplitSquareVertical className="h-6 w-6" /> variant="ghost"
)} size="icon"
</button> icon={direction === "rtl" ? MoveLeft : MoveRight}
<button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleDirection(); onToggleDirection();
}} }}
className="p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-colors" tooltip={t("reader.controls.direction.current", {
aria-label={t("reader.controls.direction.current", {
direction: t( direction: t(
direction === "ltr" direction === "ltr"
? "reader.controls.direction.ltr" ? "reader.controls.direction.ltr"
: "reader.controls.direction.rtl" : "reader.controls.direction.rtl"
), ),
})} })}
> iconClassName="h-6 w-6"
{direction === "rtl" ? ( className="rounded-full"
<MoveLeft className="h-6 w-6" /> />
) : ( <IconButton
<MoveRight className="h-6 w-6" /> variant="ghost"
)} size="icon"
</button> icon={isFullscreen ? Minimize2 : Maximize2}
<button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleFullscreen(); onToggleFullscreen();
}} }}
className="p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-colors" tooltip={t(
aria-label={t(
isFullscreen ? "reader.controls.fullscreen.exit" : "reader.controls.fullscreen.enter" isFullscreen ? "reader.controls.fullscreen.exit" : "reader.controls.fullscreen.enter"
)} )}
> iconClassName="h-6 w-6"
{isFullscreen ? <Minimize2 className="h-6 w-6" /> : <Maximize2 className="h-6 w-6" />} className="rounded-full"
</button> />
<button <IconButton
variant="ghost"
size="icon"
icon={Images}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onToggleThumbnails(); onToggleThumbnails();
}} }}
className={cn( tooltip={t(
"p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-colors",
showThumbnails && "ring-2 ring-primary"
)}
aria-label={t(
showThumbnails ? "reader.controls.thumbnails.hide" : "reader.controls.thumbnails.show" showThumbnails ? "reader.controls.thumbnails.hide" : "reader.controls.thumbnails.show"
)} )}
> iconClassName="h-6 w-6"
<Images className="h-6 w-6" /> className={cn("rounded-full", showThumbnails && "ring-2 ring-primary")}
</button> />
<div className="p-2 rounded-full bg-background/70 backdrop-blur-md" onClick={(e) => e.stopPropagation()}> <div className="p-2 rounded-full" onClick={(e) => e.stopPropagation()}>
<PageInput <PageInput
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
@@ -127,55 +125,61 @@ export const ControlButtons = ({
{/* Bouton fermer */} {/* Bouton fermer */}
{onClose && ( {onClose && (
<button <IconButton
variant="ghost"
size="icon"
icon={X}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClose(currentPage); onClose(currentPage);
}} }}
tooltip={t("reader.controls.close")}
iconClassName="h-6 w-6"
className={cn( className={cn(
"absolute top-4 right-4 p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-all duration-300 z-30", "absolute top-4 right-4 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-all duration-300 z-30",
showControls ? "opacity-100" : "opacity-0 pointer-events-none" showControls ? "opacity-100" : "opacity-0 pointer-events-none"
)} )}
aria-label={t("reader.controls.close")} />
>
<X className="h-6 w-6" />
</button>
)} )}
{/* Bouton précédent */} {/* Bouton précédent */}
{currentPage > 1 && ( {currentPage > 1 && (
<button <IconButton
variant="ghost"
size="icon"
icon={ChevronLeft}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onPreviousPage(); onPreviousPage();
}} }}
tooltip={t("reader.controls.previousPage")}
iconClassName="h-8 w-8"
className={cn( className={cn(
"absolute top-1/2 -translate-y-1/2 p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-all duration-300 z-20", "absolute top-1/2 -translate-y-1/2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-all duration-300 z-20",
direction === "rtl" ? "right-4" : "left-4", direction === "rtl" ? "right-4" : "left-4",
showControls ? "opacity-100" : "opacity-0 pointer-events-none" showControls ? "opacity-100" : "opacity-0 pointer-events-none"
)} )}
aria-label={t("reader.controls.previousPage")} />
>
<ChevronLeft className="h-8 w-8" />
</button>
)} )}
{/* Bouton suivant */} {/* Bouton suivant */}
{currentPage < totalPages && ( {currentPage < totalPages && (
<button <IconButton
variant="ghost"
size="icon"
icon={ChevronRight}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onNextPage(); onNextPage();
}} }}
tooltip={t("reader.controls.nextPage")}
iconClassName="h-8 w-8"
className={cn( className={cn(
"absolute top-1/2 -translate-y-1/2 p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-all duration-300 z-20", "absolute top-1/2 -translate-y-1/2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-all duration-300 z-20",
direction === "rtl" ? "left-4" : "right-4", direction === "rtl" ? "left-4" : "right-4",
showControls ? "opacity-100" : "opacity-0 pointer-events-none" showControls ? "opacity-100" : "opacity-0 pointer-events-none"
)} )}
aria-label={t("reader.controls.nextPage")} />
>
<ChevronRight className="h-8 w-8" />
</button>
)} )}
</> </>
); );

View File

@@ -3,7 +3,6 @@
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react"; import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
import type { KomgaSeries } from "@/types/komga"; import type { KomgaSeries } from "@/types/komga";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Button } from "../ui/button";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { RefreshButton } from "@/components/library/RefreshButton"; import { RefreshButton } from "@/components/library/RefreshButton";
import { AppError } from "@/utils/errors"; import { AppError } from "@/utils/errors";
@@ -11,6 +10,8 @@ import { ERROR_CODES } from "@/constants/errorCodes";
import { getErrorMessage } from "@/utils/errors"; import { getErrorMessage } from "@/utils/errors";
import { useTranslate } from "@/hooks/useTranslate"; import { useTranslate } from "@/hooks/useTranslate";
import { SeriesCover } from "@/components/ui/series-cover"; import { SeriesCover } from "@/components/ui/series-cover";
import { StatusBadge } from "@/components/ui/status-badge";
import { IconButton } from "@/components/ui/icon-button";
interface SeriesHeaderProps { interface SeriesHeaderProps {
series: KomgaSeries; series: KomgaSeries;
@@ -93,7 +94,7 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
if (booksReadCount === booksCount) { if (booksReadCount === booksCount) {
return { return {
label: t("series.header.status.read"), label: t("series.header.status.read"),
className: "bg-green-500/10 text-green-500", status: "success" as const,
icon: BookMarked, icon: BookMarked,
}; };
} }
@@ -104,14 +105,14 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
read: booksReadCount, read: booksReadCount,
total: booksCount, total: booksCount,
}), }),
className: "bg-blue-500/10 text-blue-500", status: "reading" as const,
icon: BookOpen, icon: BookOpen,
}; };
} }
return { return {
label: t("series.header.status.unread"), label: t("series.header.status.unread"),
className: "bg-yellow-500/10 text-yellow-500", status: "unread" as const,
icon: Book, icon: Book,
}; };
}; };
@@ -151,30 +152,24 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
</p> </p>
)} )}
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap"> <div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
<span <StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
className={`px-2 py-0.5 rounded-full text-sm flex items-center gap-1 ${statusInfo.className}`}
>
<statusInfo.icon className="w-4 h-4" />
{statusInfo.label} {statusInfo.label}
</span> </StatusBadge>
<span className="text-sm text-white/80"> <span className="text-sm text-white/80">
{series.booksCount === 1 {series.booksCount === 1
? t("series.header.books", { count: series.booksCount }) ? t("series.header.books", { count: series.booksCount })
: t("series.header.books_plural", { count: series.booksCount }) : t("series.header.books_plural", { count: series.booksCount })
} }
</span> </span>
<Button <IconButton
variant="ghost" variant="ghost"
size="icon" size="icon"
icon={isFavorite ? Star : StarOff}
onClick={handleToggleFavorite} onClick={handleToggleFavorite}
tooltip={t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add")}
className="text-white hover:text-white" className="text-white hover:text-white"
> iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
{isFavorite ? ( />
<Star className="w-5 h-5 fill-yellow-400 text-yellow-400" />
) : (
<StarOff className="w-5 h-5" />
)}
</Button>
<RefreshButton libraryId={series.id} refreshLibrary={refreshSeries} /> <RefreshButton libraryId={series.id} refreshLibrary={refreshSeries} />
</div> </div>
</div> </div>

View File

@@ -11,6 +11,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { GRADIENT_PRESETS } from "@/types/preferences"; import { GRADIENT_PRESETS } from "@/types/preferences";
import type { BackgroundType } from "@/types/preferences"; import type { BackgroundType } from "@/types/preferences";
import { Check } from "lucide-react"; import { Check } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
export function BackgroundSettings() { export function BackgroundSettings() {
const { t } = useTranslate(); const { t } = useTranslate();
@@ -94,16 +95,12 @@ export function BackgroundSettings() {
}; };
return ( return (
<div className="rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm"> <Card>
<div className="p-5 space-y-6"> <CardHeader>
<div> <CardTitle>{t("settings.background.title")}</CardTitle>
<h2 className="text-xl font-semibold flex items-center gap-2"> <CardDescription>{t("settings.background.description")}</CardDescription>
{t("settings.background.title")} </CardHeader>
</h2> <CardContent className="space-y-6">
<p className="text-sm text-muted-foreground mt-1">
{t("settings.background.description")}
</p>
</div>
<div className="space-y-6"> <div className="space-y-6">
{/* Type de background */} {/* Type de background */}
@@ -188,8 +185,8 @@ export function BackgroundSettings() {
</div> </div>
)} )}
</div> </div>
</div> </CardContent>
</div> </Card>
); );
} }

View File

@@ -7,6 +7,7 @@ import { Trash2, Loader2, HardDrive } from "lucide-react";
import { CacheModeSwitch } from "@/components/settings/CacheModeSwitch"; import { CacheModeSwitch } from "@/components/settings/CacheModeSwitch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import type { TTLConfigData } from "@/types/komga"; import type { TTLConfigData } from "@/types/komga";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
interface CacheSettingsProps { interface CacheSettingsProps {
initialTTLConfig: TTLConfigData | null; initialTTLConfig: TTLConfigData | null;
@@ -186,15 +187,15 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
}; };
return ( return (
<div className="rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm"> <Card>
<div className="p-5 space-y-4"> <CardHeader>
<div> <CardTitle className="flex items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2"> <Trash2 className="h-5 w-5" />
<Trash2 className="h-5 w-5" /> {t("settings.cache.title")}
{t("settings.cache.title")} </CardTitle>
</h2> <CardDescription>{t("settings.cache.description")}</CardDescription>
<p className="text-sm text-muted-foreground mt-1">{t("settings.cache.description")}</p> </CardHeader>
</div> <CardContent className="space-y-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="space-y-0.5"> <div className="space-y-0.5">
@@ -370,7 +371,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
</button> </button>
</div> </div>
</form> </form>
</div> </CardContent>
</div> </Card>
); );
} }

View File

@@ -3,6 +3,7 @@ import { usePreferences } from "@/contexts/PreferencesContext";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
export function DisplaySettings() { export function DisplaySettings() {
const { t } = useTranslate(); const { t } = useTranslate();
@@ -27,87 +28,82 @@ export function DisplaySettings() {
}; };
return ( return (
<div className="rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm"> <Card>
<div className="p-5 space-y-4"> <CardHeader>
<div> <CardTitle>{t("settings.display.title")}</CardTitle>
<h2 className="text-xl font-semibold flex items-center gap-2"> <CardDescription>{t("settings.display.description")}</CardDescription>
{t("settings.display.title")} </CardHeader>
</h2> <CardContent className="space-y-4">
<p className="text-sm text-muted-foreground mt-1">{t("settings.display.description")}</p> <div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="thumbnails">{t("settings.display.thumbnails.label")}</Label>
<p className="text-sm text-muted-foreground">
{t("settings.display.thumbnails.description")}
</p>
</div>
<Switch
id="thumbnails"
checked={preferences.showThumbnails}
onCheckedChange={handleToggleThumbnails}
/>
</div> </div>
<div className="flex items-center justify-between">
<div className="space-y-4"> <div className="space-y-0.5">
<div className="flex items-center justify-between"> <Label htmlFor="unread-filter">{t("settings.display.unreadFilter.label")}</Label>
<div className="space-y-0.5"> <p className="text-sm text-muted-foreground">
<Label htmlFor="thumbnails">{t("settings.display.thumbnails.label")}</Label> {t("settings.display.unreadFilter.description")}
<p className="text-sm text-muted-foreground"> </p>
{t("settings.display.thumbnails.description")}
</p>
</div>
<Switch
id="thumbnails"
checked={preferences.showThumbnails}
onCheckedChange={handleToggleThumbnails}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="unread-filter">{t("settings.display.unreadFilter.label")}</Label>
<p className="text-sm text-muted-foreground">
{t("settings.display.unreadFilter.description")}
</p>
</div>
<Switch
id="unread-filter"
checked={preferences.showOnlyUnread}
onCheckedChange={async (checked) => {
try {
await updatePreferences({ showOnlyUnread: checked });
toast({
title: t("settings.title"),
description: t("settings.komga.messages.configSaved"),
});
} catch (error) {
console.error("Erreur détaillée:", error);
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.error.message"),
});
}
}}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="debug-mode">{t("settings.display.debugMode.label")}</Label>
<p className="text-sm text-muted-foreground">
{t("settings.display.debugMode.description")}
</p>
</div>
<Switch
id="debug-mode"
checked={preferences.debug}
onCheckedChange={async (checked) => {
try {
await updatePreferences({ debug: checked });
toast({
title: t("settings.title"),
description: t("settings.komga.messages.configSaved"),
});
} catch (error) {
console.error("Erreur détaillée:", error);
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.error.message"),
});
}
}}
/>
</div> </div>
<Switch
id="unread-filter"
checked={preferences.showOnlyUnread}
onCheckedChange={async (checked) => {
try {
await updatePreferences({ showOnlyUnread: checked });
toast({
title: t("settings.title"),
description: t("settings.komga.messages.configSaved"),
});
} catch (error) {
console.error("Erreur détaillée:", error);
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.error.message"),
});
}
}}
/>
</div> </div>
</div> <div className="flex items-center justify-between">
</div> <div className="space-y-0.5">
<Label htmlFor="debug-mode">{t("settings.display.debugMode.label")}</Label>
<p className="text-sm text-muted-foreground">
{t("settings.display.debugMode.description")}
</p>
</div>
<Switch
id="debug-mode"
checked={preferences.debug}
onCheckedChange={async (checked) => {
try {
await updatePreferences({ debug: checked });
toast({
title: t("settings.title"),
description: t("settings.komga.messages.configSaved"),
});
} catch (error) {
console.error("Erreur détaillée:", error);
toast({
variant: "destructive",
title: t("settings.error.title"),
description: t("settings.error.message"),
});
}
}}
/>
</div>
</CardContent>
</Card>
); );
} }

View File

@@ -5,6 +5,7 @@ import { useTranslate } from "@/hooks/useTranslate";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { Network, Loader2 } from "lucide-react"; import { Network, Loader2 } from "lucide-react";
import type { KomgaConfig } from "@/types/komga"; import type { KomgaConfig } from "@/types/komga";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
interface KomgaSettingsProps { interface KomgaSettingsProps {
initialConfig: KomgaConfig | null; initialConfig: KomgaConfig | null;
@@ -144,15 +145,15 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
}; };
return ( return (
<div className="rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm"> <Card>
<div className="p-5 space-y-4"> <CardHeader>
<div> <CardTitle className="flex items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2"> <Network className="h-5 w-5" />
<Network className="h-5 w-5" /> {t("settings.komga.title")}
{t("settings.komga.title")} </CardTitle>
</h2> <CardDescription>{t("settings.komga.description")}</CardDescription>
<p className="text-sm text-muted-foreground mt-1">{t("settings.komga.description")}</p> </CardHeader>
</div> <CardContent className="space-y-4">
{!shouldShowForm ? ( {!shouldShowForm ? (
<div className="space-y-4"> <div className="space-y-4">
@@ -274,7 +275,7 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
</div> </div>
</form> </form>
)} )}
</div> </CardContent>
</div> </Card>
); );
} }

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const containerVariants = cva("mx-auto px-4 sm:px-6 lg:px-8", {
variants: {
size: {
default: "max-w-screen-2xl",
narrow: "max-w-4xl",
wide: "max-w-screen-3xl",
full: "max-w-full",
},
spacing: {
none: "",
sm: "py-4",
default: "py-8",
lg: "py-12",
},
},
defaultVariants: {
size: "default",
spacing: "default",
},
});
export interface ContainerProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof containerVariants> {
as?: React.ElementType;
}
const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
({ className, size, spacing, as: Component = "div", ...props }, ref) => {
return (
<Component
ref={ref}
className={cn(containerVariants({ size, spacing }), className)}
{...props}
/>
);
}
);
Container.displayName = "Container";
export { Container, containerVariants };

View File

@@ -0,0 +1,33 @@
import * as React from "react";
import { type LucideIcon } from "lucide-react";
import { Button, type ButtonProps } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface IconButtonProps extends Omit<ButtonProps, "children"> {
icon: LucideIcon;
label?: string;
tooltip?: string;
iconClassName?: string;
}
const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
({ icon: Icon, label, tooltip, iconClassName, className, ...props }, ref) => {
return (
<Button
ref={ref}
className={cn(label ? "" : "aspect-square", className)}
aria-label={tooltip || label}
title={tooltip}
{...props}
>
<Icon className={cn("h-4 w-4", label && "mr-2", iconClassName)} />
{label && <span>{label}</span>}
</Button>
);
}
);
IconButton.displayName = "IconButton";
export { IconButton };

View File

@@ -0,0 +1,39 @@
import * as React from "react";
import { type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface NavButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
icon: LucideIcon;
label: string;
active?: boolean;
count?: number;
}
const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
({ icon: Icon, label, active, count, className, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(
"w-full flex items-center justify-between rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors",
active && "bg-accent",
className
)}
{...props}
>
<div className="flex items-center">
<Icon className="mr-2 h-4 w-4" />
<span className="truncate">{label}</span>
</div>
{count !== undefined && (
<span className="text-xs text-muted-foreground">{count}</span>
)}
</button>
);
}
);
NavButton.displayName = "NavButton";
export { NavButton };

View File

@@ -0,0 +1,105 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ScrollContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
showArrows?: boolean;
scrollAmount?: number;
arrowLeftLabel?: string;
arrowRightLabel?: string;
}
const ScrollContainer = React.forwardRef<HTMLDivElement, ScrollContainerProps>(
(
{
children,
className,
showArrows = true,
scrollAmount = 400,
arrowLeftLabel = "Scroll left",
arrowRightLabel = "Scroll right",
...props
},
ref
) => {
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const [showLeftArrow, setShowLeftArrow] = React.useState(false);
const [showRightArrow, setShowRightArrow] = React.useState(true);
const handleScroll = React.useCallback(() => {
if (!scrollContainerRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
setShowLeftArrow(scrollLeft > 0);
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10);
}, []);
const scroll = React.useCallback(
(direction: "left" | "right") => {
if (!scrollContainerRef.current) return;
const scrollValue = direction === "left" ? -scrollAmount : scrollAmount;
scrollContainerRef.current.scrollBy({ left: scrollValue, behavior: "smooth" });
},
[scrollAmount]
);
React.useEffect(() => {
const container = scrollContainerRef.current;
if (!container) return;
handleScroll();
const resizeObserver = new ResizeObserver(handleScroll);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [handleScroll]);
return (
<div className="relative" ref={ref}>
{showArrows && showLeftArrow && (
<button
onClick={() => scroll("left")}
className="absolute left-0 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-background/90 shadow-md border transition-opacity hover:bg-accent"
aria-label={arrowLeftLabel}
>
<ChevronLeft className="h-6 w-6" />
</button>
)}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className={cn(
"flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4",
className
)}
{...props}
>
{children}
</div>
{showArrows && showRightArrow && (
<button
onClick={() => scroll("right")}
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-background/90 shadow-md border transition-opacity hover:bg-accent"
aria-label={arrowRightLabel}
>
<ChevronRight className="h-6 w-6" />
</button>
)}
</div>
);
}
);
ScrollContainer.displayName = "ScrollContainer";
export { ScrollContainer };

View File

@@ -0,0 +1,45 @@
import * as React from "react";
import { type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SectionProps extends React.HTMLAttributes<HTMLElement> {
title?: string;
icon?: LucideIcon;
description?: string;
actions?: React.ReactNode;
headerClassName?: string;
}
const Section = React.forwardRef<HTMLElement, SectionProps>(
(
{ title, icon: Icon, description, actions, children, className, headerClassName, ...props },
ref
) => {
return (
<section ref={ref} className={cn("space-y-4", className)} {...props}>
{(title || actions) && (
<div className={cn("flex items-center justify-between", headerClassName)}>
{title && (
<div className="flex items-center gap-2">
{Icon && <Icon className="h-5 w-5 text-muted-foreground" />}
<div>
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
</div>
)}
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)}
{children}
</section>
);
}
);
Section.displayName = "Section";
export { Section };

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import { type LucideIcon } from "lucide-react";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
const statusBadgeVariants = cva("flex items-center gap-1", {
variants: {
status: {
success: "bg-green-500/10 text-green-500 border-green-500/20",
warning: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
error: "bg-red-500/10 text-red-500 border-red-500/20",
info: "bg-blue-500/10 text-blue-500 border-blue-500/20",
reading: "bg-blue-500/10 text-blue-500 border-blue-500/20",
unread: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
},
},
defaultVariants: {
status: "info",
},
});
export interface StatusBadgeProps
extends Omit<BadgeProps, "variant">,
VariantProps<typeof statusBadgeVariants> {
icon?: LucideIcon;
children: React.ReactNode;
}
const StatusBadge = ({ status, icon: Icon, children, className, ...props }: StatusBadgeProps) => {
return (
<Badge
variant="outline"
className={cn(statusBadgeVariants({ status }), className)}
{...props}
>
{Icon && <Icon className="w-4 h-4" />}
{children}
</Badge>
);
};
export { StatusBadge, statusBadgeVariants };