chore: update various components and services for improved functionality and consistency, including formatting adjustments and minor refactors
This commit is contained in:
@@ -75,7 +75,8 @@ export function ChangePasswordForm() {
|
||||
<CardHeader>
|
||||
<CardTitle>Changer le mot de passe</CardTitle>
|
||||
<CardDescription>
|
||||
Assurez-vous d'utiliser un mot de passe fort (8 caractères minimum, une majuscule et un chiffre)
|
||||
Assurez-vous d'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -60,4 +60,3 @@ export function StatsCards({ stats }: StatsCardsProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -33,4 +33,3 @@ export function ViewModeButton({ onToggle }: ViewModeButtonProps) {
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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'image de fond</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useDoublePageMode() {
|
||||
);
|
||||
|
||||
const toggleDoublePage = useCallback(() => {
|
||||
setIsDoublePage(prev => !prev);
|
||||
setIsDoublePage((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
: ""),
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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" : ""}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -26,4 +26,3 @@ const Checkbox = React.forwardRef<
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
export { Checkbox };
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -30,4 +30,3 @@ const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
IconButton.displayName = "IconButton";
|
||||
|
||||
export { IconButton };
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -42,4 +42,3 @@ const Section = React.forwardRef<HTMLElement, SectionProps>(
|
||||
Section.displayName = "Section";
|
||||
|
||||
export { Section };
|
||||
|
||||
|
||||
@@ -24,4 +24,3 @@ const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
||||
Separator.displayName = "Separator";
|
||||
|
||||
export { Separator };
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user