chore: update various components and services for improved functionality and consistency, including formatting adjustments and minor refactors

This commit is contained in:
Julien Froidefond
2025-12-07 09:54:05 +01:00
parent 4f5724c0ff
commit 39e3328123
141 changed files with 5292 additions and 3243 deletions

View File

@@ -75,7 +75,8 @@ export function ChangePasswordForm() {
<CardHeader>
<CardTitle>Changer le mot de passe</CardTitle>
<CardDescription>
Assurez-vous d&apos;utiliser un mot de passe fort (8 caractères minimum, une majuscule et un chiffre)
Assurez-vous d&apos;utiliser un mot de passe fort (8 caractères minimum, une majuscule et
un chiffre)
</CardDescription>
</CardHeader>
<CardContent>
@@ -136,4 +137,3 @@ export function ChangePasswordForm() {
</Card>
);
}

View File

@@ -6,7 +6,9 @@ import { Mail, Calendar, Shield, Heart } from "lucide-react";
import type { UserProfile } from "@/lib/services/user.service";
interface UserProfileCardProps {
profile: UserProfile & { stats: { favoritesCount: number; hasPreferences: boolean; hasKomgaConfig: boolean } };
profile: UserProfile & {
stats: { favoritesCount: number; hasPreferences: boolean; hasKomgaConfig: boolean };
};
}
export function UserProfileCard({ profile }: UserProfileCardProps) {
@@ -65,12 +67,10 @@ export function UserProfileCard({ profile }: UserProfileCardProps) {
<div className="pt-4 border-t">
<p className="text-xs text-muted-foreground">
Dernière mise à jour:{" "}
{new Date(profile.updatedAt).toLocaleDateString("fr-FR")}
Dernière mise à jour: {new Date(profile.updatedAt).toLocaleDateString("fr-FR")}
</p>
</div>
</CardContent>
</Card>
);
}

View File

@@ -36,10 +36,7 @@ export function AdminContent({ initialUsers, initialStats }: AdminContentProps)
throw new Error("Erreur lors du rafraîchissement");
}
const [newUsers, newStats] = await Promise.all([
usersResponse.json(),
statsResponse.json(),
]);
const [newUsers, newStats] = await Promise.all([usersResponse.json(), statsResponse.json()]);
setUsers(newUsers);
setStats(newStats);
@@ -65,9 +62,7 @@ export function AdminContent({ initialUsers, initialStats }: AdminContentProps)
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Administration</h1>
<p className="text-muted-foreground mt-2">
Gérez les utilisateurs de la plateforme
</p>
<p className="text-muted-foreground mt-2">Gérez les utilisateurs de la plateforme</p>
</div>
<Button onClick={refreshData} disabled={isRefreshing}>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
@@ -85,4 +80,3 @@ export function AdminContent({ initialUsers, initialStats }: AdminContentProps)
</div>
);
}

View File

@@ -21,12 +21,7 @@ interface DeleteUserDialogProps {
onSuccess: () => void;
}
export function DeleteUserDialog({
user,
open,
onOpenChange,
onSuccess,
}: DeleteUserDialogProps) {
export function DeleteUserDialog({ user, open, onOpenChange, onSuccess }: DeleteUserDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
@@ -89,4 +84,3 @@ export function DeleteUserDialog({
</AlertDialog>
);
}

View File

@@ -27,12 +27,7 @@ const AVAILABLE_ROLES = [
{ value: "ROLE_ADMIN", label: "Admin" },
];
export function EditUserDialog({
user,
open,
onOpenChange,
onSuccess,
}: EditUserDialogProps) {
export function EditUserDialog({ user, open, onOpenChange, onSuccess }: EditUserDialogProps) {
const [selectedRoles, setSelectedRoles] = useState<string[]>(user.roles);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
@@ -125,4 +120,3 @@ export function EditUserDialog({
</Dialog>
);
}

View File

@@ -152,11 +152,7 @@ export function ResetPasswordDialog({
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isLoading}
>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isLoading}>
Annuler
</Button>
<Button onClick={handleSubmit} disabled={isLoading}>
@@ -167,4 +163,3 @@ export function ResetPasswordDialog({
</Dialog>
);
}

View File

@@ -60,4 +60,3 @@ export function StatsCards({ stats }: StatsCardsProps) {
</div>
);
}

View File

@@ -57,10 +57,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
<TableCell>
<div className="flex gap-1">
{user.roles.map((role) => (
<Badge
key={role}
variant={role === "ROLE_ADMIN" ? "default" : "secondary"}
>
<Badge key={role} variant={role === "ROLE_ADMIN" ? "default" : "secondary"}>
{role.replace("ROLE_", "")}
</Badge>
))}
@@ -89,9 +86,7 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
)}
</TableCell>
<TableCell>{user._count?.favorites || 0}</TableCell>
<TableCell>
{new Date(user.createdAt).toLocaleDateString("fr-FR")}
</TableCell>
<TableCell>{new Date(user.createdAt).toLocaleDateString("fr-FR")}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
@@ -164,4 +159,3 @@ export function UsersTable({ users, onUserUpdated }: UsersTableProps) {
</>
);
}

View File

@@ -52,11 +52,13 @@ export function RegisterForm({ from: _from }: RegisterFormProps) {
if (!response.ok) {
const data = await response.json();
setError(data.error || {
code: "AUTH_REGISTRATION_FAILED",
name: "Registration failed",
message: "Erreur lors de l'inscription",
});
setError(
data.error || {
code: "AUTH_REGISTRATION_FAILED",
name: "Registration failed",
message: "Erreur lors de l'inscription",
}
);
return;
}
@@ -96,13 +98,7 @@ export function RegisterForm({ from: _from }: RegisterFormProps) {
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("login.form.password")}</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
/>
<Input id="password" name="password" type="password" autoComplete="new-password" required />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t("login.form.confirmPassword")}</Label>

View File

