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:
@@ -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,36 +160,35 @@ 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}
|
||||||
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{series.totalElements > 0 && (
|
{series.totalElements > 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
@@ -200,7 +201,8 @@ export function ClientLibraryPage({
|
|||||||
)}
|
)}
|
||||||
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
|
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
|
||||||
</div>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 && (
|
|
||||||
<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"
|
|
||||||
aria-label={t("navigation.scrollLeft")}
|
|
||||||
>
|
|
||||||
<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) => (
|
{items.map((item) => (
|
||||||
<MediaCard key={item.id} item={item} onClick={() => onItemClick?.(item)} />
|
<MediaCard key={item.id} item={item} onClick={() => onItemClick?.(item)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</ScrollContainer>
|
||||||
|
</Section>
|
||||||
{/* 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="/">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")}
|
||||||
</h2>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{t("settings.cache.description")}</p>
|
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 +28,12 @@ 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>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<Label htmlFor="thumbnails">{t("settings.display.thumbnails.label")}</Label>
|
<Label htmlFor="thumbnails">{t("settings.display.thumbnails.label")}</Label>
|
||||||
@@ -106,8 +103,7 @@ export function DisplaySettings() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")}
|
||||||
</h2>
|
</CardTitle>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{t("settings.komga.description")}</p>
|
<CardDescription>{t("settings.komga.description")}</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/components/ui/container.tsx
Normal file
47
src/components/ui/container.tsx
Normal 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 };
|
||||||
|
|
||||||
33
src/components/ui/icon-button.tsx
Normal file
33
src/components/ui/icon-button.tsx
Normal 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 };
|
||||||
|
|
||||||
39
src/components/ui/nav-button.tsx
Normal file
39
src/components/ui/nav-button.tsx
Normal 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 };
|
||||||
|
|
||||||
105
src/components/ui/scroll-container.tsx
Normal file
105
src/components/ui/scroll-container.tsx
Normal 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 };
|
||||||
|
|
||||||
45
src/components/ui/section.tsx
Normal file
45
src/components/ui/section.tsx
Normal 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 };
|
||||||
|
|
||||||
44
src/components/ui/status-badge.tsx
Normal file
44
src/components/ui/status-badge.tsx
Normal 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 };
|
||||||
|
|
||||||
Reference in New Issue
Block a user