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 { KomgaSeries, KomgaLibrary } from "@/types/komga";
|
||||
import type { UserPreferences } from "@/types/preferences";
|
||||
import { Container } from "@/components/ui/container";
|
||||
import { Section } from "@/components/ui/section";
|
||||
|
||||
interface ClientLibraryPageProps {
|
||||
currentPage: number;
|
||||
@@ -148,7 +150,7 @@ export function ClientLibraryPage({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<Container>
|
||||
<div className="flex items-center justify-between">
|
||||
<OptimizedSkeleton className="h-10 w-64" />
|
||||
<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" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">
|
||||
{library?.name || t("series.empty")}
|
||||
</h1>
|
||||
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
|
||||
</div>
|
||||
<Container>
|
||||
<Section
|
||||
title={library?.name || t("series.empty")}
|
||||
actions={<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />}
|
||||
/>
|
||||
<ErrorMessage errorCode={error} onRetry={handleRetry} />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!library || !series) {
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<Container>
|
||||
<ErrorMessage errorCode="SERIES_FETCH_ERROR" onRetry={handleRetry} />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">{library.name}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{series.totalElements > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("series.display.showing", {
|
||||
start: ((currentPage - 1) * effectivePageSize) + 1,
|
||||
end: Math.min(currentPage * effectivePageSize, series.totalElements),
|
||||
total: series.totalElements,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
|
||||
</div>
|
||||
</div>
|
||||
<Container>
|
||||
<Section
|
||||
title={library.name}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{series.totalElements > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("series.display.showing", {
|
||||
start: ((currentPage - 1) * effectivePageSize) + 1,
|
||||
end: Math.min(currentPage * effectivePageSize, series.totalElements),
|
||||
total: series.totalElements,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<RefreshButton libraryId={libraryId} refreshLibrary={handleRefresh} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PaginatedSeriesGrid
|
||||
series={series.content || []}
|
||||
currentPage={currentPage}
|
||||
@@ -209,6 +211,6 @@ export function ClientLibraryPage({
|
||||
defaultShowOnlyUnread={preferences.showOnlyUnread}
|
||||
showOnlyUnread={unreadOnly}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { AdminUserData } from "@/lib/services/admin.service";
|
||||
import { EditUserDialog } from "./EditUserDialog";
|
||||
import { DeleteUserDialog } from "./DeleteUserDialog";
|
||||
import { ResetPasswordDialog } from "./ResetPasswordDialog";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
|
||||
interface UsersTableProps {
|
||||
users: AdminUserData[];
|
||||
@@ -29,7 +30,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-lg border bg-card/70 backdrop-blur-md shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -67,28 +68,24 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.hasKomgaConfig ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span className="text-xs text-green-600 dark:text-green-400">Configuré</span>
|
||||
</div>
|
||||
<StatusBadge status="success" icon={Check}>
|
||||
Configuré
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
<span className="text-xs text-red-600 dark:text-red-400">Non configuré</span>
|
||||
</div>
|
||||
<StatusBadge status="error" icon={X}>
|
||||
Non configuré
|
||||
</StatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.hasPreferences ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span className="text-xs text-green-600 dark:text-green-400">Oui</span>
|
||||
</div>
|
||||
<StatusBadge status="success" icon={Check}>
|
||||
Oui
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<X className="h-4 w-4 text-red-500" />
|
||||
<span className="text-xs text-red-600 dark:text-red-400">Non</span>
|
||||
</div>
|
||||
<StatusBadge status="error" icon={X}>
|
||||
Non
|
||||
</StatusBadge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{user._count?.favorites || 0}</TableCell>
|
||||
@@ -134,7 +131,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
|
||||
<EditUserDialog
|
||||
user={editingUser}
|
||||
open={!!editingUser}
|
||||
onOpenChange={(open) => !open && setEditingUser(null)}
|
||||
onOpenChange={(open: boolean) => !open && setEditingUser(null)}
|
||||
onSuccess={() => {
|
||||
setEditingUser(null);
|
||||
onUserUpdated();
|
||||
@@ -146,7 +143,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
|
||||
<ResetPasswordDialog
|
||||
user={resettingPasswordUser}
|
||||
open={!!resettingPasswordUser}
|
||||
onOpenChange={(open) => !open && setResettingPasswordUser(null)}
|
||||
onOpenChange={(open: boolean) => !open && setResettingPasswordUser(null)}
|
||||
onSuccess={() => {
|
||||
setResettingPasswordUser(null);
|
||||
}}
|
||||
@@ -157,7 +154,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
|
||||
<DeleteUserDialog
|
||||
user={deletingUser}
|
||||
open={!!deletingUser}
|
||||
onOpenChange={(open) => !open && setDeletingUser(null)}
|
||||
onOpenChange={(open: boolean) => !open && setDeletingUser(null)}
|
||||
onSuccess={() => {
|
||||
setDeletingUser(null);
|
||||
onUserUpdated();
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
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 {
|
||||
from?: string;
|
||||
@@ -49,66 +53,37 @@ export function LoginForm({ from }: LoginFormProps) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t("login.form.email")}
|
||||
</label>
|
||||
<input
|
||||
<Label htmlFor="email">{t("login.form.email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
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 className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t("login.form.password")}
|
||||
</label>
|
||||
<input
|
||||
<Label htmlFor="password">{t("login.form.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
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 className="flex items-center space-x-2">
|
||||
<input
|
||||
id="remember"
|
||||
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"
|
||||
>
|
||||
<Checkbox id="remember" name="remember" defaultChecked />
|
||||
<Label htmlFor="remember" className="cursor-pointer">
|
||||
{t("login.form.remember")}
|
||||
</label>
|
||||
</Label>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-red-600 text-sm">
|
||||
{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"
|
||||
>
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{isLoading ? t("login.form.submit.loading.login") : t("login.form.submit.login")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { signIn } from "next-auth/react";
|
||||
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
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 {
|
||||
from?: string;
|
||||
@@ -88,61 +91,33 @@ export function RegisterForm({ from: _from }: RegisterFormProps) {
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="email"
|
||||
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"
|
||||
/>
|
||||
<Label htmlFor="email">{t("login.form.email")}</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="email" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t("login.form.password")}
|
||||
</label>
|
||||
<input
|
||||
<Label htmlFor="password">{t("login.form.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
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 className="space-y-2">
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{t("login.form.confirmPassword")}
|
||||
</label>
|
||||
<input
|
||||
<Label htmlFor="confirmPassword">{t("login.form.confirmPassword")}</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
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>
|
||||
{error && <ErrorMessage errorCode={error.code} variant="form" />}
|
||||
<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"
|
||||
>
|
||||
<Button type="submit" disabled={isLoading} className="w-full">
|
||||
{isLoading ? t("login.form.submit.loading.register") : t("login.form.submit.register")}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { LayoutGrid, LayoutTemplate } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface CompactModeButtonProps {
|
||||
onToggle?: (isCompact: boolean) => void;
|
||||
@@ -16,16 +17,19 @@ export function CompactModeButton({ onToggle }: CompactModeButtonProps) {
|
||||
onToggle?.(newCompactState);
|
||||
};
|
||||
|
||||
const Icon = isCompact ? LayoutTemplate : LayoutGrid;
|
||||
const label = isCompact ? t("series.filters.normal") : t("series.filters.compact");
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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={isCompact ? t("series.filters.normal") : t("series.filters.compact")}
|
||||
title={label}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{isCompact ? <LayoutTemplate className="h-4 w-4" /> : <LayoutGrid className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">
|
||||
{isCompact ? t("series.filters.normal") : t("series.filters.compact")}
|
||||
</span>
|
||||
</button>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="hidden sm:inline ml-2">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { Filter } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface UnreadFilterButtonProps {
|
||||
showOnlyUnread: boolean;
|
||||
@@ -11,16 +12,18 @@ interface UnreadFilterButtonProps {
|
||||
export function UnreadFilterButton({ showOnlyUnread, onToggle }: UnreadFilterButtonProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const label = showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread");
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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={showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread")}
|
||||
title={label}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread")}
|
||||
</span>
|
||||
</button>
|
||||
<span className="hidden sm:inline ml-2">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
||||
<MediaRow
|
||||
title={t("home.sections.continue_series")}
|
||||
items={optimizeSeriesData(data.ongoing)}
|
||||
icon={<LibraryBig className="w-6 h-6" />}
|
||||
icon={LibraryBig}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
||||
<MediaRow
|
||||
title={t("home.sections.continue_reading")}
|
||||
items={optimizeBookData(data.ongoingBooks)}
|
||||
icon={<BookOpen className="w-6 h-6" />}
|
||||
icon={BookOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -97,7 +97,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
||||
<MediaRow
|
||||
title={t("home.sections.up_next")}
|
||||
items={optimizeBookData(data.onDeck)}
|
||||
icon={<Clock className="w-6 h-6" />}
|
||||
icon={Clock}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
||||
<MediaRow
|
||||
title={t("home.sections.latest_series")}
|
||||
items={optimizeSeriesData(data.latestSeries)}
|
||||
icon={<Sparkles className="w-6 h-6" />}
|
||||
icon={Sparkles}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -113,7 +113,7 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
|
||||
<MediaRow
|
||||
title={t("home.sections.recently_added")}
|
||||
items={optimizeBookData(data.recentlyRead)}
|
||||
icon={<History className="w-6 h-6" />}
|
||||
icon={History}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { KomgaBook, KomgaSeries } from "@/types/komga";
|
||||
import { BookCover } from "../ui/book-cover";
|
||||
import { SeriesCover } from "../ui/series-cover";
|
||||
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 {
|
||||
id: string;
|
||||
@@ -36,13 +38,10 @@ interface OptimizedBook extends BaseItem {
|
||||
interface MediaRowProps {
|
||||
title: string;
|
||||
items: (OptimizedSeries | OptimizedBook)[];
|
||||
icon?: React.ReactNode;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
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 { t } = useTranslate();
|
||||
|
||||
@@ -51,64 +50,21 @@ export function MediaRow({ title, items, icon }: MediaRowProps) {
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<h2 className="text-2xl font-bold tracking-tight">{title}</h2>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{/* 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) => (
|
||||
<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>
|
||||
<Section title={title} icon={icon}>
|
||||
<ScrollContainer
|
||||
showArrows={true}
|
||||
scrollAmount={400}
|
||||
arrowLeftLabel={t("navigation.scrollLeft")}
|
||||
arrowRightLabel={t("navigation.scrollRight")}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<MediaCard key={item.id} item={item} onClick={() => onItemClick?.(item)} />
|
||||
))}
|
||||
</ScrollContainer>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,9 +84,9 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
: "");
|
||||
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
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">
|
||||
{isSeries ? (
|
||||
@@ -154,6 +110,6 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Menu, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import LanguageSelector from "@/components/LanguageSelector";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
@@ -18,16 +19,15 @@ export function Header({ onToggleSidebar }: HeaderProps) {
|
||||
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">
|
||||
<div className="container flex h-14 max-w-screen-2xl items-center">
|
||||
<button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={Menu}
|
||||
onClick={onToggleSidebar}
|
||||
className="mr-2 px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md"
|
||||
aria-label={t("header.toggleSidebar")}
|
||||
tooltip={t("header.toggleSidebar")}
|
||||
className="mr-2"
|
||||
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">
|
||||
<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 { useToast } from "@/components/ui/use-toast";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { NavButton } from "@/components/ui/nav-button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -180,17 +182,13 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
|
||||
{t("sidebar.navigation")}
|
||||
</h2>
|
||||
{mainNavItems.map((item) => (
|
||||
<button
|
||||
<NavButton
|
||||
key={item.href}
|
||||
icon={item.icon}
|
||||
label={item.title}
|
||||
active={pathname === 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>
|
||||
@@ -213,17 +211,14 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
|
||||
</div>
|
||||
) : (
|
||||
favorites.map((series) => (
|
||||
<button
|
||||
<NavButton
|
||||
key={series.id}
|
||||
icon={Star}
|
||||
label={series.metadata.title}
|
||||
active={pathname === `/series/${series.id}`}
|
||||
onClick={() => handleLinkClick(`/series/${series.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 === `/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>
|
||||
className="[&_svg]:fill-yellow-400 [&_svg]:text-yellow-400"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -235,14 +230,16 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{t("sidebar.libraries.title")}
|
||||
</h2>
|
||||
<button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={RefreshCw}
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="p-1 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors"
|
||||
aria-label={t("sidebar.libraries.refresh")}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
|
||||
</button>
|
||||
tooltip={t("sidebar.libraries.refresh")}
|
||||
iconClassName={cn(isRefreshing && "animate-spin")}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
</div>
|
||||
{isRefreshing ? (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
@@ -254,17 +251,13 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
|
||||
</div>
|
||||
) : (
|
||||
libraries.map((library) => (
|
||||
<button
|
||||
<NavButton
|
||||
key={library.id}
|
||||
icon={Library}
|
||||
label={library.name}
|
||||
active={pathname === `/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>
|
||||
@@ -275,50 +268,37 @@ export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, u
|
||||
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
||||
{t("sidebar.settings.title")}
|
||||
</h2>
|
||||
<button
|
||||
<NavButton
|
||||
icon={User}
|
||||
label={t("sidebar.account")}
|
||||
active={pathname === "/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",
|
||||
pathname === "/account" ? "bg-accent" : "transparent"
|
||||
)}
|
||||
>
|
||||
<User className="mr-2 h-4 w-4" />
|
||||
{t("sidebar.account")}
|
||||
</button>
|
||||
<button
|
||||
/>
|
||||
<NavButton
|
||||
icon={Settings}
|
||||
label={t("sidebar.settings.preferences")}
|
||||
active={pathname === "/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 && (
|
||||
<button
|
||||
<NavButton
|
||||
icon={Shield}
|
||||
label={t("sidebar.admin")}
|
||||
active={pathname === "/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 className="p-3 border-t border-border/40">
|
||||
<button
|
||||
<NavButton
|
||||
icon={LogOut}
|
||||
label={t("sidebar.logout")}
|
||||
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"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("sidebar.logout")}
|
||||
</button>
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
|
||||
import { PageSizeSelect } from "@/components/common/PageSizeSelect";
|
||||
import { CompactModeButton } from "@/components/common/CompactModeButton";
|
||||
import { UnreadFilterButton } from "@/components/common/UnreadFilterButton";
|
||||
import { Container } from "@/components/ui/container";
|
||||
|
||||
interface PaginatedSeriesGridProps {
|
||||
series: KomgaSeries[];
|
||||
@@ -97,7 +98,7 @@ export function PaginatedSeriesGrid({
|
||||
};
|
||||
|
||||
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 sm:flex-row sm:items-center gap-4">
|
||||
<div className="w-full">
|
||||
@@ -124,6 +125,6 @@ export function PaginatedSeriesGrid({
|
||||
className="order-1 sm:order-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageInput } from "./PageInput";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
|
||||
export const ControlButtons = ({
|
||||
showControls,
|
||||
@@ -40,7 +41,7 @@ export const ControlButtons = ({
|
||||
{/* Boutons de contrôle */}
|
||||
<div
|
||||
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"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@@ -48,72 +49,69 @@ export const ControlButtons = ({
|
||||
onToggleControls();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={isDoublePage ? LayoutTemplate : SplitSquareVertical}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleDoublePage();
|
||||
}}
|
||||
className="p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-colors"
|
||||
aria-label={t(
|
||||
tooltip={t(
|
||||
isDoublePage
|
||||
? "reader.controls.doublePage.disable"
|
||||
: "reader.controls.doublePage.enable"
|
||||
)}
|
||||
>
|
||||
{isDoublePage ? (
|
||||
<LayoutTemplate className="h-6 w-6" />
|
||||
) : (
|
||||
<SplitSquareVertical className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
iconClassName="h-6 w-6"
|
||||
className="rounded-full"
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={direction === "rtl" ? MoveLeft : MoveRight}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleDirection();
|
||||
}}
|
||||
className="p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-colors"
|
||||
aria-label={t("reader.controls.direction.current", {
|
||||
tooltip={t("reader.controls.direction.current", {
|
||||
direction: t(
|
||||
direction === "ltr"
|
||||
? "reader.controls.direction.ltr"
|
||||
: "reader.controls.direction.rtl"
|
||||
),
|
||||
})}
|
||||
>
|
||||
{direction === "rtl" ? (
|
||||
<MoveLeft className="h-6 w-6" />
|
||||
) : (
|
||||
<MoveRight className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
iconClassName="h-6 w-6"
|
||||
className="rounded-full"
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={isFullscreen ? Minimize2 : Maximize2}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFullscreen();
|
||||
}}
|
||||
className="p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-colors"
|
||||
aria-label={t(
|
||||
tooltip={t(
|
||||
isFullscreen ? "reader.controls.fullscreen.exit" : "reader.controls.fullscreen.enter"
|
||||
)}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-6 w-6" /> : <Maximize2 className="h-6 w-6" />}
|
||||
</button>
|
||||
<button
|
||||
iconClassName="h-6 w-6"
|
||||
className="rounded-full"
|
||||
/>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={Images}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleThumbnails();
|
||||
}}
|
||||
className={cn(
|
||||
"p-2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-colors",
|
||||
showThumbnails && "ring-2 ring-primary"
|
||||
)}
|
||||
aria-label={t(
|
||||
tooltip={t(
|
||||
showThumbnails ? "reader.controls.thumbnails.hide" : "reader.controls.thumbnails.show"
|
||||
)}
|
||||
>
|
||||
<Images className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="p-2 rounded-full bg-background/70 backdrop-blur-md" onClick={(e) => e.stopPropagation()}>
|
||||
iconClassName="h-6 w-6"
|
||||
className={cn("rounded-full", showThumbnails && "ring-2 ring-primary")}
|
||||
/>
|
||||
<div className="p-2 rounded-full" onClick={(e) => e.stopPropagation()}>
|
||||
<PageInput
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
@@ -127,55 +125,61 @@ export const ControlButtons = ({
|
||||
|
||||
{/* Bouton fermer */}
|
||||
{onClose && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={X}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose(currentPage);
|
||||
}}
|
||||
tooltip={t("reader.controls.close")}
|
||||
iconClassName="h-6 w-6"
|
||||
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"
|
||||
)}
|
||||
aria-label={t("reader.controls.close")}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bouton précédent */}
|
||||
{currentPage > 1 && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={ChevronLeft}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPreviousPage();
|
||||
}}
|
||||
tooltip={t("reader.controls.previousPage")}
|
||||
iconClassName="h-8 w-8"
|
||||
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",
|
||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
)}
|
||||
aria-label={t("reader.controls.previousPage")}
|
||||
>
|
||||
<ChevronLeft className="h-8 w-8" />
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bouton suivant */}
|
||||
{currentPage < totalPages && (
|
||||
<button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={ChevronRight}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNextPage();
|
||||
}}
|
||||
tooltip={t("reader.controls.nextPage")}
|
||||
iconClassName="h-8 w-8"
|
||||
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",
|
||||
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 type { KomgaSeries } from "@/types/komga";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
||||
import { AppError } from "@/utils/errors";
|
||||
@@ -11,6 +10,8 @@ import { ERROR_CODES } from "@/constants/errorCodes";
|
||||
import { getErrorMessage } from "@/utils/errors";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { SeriesCover } from "@/components/ui/series-cover";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
|
||||
interface SeriesHeaderProps {
|
||||
series: KomgaSeries;
|
||||
@@ -93,7 +94,7 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
|
||||
if (booksReadCount === booksCount) {
|
||||
return {
|
||||
label: t("series.header.status.read"),
|
||||
className: "bg-green-500/10 text-green-500",
|
||||
status: "success" as const,
|
||||
icon: BookMarked,
|
||||
};
|
||||
}
|
||||
@@ -104,14 +105,14 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
|
||||
read: booksReadCount,
|
||||
total: booksCount,
|
||||
}),
|
||||
className: "bg-blue-500/10 text-blue-500",
|
||||
status: "reading" as const,
|
||||
icon: BookOpen,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: t("series.header.status.unread"),
|
||||
className: "bg-yellow-500/10 text-yellow-500",
|
||||
status: "unread" as const,
|
||||
icon: Book,
|
||||
};
|
||||
};
|
||||
@@ -151,30 +152,24 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-sm flex items-center gap-1 ${statusInfo.className}`}
|
||||
>
|
||||
<statusInfo.icon className="w-4 h-4" />
|
||||
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
<span className="text-sm text-white/80">
|
||||
{series.booksCount === 1
|
||||
? t("series.header.books", { count: series.booksCount })
|
||||
: t("series.header.books_plural", { count: series.booksCount })
|
||||
}
|
||||
</span>
|
||||
<Button
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
icon={isFavorite ? Star : StarOff}
|
||||
onClick={handleToggleFavorite}
|
||||
tooltip={t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add")}
|
||||
className="text-white hover:text-white"
|
||||
>
|
||||
{isFavorite ? (
|
||||
<Star className="w-5 h-5 fill-yellow-400 text-yellow-400" />
|
||||
) : (
|
||||
<StarOff className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
|
||||
/>
|
||||
<RefreshButton libraryId={series.id} refreshLibrary={refreshSeries} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { GRADIENT_PRESETS } from "@/types/preferences";
|
||||
import type { BackgroundType } from "@/types/preferences";
|
||||
import { Check } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
export function BackgroundSettings() {
|
||||
const { t } = useTranslate();
|
||||
@@ -94,16 +95,12 @@ export function BackgroundSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm">
|
||||
<div className="p-5 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
{t("settings.background.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("settings.background.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.background.title")}</CardTitle>
|
||||
<CardDescription>{t("settings.background.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Type de background */}
|
||||
@@ -188,8 +185,8 @@ export function BackgroundSettings() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Trash2, Loader2, HardDrive } from "lucide-react";
|
||||
import { CacheModeSwitch } from "@/components/settings/CacheModeSwitch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { TTLConfigData } from "@/types/komga";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
interface CacheSettingsProps {
|
||||
initialTTLConfig: TTLConfigData | null;
|
||||
@@ -186,15 +187,15 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm">
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
{t("settings.cache.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("settings.cache.description")}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5" />
|
||||
{t("settings.cache.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("settings.cache.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="space-y-0.5">
|
||||
@@ -370,7 +371,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { usePreferences } from "@/contexts/PreferencesContext";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
export function DisplaySettings() {
|
||||
const { t } = useTranslate();
|
||||
@@ -27,87 +28,82 @@ export function DisplaySettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm">
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
{t("settings.display.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("settings.display.description")}</p>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings.display.title")}</CardTitle>
|
||||
<CardDescription>{t("settings.display.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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 className="space-y-4">
|
||||
<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 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 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>
|
||||
</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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Network, Loader2 } from "lucide-react";
|
||||
import type { KomgaConfig } from "@/types/komga";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
interface KomgaSettingsProps {
|
||||
initialConfig: KomgaConfig | null;
|
||||
@@ -144,15 +145,15 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm">
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Network className="h-5 w-5" />
|
||||
{t("settings.komga.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("settings.komga.description")}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Network className="h-5 w-5" />
|
||||
{t("settings.komga.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("settings.komga.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
{!shouldShowForm ? (
|
||||
<div className="space-y-4">
|
||||
@@ -274,7 +275,7 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</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