@@ -28,7 +28,9 @@ export function PullToRefreshIndicator({
className={cn(
"fixed top-0 left-1/2 transform -translate-x-1/2 z-50 transition-all",
isHiding ? "duration-300 ease-out" : "duration-200",
(isPulling || isRefreshing) && !isHiding ? "translate-y-0 opacity-100" : "-translate-y-full opacity-0"
(isPulling || isRefreshing) && !isHiding
? "translate-y-0 opacity-100"
: "-translate-y-full opacity-0"
)}
style={{
transform: `translate(-50%, ${(isPulling || isRefreshing) && !isHiding ? (isRefreshing ? 60 : progress * 60) : -100}px)`,
@@ -40,27 +42,26 @@ export function PullToRefreshIndicator({
<div
className={cn(
"h-full transition-all duration-200 rounded-full",
(canRefresh || isRefreshing) ? "bg-primary" : "bg-muted-foreground"
canRefresh || isRefreshing ? "bg-primary" : "bg-muted-foreground"
)}
style={{
width: `${isRefreshing ? 200 : barWidth}px`,
}}
/>
</div>
{/* Icône centrée */}
<div className="flex justify-center mt-2">
<div
className={cn(
"flex items-center justify-center w-8 h-8 rounded-full transition-all duration-200",
(canRefresh || isRefreshing) ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
canRefresh || isRefreshing
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
)}
>
<RefreshCw
className={cn(
"h-4 w-4 transition-all duration-200",
isRefreshing && "animate-spin"
)}
className={cn("h-4 w-4 transition-all duration-200", isRefreshing && "animate-spin")}
style={{
transform: isRefreshing ? "rotate(0deg)" : `rotate(${rotation}deg)`,
animationDuration: isRefreshing ? "2s" : undefined,
@@ -68,15 +69,21 @@ export function PullToRefreshIndicator({
/>
</div>
</div>
{/* Message */}
<div
className={cn(
"mt-2 text-center text-xs transition-opacity duration-200",
(canRefresh || isRefreshing) ? "text-primary opacity-100" : "text-muted-foreground opacity-70"
canRefresh || isRefreshing
? "text-primary opacity-100"
: "text-muted-foreground opacity-70"
)}
>
{isRefreshing ? "Actualisation..." : canRefresh ? "Relâchez pour actualiser" : "Tirez pour actualiser"}
{isRefreshing
? "Actualisation..."
: canRefresh
? "Relâchez pour actualiser"
: "Tirez pour actualiser"}
</div>
</div>
);

View File

@@ -33,4 +33,3 @@ export function ViewModeButton({ onToggle }: ViewModeButtonProps) {
</Button>
);
}

View File

@@ -201,25 +201,8 @@ export function DownloadManager() {
)}
</div>
<TabsContent value="all" className="space-y-4">
{downloadedBooks.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
status={status}
onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.length === 0 && (
<p className="text-center text-muted-foreground p-8">{t("downloads.empty.all")}</p>
)}
</TabsContent>
<TabsContent value="downloading" className="space-y-4">
{downloadedBooks
.filter((b) => b.status.status === "downloading")
.map(({ book, status }) => (
<TabsContent value="all" className="space-y-4">
{downloadedBooks.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
@@ -228,49 +211,66 @@ export function DownloadManager() {
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.filter((b) => b.status.status === "downloading").length === 0 && (
<p className="text-center text-muted-foreground p-8">
{t("downloads.empty.downloading")}
</p>
)}
</TabsContent>
{downloadedBooks.length === 0 && (
<p className="text-center text-muted-foreground p-8">{t("downloads.empty.all")}</p>
)}
</TabsContent>
<TabsContent value="available" className="space-y-4">
{downloadedBooks
.filter((b) => b.status.status === "available")
.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
status={status}
onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.filter((b) => b.status.status === "available").length === 0 && (
<p className="text-center text-muted-foreground p-8">
{t("downloads.empty.available")}
</p>
)}
</TabsContent>
<TabsContent value="downloading" className="space-y-4">
{downloadedBooks
.filter((b) => b.status.status === "downloading")
.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
status={status}
onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.filter((b) => b.status.status === "downloading").length === 0 && (
<p className="text-center text-muted-foreground p-8">
{t("downloads.empty.downloading")}
</p>
)}
</TabsContent>
<TabsContent value="error" className="space-y-4">
{downloadedBooks
.filter((b) => b.status.status === "error")
.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
status={status}
onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.filter((b) => b.status.status === "error").length === 0 && (
<p className="text-center text-muted-foreground p-8">{t("downloads.empty.error")}</p>
)}
</TabsContent>
</Tabs>
<TabsContent value="available" className="space-y-4">
{downloadedBooks
.filter((b) => b.status.status === "available")
.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
status={status}
onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.filter((b) => b.status.status === "available").length === 0 && (
<p className="text-center text-muted-foreground p-8">
{t("downloads.empty.available")}
</p>
)}
</TabsContent>
<TabsContent value="error" className="space-y-4">
{downloadedBooks
.filter((b) => b.status.status === "error")
.map(({ book, status }) => (
<BookDownloadCard
key={book.id}
book={book}
status={status}
onDelete={() => handleDeleteBook(book)}
onRetry={() => handleRetryDownload(book)}
/>
))}
{downloadedBooks.filter((b) => b.status.status === "error").length === 0 && (
<p className="text-center text-muted-foreground p-8">{t("downloads.empty.error")}</p>
)}
</TabsContent>
</Tabs>
</div>
</Container>
);

View File

@@ -23,19 +23,19 @@ export function ClientHomePage() {
try {
const response = await fetch("/api/komga/home", {
cache: 'default' // Utilise le cache HTTP du navigateur
cache: "default", // Utilise le cache HTTP du navigateur
});
if (!response.ok) {
const errorData = await response.json();
const errorCode = errorData.error?.code || ERROR_CODES.KOMGA.SERVER_UNREACHABLE;
// Si la config Komga est manquante, rediriger vers les settings
if (errorCode === ERROR_CODES.KOMGA.MISSING_CONFIG) {
router.push("/settings");
return;
}
throw new Error(errorCode);
}
@@ -67,7 +67,7 @@ export function ClientHomePage() {
// Récupérer les nouvelles données
const response = await fetch("/api/komga/home", {
cache: 'reload' // Force un nouveau fetch après invalidation
cache: "reload", // Force un nouveau fetch après invalidation
});
if (!response.ok) {
@@ -128,4 +128,3 @@ export function ClientHomePage() {
</>
);
}

View File

@@ -20,10 +20,10 @@ export function HomeContent({ data, refreshHome }: HomeContentProps) {
// Vérifier si la HeroSection a déjà été affichée
useEffect(() => {
const heroShown = localStorage.getItem('heroSectionShown');
const heroShown = localStorage.getItem("heroSectionShown");
if (!heroShown && data.ongoing && data.ongoing.length > 0) {
setShowHero(true);
localStorage.setItem('heroSectionShown', 'true');
localStorage.setItem("heroSectionShown", "true");
}
}, [data.ongoing]);

View File

@@ -79,13 +79,11 @@ function MediaCard({ item, onClick }: MediaCardProps) {
const { t } = useTranslate();
const isSeries = "booksCount" in item;
const { isAccessible } = useBookOfflineStatus(isSeries ? "" : item.id);
const title = isSeries
? item.metadata.title
: item.metadata.title ||
(item.metadata.number
? t("navigation.volume", { number: item.metadata.number })
: "");
(item.metadata.number ? t("navigation.volume", { number: item.metadata.number }) : "");
const handleClick = () => {
// Pour les séries, toujours autoriser le clic
@@ -100,7 +98,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
onClick={handleClick}
className={cn(
"flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden",
(!isSeries && !isAccessible) ? "cursor-not-allowed" : "cursor-pointer"
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
)}
>
<div className="relative aspect-[2/3] bg-muted">

View File

@@ -24,7 +24,12 @@ interface ClientLayoutProps {
userIsAdmin?: boolean;
}
export default function ClientLayout({ children, initialLibraries = [], initialFavorites = [], userIsAdmin = false }: ClientLayoutProps) {
export default function ClientLayout({
children,
initialLibraries = [],
initialFavorites = [],
userIsAdmin = false,
}: ClientLayoutProps) {
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [randomBookId, setRandomBookId] = useState<string | null>(null);
const pathname = usePathname();
@@ -67,14 +72,14 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
const backgroundStyle = useMemo(() => {
const bg = preferences.background;
const blur = bg.blur || 0;
if (bg.type === "gradient" && bg.gradient) {
return {
backgroundImage: bg.gradient,
filter: blur > 0 ? `blur(${blur}px)` : undefined,
};
}
if (bg.type === "image" && bg.imageUrl) {
return {
backgroundImage: `url(${bg.imageUrl})`,
@@ -94,7 +99,7 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
filter: blur > 0 ? `blur(${blur}px)` : undefined,
};
}
return {};
}, [preferences.background, randomBookId]);
@@ -137,10 +142,10 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
}, []);
// Ne pas afficher le header et la sidebar sur les routes publiques et le reader
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith('/books/');
const isPublicRoute = publicRoutes.includes(pathname) || pathname.startsWith("/books/");
const hasCustomBackground =
preferences.background.type === "gradient" ||
const hasCustomBackground =
preferences.background.type === "gradient" ||
preferences.background.type === "image" ||
(preferences.background.type === "komga-random" && randomBookId);
const contentOpacity = (preferences.background.opacity || 100) / 100;
@@ -149,28 +154,27 @@ export default function ClientLayout({ children, initialLibraries = [], initialF
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ImageCacheProvider>
{/* Background fixe pour les images et gradients */}
{hasCustomBackground && (
<div
className="fixed inset-0 -z-10"
style={backgroundStyle}
/>
)}
<div
{hasCustomBackground && <div className="fixed inset-0 -z-10" style={backgroundStyle} />}
<div
className={`relative min-h-screen ${hasCustomBackground ? "" : "bg-background"}`}
style={hasCustomBackground ? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` } : undefined}
style={
hasCustomBackground
? { backgroundColor: `rgba(var(--background-rgb, 255, 255, 255), ${contentOpacity})` }
: undefined
}
>
{!isPublicRoute && (
<Header
<Header
onToggleSidebar={handleToggleSidebar}
onRefreshBackground={fetchRandomBook}
showRefreshBackground={preferences.background.type === "komga-random"}
/>
)}
{!isPublicRoute && (
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
<Sidebar
isOpen={isSidebarOpen}
onClose={handleCloseSidebar}
initialLibraries={initialLibraries}
initialFavorites={initialFavorites}
userIsAdmin={userIsAdmin}
/>

View File

@@ -11,7 +11,11 @@ interface HeaderProps {
showRefreshBackground?: boolean;
}
export function Header({ onToggleSidebar, onRefreshBackground, showRefreshBackground = false }: HeaderProps) {
export function Header({
onToggleSidebar,
onRefreshBackground,
showRefreshBackground = false,
}: HeaderProps) {
const { theme, setTheme } = useTheme();
const { t } = useTranslation();
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -56,7 +60,9 @@ export function Header({ onToggleSidebar, onRefreshBackground, showRefreshBackgr
className="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Rafraîchir l'image de fond"
>
<RefreshCw className={`h-[1.2rem] w-[1.2rem] ${isRefreshing ? 'animate-spin' : ''}`} />
<RefreshCw
className={`h-[1.2rem] w-[1.2rem] ${isRefreshing ? "animate-spin" : ""}`}
/>
<span className="sr-only">Rafraîchir l&apos;image de fond</span>
</button>
)}

View File

@@ -1,6 +1,16 @@
"use client";
import { Home, Library, Settings, LogOut, RefreshCw, Star, Download, User, Shield } from "lucide-react";
import {
Home,
Library,
Settings,
LogOut,
RefreshCw,
Star,
Download,
User,
Shield,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
import { signOut } from "next-auth/react";
@@ -24,7 +34,13 @@ interface SidebarProps {
userIsAdmin?: boolean;
}
export function Sidebar({ isOpen, onClose, initialLibraries, initialFavorites, userIsAdmin = false }: SidebarProps) {
export function Sidebar({
isOpen,
onClose,
initialLibraries,
initialFavorites,
userIsAdmin = false,
}: SidebarProps) {
const { t } = useTranslate();
const pathname = usePathname();
const router = useRouter();

View File

@@ -16,19 +16,25 @@ interface LibraryHeaderProps {
refreshLibrary: (libraryId: string) => Promise<{ success: boolean; error?: string }>;
}
export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }: LibraryHeaderProps) => {
export const LibraryHeader = ({
library,
seriesCount,
series,
refreshLibrary,
}: LibraryHeaderProps) => {
const { t } = useTranslate();
// Mémoriser la sélection des séries pour éviter les rerenders inutiles
const { randomSeries, backgroundSeries } = useMemo(() => {
// Sélectionner une série aléatoire pour l'image centrale
const random = series.length > 0 ? series[Math.floor(Math.random() * series.length)] : null;
// Sélectionner une autre série aléatoire pour le fond (différente de celle du centre)
const background = series.length > 1
? series.filter(s => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
: random;
const background =
series.length > 1
? series.filter((s) => s.id !== random?.id)[Math.floor(Math.random() * (series.length - 1))]
: random;
return { randomSeries: random, backgroundSeries: background };
}, [series]);
@@ -76,23 +82,20 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
{/* Informations */}
<div className="flex-1 space-y-3 text-center md:text-left">
<h1 className="text-3xl md:text-4xl font-bold text-foreground">{library.name}</h1>
<div className="flex items-center gap-4 justify-center md:justify-start flex-wrap">
<StatusBadge status="unread" icon={Library}>
{seriesCount === 1
{seriesCount === 1
? t("library.header.series", { count: seriesCount })
: t("library.header.series_plural", { count: seriesCount })
}
: t("library.header.series_plural", { count: seriesCount })}
</StatusBadge>
<RefreshButton libraryId={library.id} refreshLibrary={refreshLibrary} />
<ScanButton libraryId={library.id} />
</div>
{library.unavailable && (
<p className="text-sm text-destructive mt-2">
{t("library.header.unavailable")}
</p>
<p className="text-sm text-destructive mt-2">{t("library.header.unavailable")}</p>
)}
</div>
</div>
@@ -100,4 +103,3 @@ export const LibraryHeader = ({ library, seriesCount, series, refreshLibrary }:
</div>
);
};

View File

@@ -38,31 +38,31 @@ export function PaginatedSeriesGrid({
const searchParams = useSearchParams();
const [showOnlyUnread, setShowOnlyUnread] = useState(initialShowOnlyUnread);
const { isCompact, itemsPerPage: displayItemsPerPage, viewMode } = useDisplayPreferences();
// Utiliser la taille de page effective (depuis l'URL ou les préférences)
const effectivePageSize = pageSize || displayItemsPerPage;
const { t } = useTranslate();
const updateUrlParams = useCallback(async (
updates: Record<string, string | null>,
replace: boolean = false
) => {
const params = new URLSearchParams(searchParams.toString());
const updateUrlParams = useCallback(
async (updates: Record<string, string | null>, replace: boolean = false) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === null) {
params.delete(key);
Object.entries(updates).forEach(([key, value]) => {
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
});
if (replace) {
await router.replace(`${pathname}?${params.toString()}`);
} else {
params.set(key, value);
await router.push(`${pathname}?${params.toString()}`);
}
});
if (replace) {
await router.replace(`${pathname}?${params.toString()}`);
} else {
await router.push(`${pathname}?${params.toString()}`);
}
}, [router, pathname, searchParams]);
},
[router, pathname, searchParams]
);
// Update local state when prop changes
useEffect(() => {
@@ -89,7 +89,6 @@ export function PaginatedSeriesGrid({
});
};
const handlePageSizeChange = async (size: number) => {
await updateUrlParams({
page: "1",

View File

@@ -67,8 +67,7 @@ export function ScanButton({ libraryId }: ScanButtonProps) {
toast({
variant: "destructive",
title: t("library.scan.error.title"),
description:
error instanceof Error ? error.message : t("library.scan.error.description"),
description: error instanceof Error ? error.message : t("library.scan.error.description"),
});
}
};
@@ -86,4 +85,3 @@ export function ScanButton({ libraryId }: ScanButtonProps) {
</Button>
);
}

View File

@@ -60,9 +60,8 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
};
const isCompleted = series.booksCount === series.booksReadCount;
const progressPercentage = series.booksCount > 0
? (series.booksReadCount / series.booksCount) * 100
: 0;
const progressPercentage =
series.booksCount > 0 ? (series.booksReadCount / series.booksCount) * 100 : 0;
const statusInfo = getReadingStatusInfo(series, t);
@@ -91,7 +90,12 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
{series.metadata.title}
</h3>
<span className={cn("px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
<span
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label}
</span>
</div>
@@ -101,7 +105,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
<span>
{series.booksCount === 1
{series.booksCount === 1
? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })}
</span>
@@ -109,9 +113,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{series.booksMetadata?.authors && series.booksMetadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{series.booksMetadata.authors[0].name}
</span>
<span className="line-clamp-1">{series.booksMetadata.authors[0].name}</span>
</div>
)}
</div>
@@ -146,9 +148,14 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{series.metadata.title}
</h3>
</div>
{/* Badge de statut */}
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
<span
className={cn(
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label}
</span>
</div>
@@ -166,7 +173,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
<span>
{series.booksCount === 1
{series.booksCount === 1
? t("series.book", { count: 1 })
: t("series.books", { count: series.booksCount })}
</span>
@@ -177,7 +184,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{series.booksMetadata.authors.map(a => a.name).join(", ")}
{series.booksMetadata.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
@@ -246,4 +253,3 @@ export function SeriesList({ series, isCompact = false }: SeriesListProps) {
</div>
);
}

View File

@@ -27,7 +27,7 @@ export function ClientBookPage({ bookId }: ClientBookPageProps) {
setError(null);
const response = await fetch(`/api/komga/books/${bookId}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.code || ERROR_CODES.BOOK.PAGES_FETCH_ERROR);
@@ -74,4 +74,3 @@ export function ClientBookPage({ bookId }: ClientBookPageProps) {
return <ClientBookWrapper book={data.book} pages={data.pages} nextBook={data.nextBook} />;
}

View File

@@ -19,5 +19,7 @@ export function ClientBookWrapper({ book, pages, nextBook }: ClientBookWrapperPr
router.push(`/series/${book.seriesId}`);
};
return <PhotoswipeReader book={book} pages={pages} onClose={handleCloseReader} nextBook={nextBook} />;
return (
<PhotoswipeReader book={book} pages={pages} onClose={handleCloseReader} nextBook={nextBook} />
);
}

View File

@@ -29,20 +29,29 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
const { direction, toggleDirection, isRTL } = useReadingDirection();
const { isFullscreen, toggleFullscreen } = useFullscreen();
const { isDoublePage, shouldShowDoublePage, toggleDoublePage } = useDoublePageMode();
const { loadedImages, imageBlobUrls, prefetchPages, prefetchNextBook, handleForceReload, getPageUrl, prefetchCount } = useImageLoader({
bookId: book.id,
pages,
prefetchCount: preferences.readerPrefetchCount,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null
});
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } = usePageNavigation({
book,
const {
loadedImages,
imageBlobUrls,
prefetchPages,
prefetchNextBook,
handleForceReload,
getPageUrl,
prefetchCount,
} = useImageLoader({
bookId: book.id,
pages,
isDoublePage,
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length),
onClose,
nextBook,
prefetchCount: preferences.readerPrefetchCount,
nextBook: nextBook ? { id: nextBook.id, pages: [] } : null,
});
const { currentPage, showEndMessage, navigateToPage, handlePreviousPage, handleNextPage } =
usePageNavigation({
book,
pages,
isDoublePage,
shouldShowDoublePage: (page) => shouldShowDoublePage(page, pages.length),
onClose,
nextBook,
});
const { pswpRef, handleZoom } = usePhotoSwipeZoom({
loadedImages,
currentPage,
@@ -58,32 +67,44 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
// Activer le zoom dans le reader en enlevant la classe no-pinch-zoom
useEffect(() => {
document.body.classList.remove('no-pinch-zoom');
document.body.classList.remove("no-pinch-zoom");
return () => {
document.body.classList.add('no-pinch-zoom');
document.body.classList.add("no-pinch-zoom");
};
}, []);
// Prefetch current and next pages
// Deduplication in useImageLoader prevents redundant requests
// Server queue (RequestQueueService) handles concurrency limits
useEffect(() => {
// Prefetch pages starting from current page
prefetchPages(currentPage, prefetchCount);
// If double page mode, also prefetch additional pages for smooth double page navigation
if (isDoublePage && shouldShowDoublePage(currentPage, pages.length) && currentPage + prefetchCount < pages.length) {
if (
isDoublePage &&
shouldShowDoublePage(currentPage, pages.length) &&
currentPage + prefetchCount < pages.length
) {
prefetchPages(currentPage + prefetchCount, 1);
}
// If we're near the end of the book, prefetch the next book
const pagesFromEnd = pages.length - currentPage;
if (pagesFromEnd <= prefetchCount && nextBook) {
prefetchNextBook(prefetchCount);
}
}, [currentPage, isDoublePage, shouldShowDoublePage, prefetchPages, prefetchNextBook, prefetchCount, pages.length, nextBook]);
}, [
currentPage,
isDoublePage,
shouldShowDoublePage,
prefetchPages,
prefetchNextBook,
prefetchCount,
pages.length,
nextBook,
]);
// Keyboard events
useEffect(() => {
@@ -109,43 +130,46 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleNextPage, handlePreviousPage, onClose, isRTL, currentPage]);
const handleContainerClick = useCallback((e: React.MouseEvent) => {
// Vérifier si c'est un double-clic sur une image
const target = e.target as HTMLElement;
const now = Date.now();
const timeSinceLastClick = now - lastClickTimeRef.current;
if (target.tagName === 'IMG' && timeSinceLastClick < 300) {
// Double-clic sur une image
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
clickTimeoutRef.current = null;
const handleContainerClick = useCallback(
(e: React.MouseEvent) => {
// Vérifier si c'est un double-clic sur une image
const target = e.target as HTMLElement;
const now = Date.now();
const timeSinceLastClick = now - lastClickTimeRef.current;
if (target.tagName === "IMG" && timeSinceLastClick < 300) {
// Double-clic sur une image
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
clickTimeoutRef.current = null;
}
e.stopPropagation();
handleZoom();
lastClickTimeRef.current = 0;
} else if (target.tagName === "IMG") {
// Premier clic sur une image - attendre pour voir si c'est un double-clic
lastClickTimeRef.current = now;
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
clickTimeoutRef.current = setTimeout(() => {
setShowControls((prev) => !prev);
clickTimeoutRef.current = null;
}, 300);
} else {
// Clic ailleurs - toggle les contrôles immédiatement
setShowControls(!showControls);
lastClickTimeRef.current = 0;
}
e.stopPropagation();
handleZoom();
lastClickTimeRef.current = 0;
} else if (target.tagName === 'IMG') {
// Premier clic sur une image - attendre pour voir si c'est un double-clic
lastClickTimeRef.current = now;
if (clickTimeoutRef.current) {
clearTimeout(clickTimeoutRef.current);
}
clickTimeoutRef.current = setTimeout(() => {
setShowControls(prev => !prev);
clickTimeoutRef.current = null;
}, 300);
} else {
// Clic ailleurs - toggle les contrôles immédiatement
setShowControls(!showControls);
lastClickTimeRef.current = 0;
}
}, [showControls, handleZoom]);
},
[showControls, handleZoom]
);
return (
<ReaderContainer onContainerClick={handleContainerClick}>
@@ -173,7 +197,11 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
showThumbnails={showThumbnails}
onToggleThumbnails={() => setShowThumbnails(!showThumbnails)}
onZoom={handleZoom}
onForceReload={() => handleForceReload(currentPage, isDoublePage, (page) => shouldShowDoublePage(page, pages.length))}
onForceReload={() =>
handleForceReload(currentPage, isDoublePage, (page) =>
shouldShowDoublePage(page, pages.length)
)
}
/>
<PageDisplay
@@ -196,4 +224,3 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
</ReaderContainer>
);
}

View File

@@ -44,26 +44,28 @@ export function PageDisplay({
<div
className={cn(
"relative h-full flex items-center",
isDoublePage && shouldShowDoublePage(currentPage)
? "w-1/2"
: "w-full justify-center",
isDoublePage && shouldShowDoublePage(currentPage) && {
"order-2 justify-start": isRTL,
"order-1 justify-end": !isRTL,
}
isDoublePage && shouldShowDoublePage(currentPage) ? "w-1/2" : "w-full justify-center",
isDoublePage &&
shouldShowDoublePage(currentPage) && {
"order-2 justify-start": isRTL,
"order-1 justify-end": !isRTL,
}
)}
>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 animate-fade-in">
<div className="relative">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-primary/20"></div>
<div className="absolute inset-0 animate-spin rounded-full h-16 w-16 border-4 border-transparent border-t-primary" style={{ animationDuration: '0.8s' }}></div>
<div
className="absolute inset-0 animate-spin rounded-full h-16 w-16 border-4 border-transparent border-t-primary"
style={{ animationDuration: "0.8s" }}
></div>
</div>
</div>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ''}`}
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
alt={`Page ${currentPage}`}
className={cn(
@@ -85,25 +87,25 @@ export function PageDisplay({
{/* Page 2 (double page) */}
{isDoublePage && shouldShowDoublePage(currentPage) && (
<div
className={cn(
"relative h-full w-1/2 flex items-center",
{
"order-1 justify-end": isRTL,
"order-2 justify-start": !isRTL,
}
)}
className={cn("relative h-full w-1/2 flex items-center", {
"order-1 justify-end": isRTL,
"order-2 justify-start": !isRTL,
})}
>
{secondPageLoading && (
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 animate-fade-in">
<div className="relative">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-primary/20"></div>
<div className="absolute inset-0 animate-spin rounded-full h-16 w-16 border-4 border-transparent border-t-primary" style={{ animationDuration: '0.8s' }}></div>
<div
className="absolute inset-0 animate-spin rounded-full h-16 w-16 border-4 border-transparent border-t-primary"
style={{ animationDuration: "0.8s" }}
></div>
</div>
</div>
)}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ''}`}
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)}
alt={`Page ${currentPage + 1}`}
className={cn(

View File

@@ -8,9 +8,12 @@ interface ReaderContainerProps {
export function ReaderContainer({ children, onContainerClick }: ReaderContainerProps) {
const readerRef = useRef<HTMLDivElement>(null);
const handleContainerClick = useCallback((e: React.MouseEvent) => {
onContainerClick(e);
}, [onContainerClick]);
const handleContainerClick = useCallback(
(e: React.MouseEvent) => {
onContainerClick(e);
},
[onContainerClick]
);
return (
<div
@@ -18,9 +21,7 @@ export function ReaderContainer({ children, onContainerClick }: ReaderContainerP
className="reader-zoom-enabled fixed inset-0 bg-background/95 backdrop-blur-sm z-50 overflow-hidden"
onClick={handleContainerClick}
>
<div className="relative h-full flex flex-col items-center justify-center">
{children}
</div>
<div className="relative h-full flex flex-col items-center justify-center">{children}</div>
</div>
);
}

View File

@@ -97,9 +97,9 @@ export const Thumbnail = forwardRef<HTMLButtonElement, ThumbnailProps>(
setImageUrl((prev) => {
if (!prev) return null;
// Utiliser & si l'URL contient déjà des query params
const separator = prev.includes('?') ? '&' : '?';
const separator = prev.includes("?") ? "&" : "?";
// Supprimer l'ancien retry param si présent
const baseUrl = prev.replace(/[?&]retry=\d+/g, '');
const baseUrl = prev.replace(/[?&]retry=\d+/g, "");
return `${baseUrl}${separator}retry=${loadAttempts.current}`;
});
}, delay);

View File

@@ -22,7 +22,7 @@ export function useDoublePageMode() {
);
const toggleDoublePage = useCallback(() => {
setIsDoublePage(prev => !prev);
setIsDoublePage((prev) => !prev);
}, []);
return {

View File

@@ -14,7 +14,9 @@ export const useFullscreen = () => {
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
if (document.fullscreenElement) {
document.exitFullscreen().catch(err => logger.error({ err }, "Erreur lors de la sortie du mode plein écran"));
document
.exitFullscreen()
.catch((err) => logger.error({ err }, "Erreur lors de la sortie du mode plein écran"));
}
};
}, []);

View File

@@ -15,7 +15,12 @@ interface UseImageLoaderProps {
nextBook?: { id: string; pages: number[] } | null; // Livre suivant pour prefetch
}
export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextBook }: UseImageLoaderProps) {
export function useImageLoader({
bookId,
pages: _pages,
prefetchCount = 5,
nextBook,
}: UseImageLoaderProps) {
const [loadedImages, setLoadedImages] = useState<Record<ImageKey, ImageDimensions>>({});
const [imageBlobUrls, setImageBlobUrls] = useState<Record<ImageKey, string>>({});
const loadedImagesRef = useRef(loadedImages);
@@ -32,217 +37,238 @@ export function useImageLoader({ bookId, pages: _pages, prefetchCount = 5, nextB
imageBlobUrlsRef.current = imageBlobUrls;
}, [imageBlobUrls]);
const getPageUrl = useCallback((pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`, [bookId]);
const getPageUrl = useCallback(
(pageNum: number) => `/api/komga/books/${bookId}/pages/${pageNum}`,
[bookId]
);
// Prefetch image and store dimensions
const prefetchImage = useCallback(async (pageNum: number) => {
// Check if we already have both dimensions and blob URL
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
if (hasDimensions && hasBlobUrl) {
return;
}
// Check if this page is already being fetched
if (pendingFetchesRef.current.has(pageNum)) {
return;
}
// Mark as pending
pendingFetchesRef.current.add(pageNum);
try {
// Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), {
cache: 'default', // Respect Cache-Control headers from server
});
if (!response.ok) {
const prefetchImage = useCallback(
async (pageNum: number) => {
// Check if we already have both dimensions and blob URL
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
if (hasDimensions && hasBlobUrl) {
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages(prev => ({
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight }
}));
// Store the blob URL for immediate use
setImageBlobUrls(prev => ({
...prev,
[pageNum]: blobUrl
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
// Remove from pending set
pendingFetchesRef.current.delete(pageNum);
}
}, [getPageUrl]);
// Check if this page is already being fetched
if (pendingFetchesRef.current.has(pageNum)) {
return;
}
// Mark as pending
pendingFetchesRef.current.add(pageNum);
try {
// Use browser cache if available - the server sets Cache-Control headers
const response = await fetch(getPageUrl(pageNum), {
cache: "default", // Respect Cache-Control headers from server
});
if (!response.ok) {
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages((prev) => ({
...prev,
[pageNum]: { width: img.naturalWidth, height: img.naturalHeight },
}));
// Store the blob URL for immediate use
setImageBlobUrls((prev) => ({
...prev,
[pageNum]: blobUrl,
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
// Remove from pending set
pendingFetchesRef.current.delete(pageNum);
}
},
[getPageUrl]
);
// Prefetch multiple pages starting from a given page
// The server-side queue (RequestQueueService) handles concurrency limits
// We only deduplicate to avoid redundant HTTP requests
const prefetchPages = useCallback(async (startPage: number, count: number = prefetchCount) => {
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
const pageNum = startPage + i;
if (pageNum <= _pages.length) {
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(pageNum);
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push(pageNum);
const prefetchPages = useCallback(
async (startPage: number, count: number = prefetchCount) => {
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
const pageNum = startPage + i;
if (pageNum <= _pages.length) {
const hasDimensions = loadedImagesRef.current[pageNum];
const hasBlobUrl = imageBlobUrlsRef.current[pageNum];
const isPending = pendingFetchesRef.current.has(pageNum);
// Prefetch if we don't have both dimensions AND blob URL AND it's not already pending
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push(pageNum);
}
}
}
}
// Let all prefetch requests run - the server queue will manage concurrency
// The browser cache and our deduplication prevent redundant requests
if (pagesToPrefetch.length > 0) {
// Fire all requests in parallel - server queue handles throttling
Promise.all(pagesToPrefetch.map(pageNum => prefetchImage(pageNum))).catch(() => {
// Silently fail - prefetch is non-critical
});
}
}, [prefetchImage, prefetchCount, _pages.length]);
// Let all prefetch requests run - the server queue will manage concurrency
// The browser cache and our deduplication prevent redundant requests
if (pagesToPrefetch.length > 0) {
// Fire all requests in parallel - server queue handles throttling
Promise.all(pagesToPrefetch.map((pageNum) => prefetchImage(pageNum))).catch(() => {
// Silently fail - prefetch is non-critical
});
}
},
[prefetchImage, prefetchCount, _pages.length]
);
// Prefetch pages from next book
const prefetchNextBook = useCallback(async (count: number = prefetchCount) => {
if (!nextBook) {
return;
}
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
const pageNum = i + 1; // Pages du livre suivant commencent à 1
// Pour le livre suivant, on utilise une clé différente pour éviter les conflits
const nextBookPageKey = `next-${pageNum}`;
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push({ pageNum, nextBookPageKey });
const prefetchNextBook = useCallback(
async (count: number = prefetchCount) => {
if (!nextBook) {
return;
}
}
// Let all prefetch requests run - server queue handles concurrency
if (pagesToPrefetch.length > 0) {
Promise.all(pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
// Mark as pending
pendingFetchesRef.current.add(nextBookPageKey);
try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
cache: 'default', // Respect Cache-Control headers from server
});
if (!response.ok) {
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages(prev => ({
...prev,
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight }
}));
// Store the blob URL for immediate use
setImageBlobUrls(prev => ({
...prev,
[nextBookPageKey]: blobUrl
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
pendingFetchesRef.current.delete(nextBookPageKey);
const pagesToPrefetch = [];
for (let i = 0; i < count; i++) {
const pageNum = i + 1; // Pages du livre suivant commencent à 1
// Pour le livre suivant, on utilise une clé différente pour éviter les conflits
const nextBookPageKey = `next-${pageNum}`;
const hasDimensions = loadedImagesRef.current[nextBookPageKey];
const hasBlobUrl = imageBlobUrlsRef.current[nextBookPageKey];
const isPending = pendingFetchesRef.current.has(nextBookPageKey);
if ((!hasDimensions || !hasBlobUrl) && !isPending) {
pagesToPrefetch.push({ pageNum, nextBookPageKey });
}
})).catch(() => {
// Silently fail - prefetch is non-critical
});
}
}, [nextBook, prefetchCount]);
}
// Let all prefetch requests run - server queue handles concurrency
if (pagesToPrefetch.length > 0) {
Promise.all(
pagesToPrefetch.map(async ({ pageNum, nextBookPageKey }) => {
// Mark as pending
pendingFetchesRef.current.add(nextBookPageKey);
try {
const response = await fetch(`/api/komga/books/${nextBook.id}/pages/${pageNum}`, {
cache: "default", // Respect Cache-Control headers from server
});
if (!response.ok) {
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// Create image to get dimensions
const img = new Image();
img.onload = () => {
setLoadedImages((prev) => ({
...prev,
[nextBookPageKey]: { width: img.naturalWidth, height: img.naturalHeight },
}));
// Store the blob URL for immediate use
setImageBlobUrls((prev) => ({
...prev,
[nextBookPageKey]: blobUrl,
}));
};
img.src = blobUrl;
} catch {
// Silently fail prefetch
} finally {
pendingFetchesRef.current.delete(nextBookPageKey);
}
})
).catch(() => {
// Silently fail - prefetch is non-critical
});
}
},
[nextBook, prefetchCount]
);
// Force reload handler
const handleForceReload = useCallback(async (currentPage: number, isDoublePage: boolean, shouldShowDoublePage: (page: number) => boolean) => {
// Révoquer les anciennes URLs blob
if (imageBlobUrls[currentPage]) {
URL.revokeObjectURL(imageBlobUrls[currentPage]);
}
if (imageBlobUrls[currentPage + 1]) {
URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
}
try {
// Fetch page 1 avec cache: reload
const response1 = await fetch(getPageUrl(currentPage), {
cache: 'reload',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (!response1.ok) {
throw new Error(`HTTP ${response1.status}`);
const handleForceReload = useCallback(
async (
currentPage: number,
isDoublePage: boolean,
shouldShowDoublePage: (page: number) => boolean
) => {
// Révoquer les anciennes URLs blob
if (imageBlobUrls[currentPage]) {
URL.revokeObjectURL(imageBlobUrls[currentPage]);
}
const blob1 = await response1.blob();
const blobUrl1 = URL.createObjectURL(blob1);
const newUrls: Record<number, string> = {
...imageBlobUrls,
[currentPage]: blobUrl1
};
// Fetch page 2 si double page
if (isDoublePage && shouldShowDoublePage(currentPage)) {
const response2 = await fetch(getPageUrl(currentPage + 1), {
cache: 'reload',
if (imageBlobUrls[currentPage + 1]) {
URL.revokeObjectURL(imageBlobUrls[currentPage + 1]);
}
try {
// Fetch page 1 avec cache: reload
const response1 = await fetch(getPageUrl(currentPage), {
cache: "reload",
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
if (!response2.ok) {
throw new Error(`HTTP ${response2.status}`);
if (!response1.ok) {
throw new Error(`HTTP ${response1.status}`);
}
const blob2 = await response2.blob();
const blobUrl2 = URL.createObjectURL(blob2);
newUrls[currentPage + 1] = blobUrl2;
const blob1 = await response1.blob();
const blobUrl1 = URL.createObjectURL(blob1);
const newUrls: Record<number, string> = {
...imageBlobUrls,
[currentPage]: blobUrl1,
};
// Fetch page 2 si double page
if (isDoublePage && shouldShowDoublePage(currentPage)) {
const response2 = await fetch(getPageUrl(currentPage + 1), {
cache: "reload",
headers: {
"Cache-Control": "no-cache",
Pragma: "no-cache",
},
});
if (!response2.ok) {
throw new Error(`HTTP ${response2.status}`);
}
const blob2 = await response2.blob();
const blobUrl2 = URL.createObjectURL(blob2);
newUrls[currentPage + 1] = blobUrl2;
}
setImageBlobUrls(newUrls);
} catch (error) {
logger.error({ err: error }, "Error reloading images:");
throw error;
}
setImageBlobUrls(newUrls);
} catch (error) {
logger.error({ err: error }, 'Error reloading images:');
throw error;
}
}, [imageBlobUrls, getPageUrl]);
},
[imageBlobUrls, getPageUrl]
);
// Cleanup blob URLs on unmount only
useEffect(() => {
return () => {
Object.values(imageBlobUrlsRef.current).forEach(url => {
Object.values(imageBlobUrlsRef.current).forEach((url) => {
if (url) URL.revokeObjectURL(url);
});
};

View File

@@ -100,7 +100,15 @@ export function usePageNavigation({
}
const step = isDoublePage && shouldShowDoublePage(currentPage) ? 2 : 1;
navigateToPage(Math.min(pages.length, currentPage + step));
}, [currentPage, pages.length, isDoublePage, shouldShowDoublePage, navigateToPage, nextBook, router]);
}, [
currentPage,
pages.length,
isDoublePage,
shouldShowDoublePage,
navigateToPage,
nextBook,
router,
]);
// Cleanup - Sync final sans debounce
useEffect(() => {
@@ -122,4 +130,4 @@ export function usePageNavigation({
handlePreviousPage,
handleNextPage,
};
}
}

View File

@@ -19,12 +19,14 @@ export function usePhotoSwipeZoom({
const dims = loadedImages[currentPage];
if (!dims) return;
const dataSource = [{
src: getPageUrl(currentPage),
width: dims.width,
height: dims.height,
alt: `Page ${currentPage}`
}];
const dataSource = [
{
src: getPageUrl(currentPage),
width: dims.width,
height: dims.height,
alt: `Page ${currentPage}`,
},
];
// Close any existing instance
if (pswpRef.current) {
@@ -36,12 +38,12 @@ export function usePhotoSwipeZoom({
dataSource,
index: 0,
bgOpacity: 0.9,
showHideAnimationType: 'fade',
showHideAnimationType: "fade",
initialZoomLevel: 0.25,
secondaryZoomLevel: 0.5, // Niveau de zoom au double-clic
maxZoomLevel: 4,
clickToCloseNonZoomable: true, // Ferme au clic simple
tapAction: 'zoom', // Ferme au tap
tapAction: "zoom", // Ferme au tap
wheelToZoom: true,
pinchToClose: false, // Pinch pour fermer
closeOnVerticalDrag: true, // Swipe vertical pour fermer
@@ -53,7 +55,7 @@ export function usePhotoSwipeZoom({
pswp.init();
// Clean up on close
pswp.on('close', () => {
pswp.on("close", () => {
pswpRef.current = null;
});
}, [loadedImages, currentPage, getPageUrl]);

View File

@@ -30,28 +30,31 @@ export function useTouchNavigation({
}, []);
// Touch handlers for swipe navigation
const handleTouchStart = useCallback((e: TouchEvent) => {
// Ne pas gérer si Photoswipe est ouvert
if (pswpRef.current) return;
// Ne pas gérer si la page est zoomée (zoom natif)
if (isZoomed()) return;
// Détecter si c'est un pinch (2+ doigts)
if (e.touches.length > 1) {
isPinchingRef.current = true;
touchStartXRef.current = null;
touchStartYRef.current = null;
return;
}
// Un seul doigt - seulement si on n'était pas en train de pinch
// On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt
if (e.touches.length === 1) {
isPinchingRef.current = false;
touchStartXRef.current = e.touches[0].clientX;
touchStartYRef.current = e.touches[0].clientY;
}
}, [pswpRef, isZoomed]);
const handleTouchStart = useCallback(
(e: TouchEvent) => {
// Ne pas gérer si Photoswipe est ouvert
if (pswpRef.current) return;
// Ne pas gérer si la page est zoomée (zoom natif)
if (isZoomed()) return;
// Détecter si c'est un pinch (2+ doigts)
if (e.touches.length > 1) {
isPinchingRef.current = true;
touchStartXRef.current = null;
touchStartYRef.current = null;
return;
}
// Un seul doigt - seulement si on n'était pas en train de pinch
// On réinitialise isPinchingRef seulement ici, quand on commence un nouveau geste à 1 doigt
if (e.touches.length === 1) {
isPinchingRef.current = false;
touchStartXRef.current = e.touches[0].clientX;
touchStartYRef.current = e.touches[0].clientY;
}
},
[pswpRef, isZoomed]
);
const handleTouchMove = useCallback((e: TouchEvent) => {
// Détecter le pinch pendant le mouvement
@@ -62,63 +65,66 @@ export function useTouchNavigation({
}
}, []);
const handleTouchEnd = useCallback((e: TouchEvent) => {
// Si on était en mode pinch, ne JAMAIS traiter le swipe
if (isPinchingRef.current) {
touchStartXRef.current = null;
touchStartYRef.current = null;
// Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart
return;
}
// Vérifier qu'on a bien des coordonnées de départ
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
// Ne pas gérer si Photoswipe est ouvert
if (pswpRef.current) return;
// Ne pas gérer si la page est zoomée (zoom natif)
if (isZoomed()) return;
const handleTouchEnd = useCallback(
(e: TouchEvent) => {
// Si on était en mode pinch, ne JAMAIS traiter le swipe
if (isPinchingRef.current) {
touchStartXRef.current = null;
touchStartYRef.current = null;
// Ne PAS réinitialiser isPinchingRef ici, on le fera au prochain touchstart
return;
}
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const deltaX = touchEndX - touchStartXRef.current;
const deltaY = touchEndY - touchStartYRef.current;
// Vérifier qu'on a bien des coordonnées de départ
if (touchStartXRef.current === null || touchStartYRef.current === null) return;
// Ne pas gérer si Photoswipe est ouvert
if (pswpRef.current) return;
// Ne pas gérer si la page est zoomée (zoom natif)
if (isZoomed()) return;
// Si le déplacement vertical est plus important, on ignore (scroll)
if (Math.abs(deltaY) > Math.abs(deltaX)) {
touchStartXRef.current = null;
touchStartYRef.current = null;
return;
}
const touchEndX = e.changedTouches[0].clientX;
const touchEndY = e.changedTouches[0].clientY;
const deltaX = touchEndX - touchStartXRef.current;
const deltaY = touchEndY - touchStartYRef.current;
// Seuil de 50px pour changer de page
if (Math.abs(deltaX) > 50) {
if (deltaX > 0) {
// Swipe vers la droite
if (isRTL) {
onNextPage();
// Si le déplacement vertical est plus important, on ignore (scroll)
if (Math.abs(deltaY) > Math.abs(deltaX)) {
touchStartXRef.current = null;
touchStartYRef.current = null;
return;
}
// Seuil de 50px pour changer de page
if (Math.abs(deltaX) > 50) {
if (deltaX > 0) {
// Swipe vers la droite
if (isRTL) {
onNextPage();
} else {
onPreviousPage();
}
} else {
onPreviousPage();
}
} else {
// Swipe vers la gauche
if (isRTL) {
onPreviousPage();
} else {
onNextPage();
// Swipe vers la gauche
if (isRTL) {
onPreviousPage();
} else {
onNextPage();
}
}
}
}
touchStartXRef.current = null;
touchStartYRef.current = null;
}, [onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]);
touchStartXRef.current = null;
touchStartYRef.current = null;
},
[onNextPage, onPreviousPage, isRTL, pswpRef, isZoomed]
);
// Setup touch event listeners
useEffect(() => {
window.addEventListener("touchstart", handleTouchStart);
window.addEventListener("touchmove", handleTouchMove);
window.addEventListener("touchend", handleTouchEnd);
return () => {
window.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchmove", handleTouchMove);

View File

@@ -48,8 +48,9 @@ function BookCard({ book, onBookClick, onSuccess, isCompact }: BookCardProps) {
<BookCover
book={book}
alt={t("books.coverAlt", {
title: book.metadata.title ||
(book.metadata.number
title:
book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: ""),
})}

View File

@@ -75,10 +75,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
};
const statusInfo = getStatusInfo();
const title = book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: book.name);
const title =
book.metadata.title ||
(book.metadata.number ? t("navigation.volume", { number: book.metadata.number }) : book.name);
if (isCompact) {
return (
@@ -118,7 +117,12 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
>
{title}
</h3>
<span className={cn("px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
<span
className={cn(
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label}
</span>
</div>
@@ -137,9 +141,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
{book.metadata.authors && book.metadata.authors.length > 0 && (
<div className="flex items-center gap-1 hidden sm:flex">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors[0].name}
</span>
<span className="line-clamp-1">{book.metadata.authors[0].name}</span>
</div>
)}
</div>
@@ -192,9 +194,14 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
</p>
)}
</div>
{/* Badge de statut */}
<span className={cn("px-2 py-1 rounded-full text-xs font-medium flex-shrink-0", statusInfo.className)}>
<span
className={cn(
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
statusInfo.className
)}
>
{statusInfo.label}
</span>
</div>
@@ -221,7 +228,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span className="line-clamp-1">
{book.metadata.authors.map(a => a.name).join(", ")}
{book.metadata.authors.map((a) => a.name).join(", ")}
</span>
</div>
)}
@@ -343,4 +350,3 @@ export function BookList({ books, onBookClick, isCompact = false }: BookListProp
</div>
);
}

View File

@@ -37,26 +37,26 @@ export function PaginatedBookGrid({
const { isCompact, itemsPerPage, viewMode } = useDisplayPreferences();
const { t } = useTranslate();
const updateUrlParams = useCallback(async (
updates: Record<string, string | null>,
replace: boolean = false
) => {
const params = new URLSearchParams(searchParams.toString());
const updateUrlParams = useCallback(
async (updates: Record<string, string | null>, replace: boolean = false) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value === null) {
params.delete(key);
Object.entries(updates).forEach(([key, value]) => {
if (value === null) {
params.delete(key);
} else {
params.set(key, value);
}
});
if (replace) {
await router.replace(`${pathname}?${params.toString()}`);
} else {
params.set(key, value);
await router.push(`${pathname}?${params.toString()}`);
}
});
if (replace) {
await router.replace(`${pathname}?${params.toString()}`);
} else {
await router.push(`${pathname}?${params.toString()}`);
}
}, [router, pathname, searchParams]);
},
[router, pathname, searchParams]
);
// Update local state when prop changes
useEffect(() => {

View File

@@ -157,17 +157,18 @@ export const SeriesHeader = ({ series, refreshSeries }: SeriesHeaderProps) => {
{statusInfo.label}
</StatusBadge>
<span className="text-sm text-white/80">
{series.booksCount === 1
{series.booksCount === 1
? t("series.header.books", { count: series.booksCount })
: t("series.header.books_plural", { count: series.booksCount })
}
: t("series.header.books_plural", { count: series.booksCount })}
</span>
<IconButton
variant="ghost"
size="icon"
icon={isFavorite ? Star : StarOff}
onClick={handleToggleFavorite}
tooltip={t(isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add")}
tooltip={t(
isFavorite ? "series.header.favorite.remove" : "series.header.favorite.add"
)}
className="text-white hover:text-white"
iconClassName={isFavorite ? "fill-yellow-400 text-yellow-400" : ""}
/>

View File

@@ -116,9 +116,7 @@ export function AdvancedSettings() {
<Shield className="h-5 w-5 text-primary" />
<CardTitle className="text-lg">{t("settings.advanced.circuitBreaker.title")}</CardTitle>
</div>
<CardDescription>
{t("settings.advanced.circuitBreaker.description")}
</CardDescription>
<CardDescription>{t("settings.advanced.circuitBreaker.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<SliderControl

View File

@@ -147,7 +147,6 @@ export function BackgroundSettings() {
}
};
const handleLibraryToggle = async (libraryId: string) => {
const newSelection = selectedLibraries.includes(libraryId)
? selectedLibraries.filter((id) => id !== libraryId)
@@ -174,7 +173,6 @@ export function BackgroundSettings() {
<CardDescription>{t("settings.background.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-6">
{/* Type de background */}
<div className="space-y-3">
@@ -258,7 +256,9 @@ export function BackgroundSettings() {
onChange={(e) => setCustomImageUrl(e.target.value)}
className="flex-1"
/>
<Button onClick={handleCustomImageSave}>{t("settings.background.image.save")}</Button>
<Button onClick={handleCustomImageSave}>
{t("settings.background.image.save")}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.background.image.description")}
@@ -326,4 +326,3 @@ export function BackgroundSettings() {
</Card>
);
}

View File

@@ -129,7 +129,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if (response) {
const blob = await response.clone().blob();
totalSize += blob.size;
// Calculer la taille du cache API séparément
if (cacheName.includes("api")) {
apiSize += blob.size;
@@ -214,19 +214,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
try {
const urlObj = new URL(url);
const path = urlObj.pathname;
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) return '/';
const segments = path.split("/").filter(Boolean);
if (segments.length === 0) return "/";
// Pour /api/komga/images, grouper par type (series/books)
if (segments[0] === 'api' && segments[1] === 'komga' && segments[2] === 'images' && segments[3]) {
if (
segments[0] === "api" &&
segments[1] === "komga" &&
segments[2] === "images" &&
segments[3]
) {
return `/${segments[0]}/${segments[1]}/${segments[2]}/${segments[3]}`;
}
// Pour les autres, garder juste le premier segment
return `/${segments[0]}`;
} catch {
return 'Autres';
return "Autres";
}
};
@@ -255,8 +260,8 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
// Trier par date (le plus récent en premier) basé sur le paramètre v
Object.keys(grouped).forEach((key) => {
grouped[key].sort((a, b) => {
const aVersion = new URL(a.url).searchParams.get('v') || '0';
const bVersion = new URL(b.url).searchParams.get('v') || '0';
const aVersion = new URL(a.url).searchParams.get("v") || "0";
const bVersion = new URL(b.url).searchParams.get("v") || "0";
return Number(bVersion) - Number(aVersion);
});
});
@@ -363,13 +368,13 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if ("serviceWorker" in navigator && "caches" in window) {
const cacheNames = await caches.keys();
await Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)));
// Forcer la mise à jour du service worker
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
toast({
title: t("settings.cache.title"),
description: t("settings.cache.messages.serviceWorkerCleared"),
@@ -383,7 +388,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
if (showSwEntries) {
await fetchSwCacheEntries();
}
// Recharger la page après 1 seconde pour réenregistrer le SW
setTimeout(() => {
window.location.reload();
@@ -458,7 +463,6 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<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">
<Label htmlFor="cache-mode">{t("settings.cache.mode.label")}</Label>
@@ -488,7 +492,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
</div>
</div>
) : (
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
<div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)}
</div>
@@ -497,7 +503,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
{swCacheSize !== null ? (
<div className="text-sm text-muted-foreground">{formatBytes(swCacheSize)}</div>
) : (
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
<div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)}
</div>
@@ -506,7 +514,9 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
{apiCacheSize !== null ? (
<div className="text-sm text-muted-foreground">{formatBytes(apiCacheSize)}</div>
) : (
<div className="text-sm text-muted-foreground">{t("settings.cache.size.error")}</div>
<div className="text-sm text-muted-foreground">
{t("settings.cache.size.error")}
</div>
)}
</div>
</div>
@@ -525,11 +535,7 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<List className="h-4 w-4" />
{t("settings.cache.entries.serverTitle")}
</span>
{showEntries ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{showEntries ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
{showEntries && (
@@ -569,7 +575,10 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
>
{getTimeRemaining(entry.expiry)}
</div>
<div className="text-muted-foreground/70" title={formatDate(entry.expiry)}>
<div
className="text-muted-foreground/70"
title={formatDate(entry.expiry)}
>
{new Date(entry.expiry).toLocaleDateString()}
</div>
</div>
@@ -649,72 +658,90 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
<div className="space-y-1 pl-2">
{(() => {
const versionGroups = groupVersions(entries);
return Object.entries(versionGroups).map(([baseUrl, versions]) => {
const hasMultipleVersions = versions.length > 1;
const isVersionExpanded = expandedVersions[baseUrl];
const totalSize = versions.reduce((sum, v) => sum + v.size, 0);
return Object.entries(versionGroups).map(
([baseUrl, versions]) => {
const hasMultipleVersions = versions.length > 1;
const isVersionExpanded = expandedVersions[baseUrl];
const totalSize = versions.reduce(
(sum, v) => sum + v.size,
0
);
if (!hasMultipleVersions) {
const entry = versions[0];
return (
<div key={baseUrl} className="py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate text-muted-foreground" title={entry.url}>
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
if (!hasMultipleVersions) {
const entry = versions[0];
return (
<div key={baseUrl} className="py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div
className="font-mono text-xs truncate text-muted-foreground"
title={entry.url}
>
{entry.url.replace(/^https?:\/\/[^/]+/, "")}
</div>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(entry.size)}
</div>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatBytes(entry.size)}
</div>
</div>
);
}
return (
<div key={baseUrl} className="py-1">
<button
type="button"
onClick={() => toggleVersions(baseUrl)}
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
>
<div className="flex-1 min-w-0 flex items-center gap-1">
{isVersionExpanded ? (
<ChevronDown className="h-3 w-3 flex-shrink-0" />
) : (
<ChevronUp className="h-3 w-3 flex-shrink-0" />
)}
<div
className="font-mono text-xs truncate text-muted-foreground"
title={baseUrl}
>
{baseUrl}
</div>
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
{versions.length} versions
</span>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
{formatBytes(totalSize)}
</div>
</button>
{isVersionExpanded && (
<div className="pl-4 mt-1 space-y-1">
{versions.map((version, vIdx) => (
<div
key={vIdx}
className="py-0.5 flex items-start justify-between gap-2"
>
<div className="flex-1 min-w-0">
<div
className="font-mono text-xs truncate text-muted-foreground/70"
title={version.url}
>
{new URL(version.url).search ||
"(no version)"}
</div>
</div>
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
{formatBytes(version.size)}
</div>
</div>
))}
</div>
)}
</div>
);
}
return (
<div key={baseUrl} className="py-1">
<button
type="button"
onClick={() => toggleVersions(baseUrl)}
className="w-full flex items-start justify-between gap-2 hover:bg-muted/30 rounded p-1 -m-1 transition-colors"
>
<div className="flex-1 min-w-0 flex items-center gap-1">
{isVersionExpanded ? (
<ChevronDown className="h-3 w-3 flex-shrink-0" />
) : (
<ChevronUp className="h-3 w-3 flex-shrink-0" />
)}
<div className="font-mono text-xs truncate text-muted-foreground" title={baseUrl}>
{baseUrl}
</div>
<span className="inline-flex items-center rounded-full bg-orange-500/10 px-1.5 py-0.5 text-xs font-medium text-orange-600 dark:text-orange-400 flex-shrink-0">
{versions.length} versions
</span>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap font-medium">
{formatBytes(totalSize)}
</div>
</button>
{isVersionExpanded && (
<div className="pl-4 mt-1 space-y-1">
{versions.map((version, vIdx) => (
<div key={vIdx} className="py-0.5 flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-mono text-xs truncate text-muted-foreground/70" title={version.url}>
{new URL(version.url).search || "(no version)"}
</div>
</div>
<div className="text-xs text-muted-foreground/70 whitespace-nowrap">
{formatBytes(version.size)}
</div>
</div>
))}
</div>
)}
</div>
);
});
);
})()}
</div>
)}
@@ -833,12 +860,24 @@ export function CacheSettings({ initialTTLConfig }: CacheSettingsProps) {
onChange={handleTTLChange}
className="flex h-9 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-1 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="0">{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}</option>
<option value="3600">{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}</option>
<option value="86400">{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}</option>
<option value="604800">{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}</option>
<option value="2592000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}</option>
<option value="31536000">{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}</option>
<option value="0">
{t("settings.cache.ttl.imageCacheMaxAge.options.noCache")}
</option>
<option value="3600">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneHour")}
</option>
<option value="86400">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneDay")}
</option>
<option value="604800">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneWeek")}
</option>
<option value="2592000">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneMonth")}
</option>
<option value="31536000">
{t("settings.cache.ttl.imageCacheMaxAge.options.oneYear")}
</option>
</select>
</div>
</div>

View File

@@ -21,7 +21,7 @@ export function ClientSettings({ initialConfig, initialTTLConfig }: ClientSettin
return (
<div className="container mx-auto px-4 py-8 space-y-6">
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
<Tabs defaultValue="display" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="display" className="flex items-center gap-2">

View File

@@ -155,7 +155,6 @@ export function KomgaSettings({ initialConfig }: KomgaSettingsProps) {
<CardDescription>{t("settings.komga.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!shouldShowForm ? (
<div className="space-y-4">
<div className="space-y-3">

View File

@@ -8,16 +8,7 @@ interface OptimizedSkeletonProps {
}
export function OptimizedSkeleton({ className, children }: OptimizedSkeletonProps) {
return (
<div
className={cn(
"animate-pulse rounded-md bg-muted/50",
className
)}
>
{children}
</div>
);
return <div className={cn("animate-pulse rounded-md bg-muted/50", className)}>{children}</div>;
}
export function HomePageSkeleton() {

View File

@@ -13,9 +13,9 @@ interface ErrorMessageProps {
retryLabel?: string;
}
export const ErrorMessage = ({
errorCode,
error,
export const ErrorMessage = ({
errorCode,
error,
variant = "default",
onRetry,
retryLabel,
@@ -37,12 +37,7 @@ export const ErrorMessage = ({
<AlertCircle className="h-4 w-4" />
<p>{message}</p>
{onRetry && (
<Button
onClick={onRetry}
variant="ghost"
size="sm"
className="ml-auto"
>
<Button onClick={onRetry} variant="ghost" size="sm" className="ml-auto">
<RefreshCw className="h-3 w-3" />
</Button>
)}
@@ -68,11 +63,11 @@ export const ErrorMessage = ({
{t("errors.GENERIC_ERROR")}
</h3>
<p className="text-sm text-destructive/90 dark:text-red-300/90">{message}</p>
{onRetry && (
<Button
<Button
onClick={onRetry}
variant="outline"
variant="outline"
size="sm"
className="mt-4 border-destructive/30 hover:bg-destructive/10"
>

View File

@@ -24,13 +24,10 @@ const badgeVariants = cva(
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -138,8 +138,8 @@ export function BookCover({
{showOverlay && overlayVariant === "default" && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-4 space-y-2 translate-y-full group-hover:translate-y-0 transition-transform duration-200">
<p className="text-sm font-medium text-white text-left line-clamp-2">
{book.metadata.title ||
(book.metadata.number
{book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: "")}
</p>
@@ -155,8 +155,8 @@ export function BookCover({
{showOverlay && overlayVariant === "home" && (
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
<h3 className="font-medium text-sm text-white line-clamp-2">
{book.metadata.title ||
(book.metadata.number
{book.metadata.title ||
(book.metadata.number
? t("navigation.volume", { number: book.metadata.number })
: "")}
</h3>

View File

@@ -273,8 +273,8 @@ export function BookOfflineButton({ book, className }: BookOfflineButtonProps) {
const buttonTitle = isLoading
? `Téléchargement en cours (${Math.round(downloadProgress)}%)`
: isAvailableOffline
? "Supprimer hors ligne"
: "Disponible hors ligne";
? "Supprimer hors ligne"
: "Disponible hors ligne";
return (
<Button

View File

@@ -9,9 +9,12 @@ const buttonVariants = cva(
variants: {
variant: {
default: "bg-primary/90 backdrop-blur-md text-primary-foreground hover:bg-primary/80",
destructive: "bg-destructive/90 backdrop-blur-md text-destructive-foreground hover:bg-destructive/80",
outline: "border border-input bg-background/70 backdrop-blur-md hover:bg-accent/80 hover:text-accent-foreground",
secondary: "bg-secondary/80 backdrop-blur-md text-secondary-foreground hover:bg-secondary/70",
destructive:
"bg-destructive/90 backdrop-blur-md text-destructive-foreground hover:bg-destructive/80",
outline:
"border border-input bg-background/70 backdrop-blur-md hover:bg-accent/80 hover:text-accent-foreground",
secondary:
"bg-secondary/80 backdrop-blur-md text-secondary-foreground hover:bg-secondary/70",
ghost: "hover:bg-accent/80 hover:backdrop-blur-md hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
@@ -30,8 +33,7 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

View File

@@ -6,7 +6,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm", className)}
className={cn(
"rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm",
className
)}
{...props}
/>
)

View File

@@ -26,4 +26,3 @@ const Checkbox = React.forwardRef<
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -24,8 +24,7 @@ const containerVariants = cva("mx-auto px-2 sm:px-6 lg:px-8", {
});
export interface ContainerProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof containerVariants> {
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof containerVariants> {
as?: React.ElementType;
}
@@ -44,4 +43,3 @@ const Container = React.forwardRef<HTMLDivElement, ContainerProps>(
Container.displayName = "Container";
export { Container, containerVariants };

View File

@@ -56,7 +56,7 @@ export const CoverClient = ({
const timer = setTimeout(() => {
setImageError(false);
setIsLoading(true);
setRetryCount(prev => prev + 1);
setRetryCount((prev) => prev + 1);
}, 2000);
return () => clearTimeout(timer);
@@ -80,9 +80,10 @@ export const CoverClient = ({
};
// Ajouter un timestamp pour forcer le rechargement en cas de retry
const imageUrlWithRetry = retryCount > 0
? `${imageUrl}${imageUrl.includes('?') ? '&' : '?'}retry=${retryCount}`
: imageUrl;
const imageUrlWithRetry =
retryCount > 0
? `${imageUrl}${imageUrl.includes("?") ? "&" : "?"}retry=${retryCount}`
: imageUrl;
if (imageError) {
return (

View File

@@ -30,4 +30,3 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
IconButton.displayName = "IconButton";
export { IconButton };

View File

@@ -8,18 +8,12 @@ const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement>,
VariantProps<typeof labelVariants> {}
interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement>, VariantProps<typeof labelVariants> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
)
);
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(({ className, ...props }, ref) => (
<label ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = "Label";
export { Label };

View File

@@ -25,9 +25,7 @@ const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
<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>
)}
{count !== undefined && <span className="text-xs text-muted-foreground">{count}</span>}
</button>
);
}
@@ -36,4 +34,3 @@ const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
NavButton.displayName = "NavButton";
export { NavButton };

View File

@@ -6,10 +6,11 @@ interface ProgressBarProps {
export function ProgressBar({ progress, total, type }: ProgressBarProps) {
const percentage = Math.round((progress / total) * 100);
const barColor = type === "series"
? "bg-gradient-to-r from-purple-500 to-pink-500"
: "bg-gradient-to-r from-blue-500 to-cyan-500";
const barColor =
type === "series"
? "bg-gradient-to-r from-purple-500 to-pink-500"
: "bg-gradient-to-r from-blue-500 to-cyan-500";
return (
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-black/70 backdrop-blur-md border-t border-white/10">
<div className="h-2 bg-white/30 rounded-full overflow-hidden">

View File

@@ -10,9 +10,7 @@ const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />
);
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
@@ -38,4 +36,3 @@ const RadioGroupItem = React.forwardRef<
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };

View File

@@ -76,10 +76,7 @@ const ScrollContainer = React.forwardRef<HTMLDivElement, ScrollContainerProps>(
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className={cn(
"flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4",
className
)}
className={cn("flex gap-4 overflow-x-auto scrollbar-hide scroll-smooth pb-4", className)}
{...props}
>
{children}
@@ -102,4 +99,3 @@ const ScrollContainer = React.forwardRef<HTMLDivElement, ScrollContainerProps>(
ScrollContainer.displayName = "ScrollContainer";
export { ScrollContainer };

View File

@@ -42,4 +42,3 @@ const Section = React.forwardRef<HTMLElement, SectionProps>(
Section.displayName = "Section";
export { Section };

View File

@@ -24,4 +24,3 @@ const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
Separator.displayName = "Separator";
export { Separator };

View File

@@ -1,16 +1,7 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };

View File

@@ -69,10 +69,7 @@ export function SliderControl({
<Plus className="h-4 w-4" />
</Button>
</div>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
);
}

View File

@@ -1,9 +1,9 @@
"use client"
"use client";
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
@@ -11,11 +11,8 @@ const Slider = React.forwardRef<
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-auto select-none items-center",
className
)}
style={{ touchAction: 'pan-x' }}
className={cn("relative flex w-full touch-auto select-none items-center", className)}
style={{ touchAction: "pan-x" }}
{...props}
>
<SliderPrimitive.Track className="relative h-3 w-full grow overflow-hidden rounded-full bg-secondary">
@@ -23,8 +20,7 @@ const Slider = React.forwardRef<
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-6 w-6 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:scale-110 active:scale-105 touch-manipulation cursor-pointer" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -21,19 +21,14 @@ const statusBadgeVariants = cva("flex items-center gap-1", {
});
export interface StatusBadgeProps
extends Omit<BadgeProps, "variant">,
VariantProps<typeof statusBadgeVariants> {
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}
>
<Badge variant="outline" className={cn(statusBadgeVariants({ status }), className)} {...props}>
{Icon && <Icon className="w-4 h-4" />}
{children}
</Badge>
@@ -41,4 +36,3 @@ const StatusBadge = ({ status, icon: Icon, children, className, ...props }: Stat
};
export { StatusBadge, statusBadgeVariants };

View File

@@ -3,8 +3,10 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface SwitchProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "role" | "aria-checked"> {
interface SwitchProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type" | "role" | "aria-checked"
> {
onCheckedChange?: (checked: boolean) => void;
}

View File

@@ -5,11 +5,7 @@ import { cn } from "@/lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
)
);
@@ -37,7 +33,10 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 backdrop-blur-md font-medium [&>tr]:last:border-b-0", className)}
className={cn(
"border-t bg-muted/50 backdrop-blur-md font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
@@ -93,4 +92,3 @@ const TableCaption = React.forwardRef<
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };

View File

@@ -27,7 +27,8 @@ const toastVariants = cva(
{
variants: {
variant: {
default: "border border-border/40 bg-background/70 backdrop-blur-md text-foreground shadow-lg",
default:
"border border-border/40 bg-background/70 backdrop-blur-md text-foreground shadow-lg",
destructive:
"destructive group border-destructive/20 bg-destructive/70 backdrop-blur-md text-destructive-foreground font-medium",
},