Compare commits
6 Commits
01951c806d
...
0cb51ce99d
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cb51ce99d | |||
| 41faa30453 | |||
| 25ede2532e | |||
| 6ce8a6e38d | |||
| 83212434f2 | |||
| 9b679a4db2 |
@@ -25,7 +25,7 @@ export default async function AccountPage() {
|
|||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<UserProfileCard profile={{ ...profile, stats }} />
|
<UserProfileCard profile={{ ...profile, stats }} />
|
||||||
<ChangePasswordForm />
|
<ChangePasswordForm username={profile.email} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ import { useToast } from "@/components/ui/use-toast";
|
|||||||
import { Lock } from "lucide-react";
|
import { Lock } from "lucide-react";
|
||||||
import { changePassword } from "@/app/actions/password";
|
import { changePassword } from "@/app/actions/password";
|
||||||
|
|
||||||
export function ChangePasswordForm() {
|
interface ChangePasswordFormProps {
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangePasswordForm({ username }: ChangePasswordFormProps) {
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
@@ -77,13 +81,26 @@ export function ChangePasswordForm() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
value={username || ""}
|
||||||
|
readOnly
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="currentPassword">Mot de passe actuel</Label>
|
<Label htmlFor="currentPassword">Mot de passe actuel</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="currentPassword"
|
id="currentPassword"
|
||||||
|
name="currentPassword"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
@@ -99,7 +116,9 @@ export function ChangePasswordForm() {
|
|||||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="newPassword"
|
id="newPassword"
|
||||||
|
name="newPassword"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
@@ -115,7 +134,9 @@ export function ChangePasswordForm() {
|
|||||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
|
import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
|
import type { AppErrorType } from "@/types/global";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -16,9 +18,17 @@ interface LoginFormProps {
|
|||||||
export function LoginForm({ from }: LoginFormProps) {
|
export function LoginForm({ from }: LoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<AppErrorType | null>(null);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
|
const getSafeRedirectPath = (path?: string) => {
|
||||||
|
if (!path || !path.startsWith("/") || path.startsWith("//")) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -37,14 +47,24 @@ export function LoginForm({ from }: LoginFormProps) {
|
|||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (!result || result.error || !result.ok) {
|
||||||
setError("Email ou mot de passe incorrect");
|
setError({
|
||||||
} else {
|
code: "AUTH_INVALID_CREDENTIALS",
|
||||||
router.push(from || "/");
|
name: "Login failed",
|
||||||
router.refresh();
|
message: "Email ou mot de passe incorrect",
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
|
||||||
setError("Une erreur est survenue lors de la connexion : " + _error);
|
const redirectPath = getSafeRedirectPath(from);
|
||||||
|
window.location.assign(redirectPath);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
setError({
|
||||||
|
code: "AUTH_FETCH_ERROR",
|
||||||
|
name: "Login error",
|
||||||
|
message: "Une erreur est survenue lors de la connexion",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -80,7 +100,7 @@ export function LoginForm({ from }: LoginFormProps) {
|
|||||||
{t("login.form.remember")}
|
{t("login.form.remember")}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
{error && <ErrorMessage errorCode={error.code} variant="form" />}
|
||||||
<Button type="submit" disabled={isLoading} className="w-full">
|
<Button type="submit" disabled={isLoading} className="w-full">
|
||||||
{isLoading ? t("login.form.submit.loading.login") : t("login.form.submit.login")}
|
{isLoading ? t("login.form.submit.loading.login") : t("login.form.submit.login")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,12 +15,20 @@ interface RegisterFormProps {
|
|||||||
from?: string;
|
from?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterForm({ from: _from }: RegisterFormProps) {
|
export function RegisterForm({ from }: RegisterFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<AppErrorType | null>(null);
|
const [error, setError] = useState<AppErrorType | null>(null);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
|
const getSafeRedirectPath = (path?: string) => {
|
||||||
|
if (!path || !path.startsWith("/") || path.startsWith("//")) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -61,14 +69,15 @@ export function RegisterForm({ from: _from }: RegisterFormProps) {
|
|||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (signInResult?.error) {
|
if (!signInResult || signInResult.error || !signInResult.ok) {
|
||||||
setError({
|
setError({
|
||||||
code: "AUTH_INVALID_CREDENTIALS",
|
code: "AUTH_INVALID_CREDENTIALS",
|
||||||
name: "Login failed",
|
name: "Login failed",
|
||||||
message: "Inscription réussie mais erreur lors de la connexion automatique",
|
message: "Inscription réussie mais erreur lors de la connexion automatique",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
router.push("/");
|
const redirectPath = getSafeRedirectPath(from);
|
||||||
|
window.location.assign(redirectPath);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,18 +2,22 @@ import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
|
|||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { LayoutGrid, LayoutTemplate } from "lucide-react";
|
import { LayoutGrid, LayoutTemplate } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface CompactModeButtonProps {
|
interface CompactModeButtonProps {
|
||||||
onToggle?: (isCompact: boolean) => void;
|
onToggle?: (isCompact: boolean) => void;
|
||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactModeButtonBase({
|
function CompactModeButtonBase({
|
||||||
isCompact,
|
isCompact,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
isCompact: boolean;
|
isCompact: boolean;
|
||||||
onToggle: (isCompact: boolean) => Promise<void> | void;
|
onToggle: (isCompact: boolean) => Promise<void> | void;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
@@ -31,15 +35,21 @@ function CompactModeButtonBase({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={label}
|
title={label}
|
||||||
className="whitespace-nowrap"
|
className={cn(
|
||||||
|
"h-9 rounded-full border border-border/60 bg-background/40 px-3 text-xs font-medium backdrop-blur-sm hover:bg-accent/40 sm:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline ml-2">{label}</span>
|
<span className="ml-2 hidden whitespace-nowrap min-[420px]:inline">{label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompactModeButtonUncontrolled({ onToggle }: Pick<CompactModeButtonProps, "onToggle">) {
|
function CompactModeButtonUncontrolled({
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
}: Pick<CompactModeButtonProps, "onToggle" | "className">) {
|
||||||
const { isCompact, handleCompactToggle } = useDisplayPreferences();
|
const { isCompact, handleCompactToggle } = useDisplayPreferences();
|
||||||
|
|
||||||
const handleToggle = async (nextCompactMode: boolean) => {
|
const handleToggle = async (nextCompactMode: boolean) => {
|
||||||
@@ -47,15 +57,17 @@ function CompactModeButtonUncontrolled({ onToggle }: Pick<CompactModeButtonProps
|
|||||||
onToggle?.(nextCompactMode);
|
onToggle?.(nextCompactMode);
|
||||||
};
|
};
|
||||||
|
|
||||||
return <CompactModeButtonBase isCompact={isCompact} onToggle={handleToggle} />;
|
return (
|
||||||
|
<CompactModeButtonBase isCompact={isCompact} onToggle={handleToggle} className={className} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CompactModeButton({ onToggle, isCompact }: CompactModeButtonProps) {
|
export function CompactModeButton({ onToggle, isCompact, className }: CompactModeButtonProps) {
|
||||||
const isControlled = typeof isCompact === "boolean" && typeof onToggle === "function";
|
const isControlled = typeof isCompact === "boolean" && typeof onToggle === "function";
|
||||||
|
|
||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
return <CompactModeButtonBase isCompact={isCompact} onToggle={onToggle} />;
|
return <CompactModeButtonBase isCompact={isCompact} onToggle={onToggle} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <CompactModeButtonUncontrolled onToggle={onToggle} />;
|
return <CompactModeButtonUncontrolled onToggle={onToggle} className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PageSizeSelectProps {
|
interface PageSizeSelectProps {
|
||||||
onSizeChange?: (size: number) => void;
|
onSizeChange?: (size: number) => void;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageSizeSelectBase({
|
function PageSizeSelectBase({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
value: number;
|
value: number;
|
||||||
onChange: (size: number) => Promise<void> | void;
|
onChange: (size: number) => Promise<void> | void;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const handleChange = async (rawValue: string) => {
|
const handleChange = async (rawValue: string) => {
|
||||||
const size = parseInt(rawValue);
|
const size = parseInt(rawValue);
|
||||||
@@ -27,7 +31,12 @@ function PageSizeSelectBase({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={value.toString()} onValueChange={handleChange}>
|
<Select value={value.toString()} onValueChange={handleChange}>
|
||||||
<SelectTrigger className="w-[80px]">
|
<SelectTrigger
|
||||||
|
className={cn(
|
||||||
|
"h-9 w-[96px] rounded-full border border-border/60 bg-background/40 text-xs font-medium backdrop-blur-sm sm:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<LayoutList className="h-4 w-4" />
|
<LayoutList className="h-4 w-4" />
|
||||||
<SelectValue className="ml-2" />
|
<SelectValue className="ml-2" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -40,7 +49,10 @@ function PageSizeSelectBase({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageSizeSelectUncontrolled({ onSizeChange }: Pick<PageSizeSelectProps, "onSizeChange">) {
|
function PageSizeSelectUncontrolled({
|
||||||
|
onSizeChange,
|
||||||
|
className,
|
||||||
|
}: Pick<PageSizeSelectProps, "onSizeChange" | "className">) {
|
||||||
const { itemsPerPage, handlePageSizeChange } = useDisplayPreferences();
|
const { itemsPerPage, handlePageSizeChange } = useDisplayPreferences();
|
||||||
|
|
||||||
const onChange = async (size: number) => {
|
const onChange = async (size: number) => {
|
||||||
@@ -48,15 +60,15 @@ function PageSizeSelectUncontrolled({ onSizeChange }: Pick<PageSizeSelectProps,
|
|||||||
onSizeChange?.(size);
|
onSizeChange?.(size);
|
||||||
};
|
};
|
||||||
|
|
||||||
return <PageSizeSelectBase value={itemsPerPage} onChange={onChange} />;
|
return <PageSizeSelectBase value={itemsPerPage} onChange={onChange} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageSizeSelect({ onSizeChange, pageSize }: PageSizeSelectProps) {
|
export function PageSizeSelect({ onSizeChange, pageSize, className }: PageSizeSelectProps) {
|
||||||
const isControlled = typeof pageSize === "number" && typeof onSizeChange === "function";
|
const isControlled = typeof pageSize === "number" && typeof onSizeChange === "function";
|
||||||
|
|
||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
return <PageSizeSelectBase value={pageSize} onChange={onSizeChange} />;
|
return <PageSizeSelectBase value={pageSize} onChange={onSizeChange} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <PageSizeSelectUncontrolled onSizeChange={onSizeChange} />;
|
return <PageSizeSelectUncontrolled onSizeChange={onSizeChange} className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,19 @@
|
|||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { Filter } from "lucide-react";
|
import { Filter } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface UnreadFilterButtonProps {
|
interface UnreadFilterButtonProps {
|
||||||
showOnlyUnread: boolean;
|
showOnlyUnread: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UnreadFilterButton({ showOnlyUnread, onToggle }: UnreadFilterButtonProps) {
|
export function UnreadFilterButton({
|
||||||
|
showOnlyUnread,
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
}: UnreadFilterButtonProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
const label = showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread");
|
const label = showOnlyUnread ? t("series.filters.showAll") : t("series.filters.unread");
|
||||||
@@ -20,10 +26,16 @@ export function UnreadFilterButton({ showOnlyUnread, onToggle }: UnreadFilterBut
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
title={label}
|
title={label}
|
||||||
className="whitespace-nowrap"
|
className={cn(
|
||||||
|
"h-9 rounded-full border px-3 text-xs font-medium backdrop-blur-sm sm:text-sm",
|
||||||
|
showOnlyUnread
|
||||||
|
? "border-primary/40 bg-primary/15 text-primary hover:bg-primary/20"
|
||||||
|
: "border-border/60 bg-background/40 hover:bg-accent/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline ml-2">{label}</span>
|
<span className="ml-2 hidden whitespace-nowrap min-[420px]:inline">{label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,22 @@ import { useDisplayPreferences } from "@/hooks/useDisplayPreferences";
|
|||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
import { LayoutGrid, List } from "lucide-react";
|
import { LayoutGrid, List } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ViewModeButtonProps {
|
interface ViewModeButtonProps {
|
||||||
onToggle?: (viewMode: "grid" | "list") => void;
|
onToggle?: (viewMode: "grid" | "list") => void;
|
||||||
viewMode?: "grid" | "list";
|
viewMode?: "grid" | "list";
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ViewModeButtonBase({
|
function ViewModeButtonBase({
|
||||||
viewMode,
|
viewMode,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
viewMode: "grid" | "list";
|
viewMode: "grid" | "list";
|
||||||
onToggle: (viewMode: "grid" | "list") => Promise<void> | void;
|
onToggle: (viewMode: "grid" | "list") => Promise<void> | void;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
@@ -31,15 +35,21 @@ function ViewModeButtonBase({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={label}
|
title={label}
|
||||||
className="whitespace-nowrap"
|
className={cn(
|
||||||
|
"h-9 rounded-full border border-border/60 bg-background/40 px-3 text-xs font-medium backdrop-blur-sm hover:bg-accent/40 sm:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline ml-2">{label}</span>
|
<span className="ml-2 hidden whitespace-nowrap min-[420px]:inline">{label}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ViewModeButtonUncontrolled({ onToggle }: Pick<ViewModeButtonProps, "onToggle">) {
|
function ViewModeButtonUncontrolled({
|
||||||
|
onToggle,
|
||||||
|
className,
|
||||||
|
}: Pick<ViewModeButtonProps, "onToggle" | "className">) {
|
||||||
const { viewMode, handleViewModeToggle } = useDisplayPreferences();
|
const { viewMode, handleViewModeToggle } = useDisplayPreferences();
|
||||||
|
|
||||||
const handleToggle = async (nextViewMode: "grid" | "list") => {
|
const handleToggle = async (nextViewMode: "grid" | "list") => {
|
||||||
@@ -47,15 +57,15 @@ function ViewModeButtonUncontrolled({ onToggle }: Pick<ViewModeButtonProps, "onT
|
|||||||
onToggle?.(nextViewMode);
|
onToggle?.(nextViewMode);
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ViewModeButtonBase viewMode={viewMode} onToggle={handleToggle} />;
|
return <ViewModeButtonBase viewMode={viewMode} onToggle={handleToggle} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ViewModeButton({ onToggle, viewMode }: ViewModeButtonProps) {
|
export function ViewModeButton({ onToggle, viewMode, className }: ViewModeButtonProps) {
|
||||||
const isControlled = typeof viewMode === "string" && typeof onToggle === "function";
|
const isControlled = typeof viewMode === "string" && typeof onToggle === "function";
|
||||||
|
|
||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
return <ViewModeButtonBase viewMode={viewMode} onToggle={onToggle} />;
|
return <ViewModeButtonBase viewMode={viewMode} onToggle={onToggle} className={className} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ViewModeButtonUncontrolled onToggle={onToggle} />;
|
return <ViewModeButtonUncontrolled onToggle={onToggle} className={className} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useRouter } from "next/navigation";
|
|||||||
import { RefreshButton } from "@/components/library/RefreshButton";
|
import { RefreshButton } from "@/components/library/RefreshButton";
|
||||||
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
import { PullToRefreshIndicator } from "@/components/common/PullToRefreshIndicator";
|
||||||
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
import { usePullToRefresh } from "@/hooks/usePullToRefresh";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
|
||||||
|
|
||||||
interface HomeClientWrapperProps {
|
interface HomeClientWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -13,7 +12,6 @@ interface HomeClientWrapperProps {
|
|||||||
|
|
||||||
export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
|
export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslate();
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
@@ -45,12 +43,16 @@ export function HomeClientWrapper({ children }: HomeClientWrapperProps) {
|
|||||||
canRefresh={pullToRefresh.canRefresh}
|
canRefresh={pullToRefresh.canRefresh}
|
||||||
isHiding={pullToRefresh.isHiding}
|
isHiding={pullToRefresh.isHiding}
|
||||||
/>
|
/>
|
||||||
<main className="container mx-auto px-4 py-8 space-y-12">
|
<main className="relative isolate overflow-hidden">
|
||||||
<div className="flex justify-between items-center">
|
<div className="pointer-events-none absolute inset-0 -z-10 bg-[linear-gradient(180deg,hsl(var(--background)/0.99)_0%,hsl(var(--background)/0.95)_28%,hsl(var(--background))_100%)]" />
|
||||||
<h1 className="text-3xl font-bold">{t("home.title")}</h1>
|
<div className="pointer-events-none absolute inset-0 -z-10 bg-[linear-gradient(128deg,hsl(var(--primary)/0.14)_0%,transparent_36%),linear-gradient(235deg,hsl(185_82%_54%/0.1)_4%,transparent_34%),linear-gradient(320deg,hsl(332_82%_63%/0.08)_8%,transparent_32%)]" />
|
||||||
|
|
||||||
|
<div className="container mx-auto space-y-12 px-4 py-8">
|
||||||
|
<div className="flex justify-end">
|
||||||
<RefreshButton libraryId="home" refreshLibrary={handleRefresh} />
|
<RefreshButton libraryId="home" refreshLibrary={handleRefresh} />
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,17 @@ const optimizeBookData = (books: KomgaBook[]) => {
|
|||||||
|
|
||||||
export function HomeContent({ data }: HomeContentProps) {
|
export function HomeContent({ data }: HomeContentProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-10 pb-2">
|
||||||
|
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
||||||
|
<div className="rounded-2xl border border-primary/20 bg-[linear-gradient(145deg,hsl(var(--primary)/0.12),hsl(var(--background)/0.1)_45%)] p-4 sm:p-5">
|
||||||
|
<MediaRow
|
||||||
|
titleKey="home.sections.continue_reading"
|
||||||
|
items={optimizeBookData(data.ongoingBooks)}
|
||||||
|
iconName="BookOpen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{data.ongoing && data.ongoing.length > 0 && (
|
{data.ongoing && data.ongoing.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.continue_series"
|
titleKey="home.sections.continue_series"
|
||||||
@@ -38,14 +48,6 @@ export function HomeContent({ data }: HomeContentProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.ongoingBooks && data.ongoingBooks.length > 0 && (
|
|
||||||
<MediaRow
|
|
||||||
titleKey="home.sections.continue_reading"
|
|
||||||
items={optimizeBookData(data.ongoingBooks)}
|
|
||||||
iconName="BookOpen"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.onDeck && data.onDeck.length > 0 && (
|
{data.onDeck && data.onDeck.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.up_next"
|
titleKey="home.sections.up_next"
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ export function MediaRow({ titleKey, items, iconName }: MediaRowProps) {
|
|||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t(titleKey)} icon={icon}>
|
<Section
|
||||||
|
title={t(titleKey)}
|
||||||
|
icon={icon}
|
||||||
|
className="space-y-5"
|
||||||
|
headerClassName="border-b border-border/50 pb-2"
|
||||||
|
>
|
||||||
<ScrollContainer
|
<ScrollContainer
|
||||||
showArrows={true}
|
showArrows={true}
|
||||||
scrollAmount={400}
|
scrollAmount={400}
|
||||||
@@ -106,7 +111,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
<Card
|
<Card
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-shrink-0 w-[200px] relative flex flex-col hover:bg-accent hover:text-accent-foreground transition-colors overflow-hidden",
|
"relative flex w-[188px] flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border/60 bg-card/85 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:bg-card hover:shadow-md sm:w-[200px]",
|
||||||
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
|
!isSeries && !isAccessible ? "cursor-not-allowed" : "cursor-pointer"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -114,7 +119,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
{isSeries ? (
|
{isSeries ? (
|
||||||
<>
|
<>
|
||||||
<SeriesCover series={item as KomgaSeries} alt={`Couverture de ${title}`} />
|
<SeriesCover series={item as KomgaSeries} alt={`Couverture de ${title}`} />
|
||||||
<div className="absolute inset-0 bg-black/60 opacity-0 hover:opacity-100 transition-opacity duration-200 flex flex-col justify-end p-3">
|
<div className="absolute inset-0 flex flex-col justify-end bg-gradient-to-t from-black/75 via-black/30 to-transparent p-3 opacity-0 transition-opacity duration-200 hover:opacity-100">
|
||||||
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
||||||
<p className="text-xs text-white/80 mt-1">
|
<p className="text-xs text-white/80 mt-1">
|
||||||
{t("series.books", { count: item.booksCount })}
|
{t("series.books", { count: item.booksCount })}
|
||||||
|
|||||||
@@ -176,6 +176,14 @@ export default function ClientLayout({
|
|||||||
userIsAdmin={userIsAdmin}
|
userIsAdmin={userIsAdmin}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isPublicRoute && isSidebarOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Fermer la navigation"
|
||||||
|
className="fixed inset-0 top-[calc(4rem+env(safe-area-inset-top,0px))] z-20 bg-black/35 backdrop-blur-[1px] transition-opacity lg:hidden"
|
||||||
|
onClick={handleCloseSidebar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<main className={!isPublicRoute ? "pt-safe" : ""}>{children}</main>
|
<main className={!isPublicRoute ? "pt-safe" : ""}>{children}</main>
|
||||||
<InstallPWA />
|
<InstallPWA />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -33,46 +33,56 @@ export function Header({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/50 pt-safe">
|
<header className="sticky top-0 z-50 w-full border-b border-primary/30 bg-background/70 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/65 pt-safe relative overflow-hidden">
|
||||||
<div className="container flex h-14 max-w-screen-2xl items-center">
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(112deg,hsl(var(--primary)/0.24)_0%,hsl(192_85%_55%/0.2)_30%,transparent_56%),linear-gradient(248deg,hsl(338_82%_62%/0.16)_0%,transparent_46%),repeating-linear-gradient(135deg,hsl(var(--foreground)/0.03)_0_1px,transparent_1px_11px)]" />
|
||||||
|
<div className="container relative flex h-16 max-w-screen-2xl items-center">
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
icon={Menu}
|
icon={Menu}
|
||||||
onClick={onToggleSidebar}
|
onClick={onToggleSidebar}
|
||||||
tooltip={t("header.toggleSidebar")}
|
tooltip={t("header.toggleSidebar")}
|
||||||
className="mr-2"
|
className="mr-2 h-10 w-10 rounded-full"
|
||||||
id="sidebar-toggle"
|
id="sidebar-toggle"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mr-4 hidden md:flex">
|
<div className="mr-2 flex items-center md:mr-4">
|
||||||
<a className="mr-6 flex items-center space-x-2" href="/">
|
<a className="mr-2 flex items-center md:mr-6" href="/">
|
||||||
<span className="hidden font-bold sm:inline-block">StripStream</span>
|
<span className="inline-flex bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-sm font-bold uppercase tracking-[0.1em] text-transparent sm:hidden">
|
||||||
|
StripStream
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline-flex flex-col leading-none">
|
||||||
|
<span className="bg-gradient-to-r from-primary via-cyan-500 to-fuchsia-500 bg-clip-text text-lg font-bold tracking-[0.1em] text-transparent">
|
||||||
|
STRIPSTREAM
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 text-[10px] font-medium uppercase tracking-[0.28em] text-foreground/70">
|
||||||
|
comic reader
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
|
<div className="ml-auto flex items-center">
|
||||||
<nav className="flex items-center space-x-2">
|
<nav className="flex items-center gap-1 rounded-full border border-border/60 bg-background/45 px-1 py-1 shadow-[0_4px_18px_-14px_rgba(0,0,0,0.65)] backdrop-blur-md">
|
||||||
{showRefreshBackground && (
|
{showRefreshBackground && (
|
||||||
<button
|
<IconButton
|
||||||
onClick={handleRefreshBackground}
|
onClick={handleRefreshBackground}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
className="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="ghost"
|
||||||
aria-label="Rafraîchir l'image de fond"
|
size="icon"
|
||||||
>
|
icon={RefreshCw}
|
||||||
<RefreshCw
|
iconClassName={isRefreshing ? "animate-spin" : ""}
|
||||||
className={`h-[1.2rem] w-[1.2rem] ${isRefreshing ? "animate-spin" : ""}`}
|
className="h-9 w-9 rounded-full"
|
||||||
|
tooltip="Rafraîchir l'image de fond"
|
||||||
/>
|
/>
|
||||||
<span className="sr-only">Rafraîchir l'image de fond</span>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="px-2 py-1.5 hover:bg-accent hover:text-accent-foreground rounded-md"
|
className="rounded-full p-2 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
aria-label={t("header.toggleTheme")}
|
aria-label={t("header.toggleTheme")}
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center w-5 h-5">
|
<div className="relative flex h-5 w-5 items-center">
|
||||||
<Sun className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute inset-0 h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,17 +149,19 @@ export function Sidebar({
|
|||||||
<aside
|
<aside
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-0 top-14 z-30 h-[calc(100vh-3.5rem)] w-64 border-r border-border/40",
|
"fixed left-0 top-[calc(4rem+env(safe-area-inset-top,0px))] z-30 h-[calc(100vh-4rem-env(safe-area-inset-top,0px))] w-72 border-r border-primary/30",
|
||||||
"bg-background/70 backdrop-blur-md supports-[backdrop-filter]:bg-background/50",
|
"bg-background/70 shadow-sm backdrop-blur-xl supports-[backdrop-filter]:bg-background/65",
|
||||||
"transition-transform duration-300 ease-in-out flex flex-col",
|
"transition-transform duration-300 ease-in-out flex flex-col",
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full"
|
isOpen ? "translate-x-0" : "-translate-x-full"
|
||||||
)}
|
)}
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
>
|
>
|
||||||
<div className="flex-1 space-y-4 py-4 overflow-y-auto">
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(160deg,hsl(var(--primary)/0.12)_0%,hsl(192_85%_55%/0.08)_32%,transparent_58%),linear-gradient(332deg,hsl(338_82%_62%/0.06)_0%,transparent_42%),repeating-linear-gradient(135deg,hsl(var(--foreground)/0.02)_0_1px,transparent_1px_11px)]" />
|
||||||
<div className="px-3 py-2">
|
|
||||||
|
<div className="relative flex-1 space-y-4 overflow-y-auto px-3 py-4">
|
||||||
|
<div className="rounded-xl border border-border/50 bg-background/30 p-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
<h2 className="mb-2 px-3 text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
{t("sidebar.navigation")}
|
{t("sidebar.navigation")}
|
||||||
</h2>
|
</h2>
|
||||||
{mainNavItems.map((item) => (
|
{mainNavItems.map((item) => (
|
||||||
@@ -174,10 +176,10 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2">
|
<div className="rounded-xl border border-border/50 bg-background/30 p-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="mb-2 px-4 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between px-3">
|
||||||
<h2 className="text-lg font-semibold tracking-tight">
|
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
{t("sidebar.favorites.title")}
|
{t("sidebar.favorites.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs text-muted-foreground">{favorites.length}</span>
|
<span className="text-xs text-muted-foreground">{favorites.length}</span>
|
||||||
@@ -205,10 +207,10 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2">
|
<div className="rounded-xl border border-border/50 bg-background/30 p-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="mb-2 px-4 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between px-3">
|
||||||
<h2 className="text-lg font-semibold tracking-tight">
|
<h2 className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
{t("sidebar.libraries.title")}
|
{t("sidebar.libraries.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -244,9 +246,9 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-3 py-2">
|
<div className="rounded-xl border border-border/50 bg-background/30 p-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="mb-2 px-4 text-lg font-semibold tracking-tight">
|
<h2 className="mb-2 px-3 text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
|
||||||
{t("sidebar.settings.title")}
|
{t("sidebar.settings.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<NavButton
|
<NavButton
|
||||||
@@ -273,7 +275,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 border-t border-border/40">
|
<div className="relative border-t border-border/50 bg-background/30 p-3">
|
||||||
<NavButton
|
<NavButton
|
||||||
icon={LogOut}
|
icon={LogOut}
|
||||||
label={t("sidebar.logout")}
|
label={t("sidebar.logout")}
|
||||||
|
|||||||
@@ -36,14 +36,14 @@ export function LibraryHeader({
|
|||||||
const seriesLabel = `${seriesCount} ${seriesCount > 1 ? "series" : "serie"}`;
|
const seriesLabel = `${seriesCount} ${seriesCount > 1 ? "series" : "serie"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-[200px] md:h-[200px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
<div className="relative min-h-[220px] md:h-[220px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden border-y border-border/60">
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<div className="absolute inset-0 bg-black/40" />
|
<div className="absolute inset-0 bg-gradient-to-r from-background/85 via-background/65 to-background/85" />
|
||||||
{background ? (
|
{background ? (
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
series={background}
|
series={background}
|
||||||
alt=""
|
alt=""
|
||||||
className="blur-sm scale-105 brightness-50"
|
className="scale-105 blur-sm brightness-50"
|
||||||
showProgressUi={false}
|
showProgressUi={false}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -51,9 +51,9 @@ export function LibraryHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative container mx-auto px-4 py-8 h-full">
|
<div className="relative container mx-auto h-full px-4 py-8">
|
||||||
<div className="flex flex-col md:flex-row gap-6 items-center md:items-start h-full">
|
<div className="flex h-full flex-col items-center gap-6 md:flex-row md:items-start">
|
||||||
<div className="relative w-[120px] h-[120px] rounded-lg overflow-hidden shadow-lg flex-shrink-0">
|
<div className="relative h-[120px] w-[120px] flex-shrink-0 overflow-hidden rounded-xl border border-border/60 shadow-lg">
|
||||||
{featured ? (
|
{featured ? (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
@@ -73,10 +73,10 @@ export function LibraryHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 space-y-3 text-center md:text-left">
|
<div className="flex-1 space-y-4 text-center md:text-left">
|
||||||
<h1 className="text-3xl md:text-4xl font-bold text-foreground">{library.name}</h1>
|
<h1 className="text-3xl font-bold text-foreground md:text-4xl">{library.name}</h1>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 justify-center md:justify-start flex-wrap">
|
<div className="flex flex-wrap items-center justify-center gap-3 rounded-xl border border-border/60 bg-background/45 p-2 backdrop-blur-sm md:justify-start">
|
||||||
<StatusBadge status="unread" icon={Library}>
|
<StatusBadge status="unread" icon={Library}>
|
||||||
{seriesLabel}
|
{seriesLabel}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
|
|||||||
@@ -171,17 +171,39 @@ export function PaginatedSeriesGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="rounded-2xl border border-border/60 bg-[linear-gradient(140deg,hsl(var(--background)/0.6),hsl(var(--background)/0.38))] p-4 shadow-sm backdrop-blur-sm sm:p-5">
|
||||||
<p className="text-sm text-muted-foreground text-right">{getShowingText()}</p>
|
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<div className="space-y-1">
|
||||||
<div className="w-full">
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
<SearchInput placeholder={t("series.filters.search")} />
|
Explorer
|
||||||
|
</p>
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight sm:text-2xl">Séries</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{getShowingText()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SearchInput placeholder={t("series.filters.search")} />
|
||||||
|
|
||||||
|
<div className="pb-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<UnreadFilterButton
|
||||||
|
showOnlyUnread={showOnlyUnread}
|
||||||
|
onToggle={handleUnreadFilter}
|
||||||
|
/>
|
||||||
|
<ViewModeButton
|
||||||
|
viewMode={viewMode}
|
||||||
|
onToggle={handleViewModeToggle}
|
||||||
|
/>
|
||||||
|
<CompactModeButton
|
||||||
|
isCompact={isCompact}
|
||||||
|
onToggle={handleCompactModeToggle}
|
||||||
|
/>
|
||||||
|
<PageSizeSelect
|
||||||
|
pageSize={effectivePageSize}
|
||||||
|
onSizeChange={handlePageSizeChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<PageSizeSelect pageSize={effectivePageSize} onSizeChange={handlePageSizeChange} />
|
|
||||||
<ViewModeButton viewMode={viewMode} onToggle={handleViewModeToggle} />
|
|
||||||
<CompactModeButton isCompact={isCompact} onToggle={handleCompactModeToggle} />
|
|
||||||
<UnreadFilterButton showOnlyUnread={showOnlyUnread} onToggle={handleUnreadFilter} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ export const SearchInput = ({ placeholder }: SearchInputProps) => {
|
|||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full max-w-md">
|
<div className="relative w-full rounded-2xl border border-border/60 bg-[linear-gradient(140deg,hsl(var(--background)/0.72),hsl(var(--background)/0.5))] px-1 shadow-sm backdrop-blur-sm transition-colors focus-within:border-primary/40 focus-within:ring-2 focus-within:ring-ring/30 sm:max-w-2xl">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type={isPending ? "text" : "search"}
|
type={isPending ? "text" : "search"}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="pl-3"
|
className="h-12 rounded-xl border-0 bg-transparent pl-10 pr-10 text-sm shadow-none focus-visible:border-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
defaultValue={searchParams.get("search") ?? ""}
|
defaultValue={searchParams.get("search") ?? ""}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
aria-label={placeholder}
|
aria-label={placeholder}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const getReadingStatusInfo = (
|
|||||||
read: series.booksReadCount,
|
read: series.booksReadCount,
|
||||||
total: series.booksCount,
|
total: series.booksCount,
|
||||||
}),
|
}),
|
||||||
className: "bg-blue-500/10 text-blue-500",
|
className: "bg-primary/15 text-primary",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid gap-4",
|
"grid gap-4 md:gap-5",
|
||||||
isCompact
|
isCompact
|
||||||
? "grid-cols-3 sm:grid-cols-4 lg:grid-cols-6"
|
? "grid-cols-3 sm:grid-cols-4 lg:grid-cols-6"
|
||||||
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
: "grid-cols-2 sm:grid-cols-3 lg:grid-cols-5"
|
||||||
@@ -72,7 +72,7 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
|||||||
key={series.id}
|
key={series.id}
|
||||||
onClick={() => router.push(`/series/${series.id}`)}
|
onClick={() => router.push(`/series/${series.id}`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative aspect-[2/3] overflow-hidden rounded-lg bg-muted",
|
"group relative aspect-[2/3] overflow-hidden rounded-xl border border-border/60 bg-card/80 shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md",
|
||||||
series.booksCount === series.booksReadCount && "opacity-50",
|
series.booksCount === series.booksReadCount && "opacity-50",
|
||||||
isCompact && "aspect-[3/4]"
|
isCompact && "aspect-[3/4]"
|
||||||
)}
|
)}
|
||||||
@@ -81,7 +81,7 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
|||||||
series={series as KomgaSeries}
|
series={series as KomgaSeries}
|
||||||
alt={t("series.coverAlt", { title: series.metadata.title })}
|
alt={t("series.coverAlt", { title: series.metadata.title })}
|
||||||
/>
|
/>
|
||||||
<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">
|
<div className="absolute inset-x-0 bottom-0 translate-y-full space-y-2 bg-gradient-to-t from-black/75 via-black/25 to-transparent p-4 transition-transform duration-200 group-hover:translate-y-0">
|
||||||
<h3 className="font-medium text-sm text-white line-clamp-2">{series.metadata.title}</h3>
|
<h3 className="font-medium text-sm text-white line-clamp-2">{series.metadata.title}</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const getReadingStatusInfo = (
|
|||||||
read: series.booksReadCount,
|
read: series.booksReadCount,
|
||||||
total: series.booksCount,
|
total: series.booksCount,
|
||||||
}),
|
}),
|
||||||
className: "bg-blue-500/10 text-blue-500",
|
className: "bg-primary/15 text-primary",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex gap-3 p-2 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
|
"group relative flex cursor-pointer gap-3 rounded-lg border border-border/60 bg-background/35 p-2 transition-colors hover:bg-accent/35",
|
||||||
isCompleted && "opacity-75"
|
isCompleted && "opacity-75"
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -128,7 +128,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex gap-4 p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors cursor-pointer",
|
"group relative flex cursor-pointer gap-4 rounded-xl border border-border/60 bg-background/35 p-4 transition-all duration-200 hover:bg-accent/35 hover:shadow-sm",
|
||||||
isCompleted && "opacity-75"
|
isCompleted && "opacity-75"
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const ControlButtons = ({
|
|||||||
{/* Boutons de contrôle */}
|
{/* Boutons de contrôle */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-4 left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 transition-all duration-300 p-1.5 rounded-full bg-background/70 backdrop-blur-md",
|
"absolute left-1/2 top-4 z-30 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border/60 bg-background/55 p-1.5 shadow-[0_8px_28px_-18px_rgba(0,0,0,0.75)] backdrop-blur-xl transition-all duration-300",
|
||||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -178,7 +178,7 @@ export const ControlButtons = ({
|
|||||||
tooltip={t("reader.controls.previousPage")}
|
tooltip={t("reader.controls.previousPage")}
|
||||||
iconClassName="h-8 w-8"
|
iconClassName="h-8 w-8"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-1/2 -translate-y-1/2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-all duration-300 z-20",
|
"absolute top-1/2 z-20 -translate-y-1/2 rounded-full border border-border/60 bg-background/55 shadow-[0_8px_24px_-16px_rgba(0,0,0,0.75)] backdrop-blur-xl transition-all duration-300 hover:bg-background/70",
|
||||||
direction === "rtl" ? "right-4" : "left-4",
|
direction === "rtl" ? "right-4" : "left-4",
|
||||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
)}
|
)}
|
||||||
@@ -198,7 +198,7 @@ export const ControlButtons = ({
|
|||||||
tooltip={t("reader.controls.nextPage")}
|
tooltip={t("reader.controls.nextPage")}
|
||||||
iconClassName="h-8 w-8"
|
iconClassName="h-8 w-8"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute top-1/2 -translate-y-1/2 rounded-full bg-background/70 backdrop-blur-md hover:bg-background/80 transition-all duration-300 z-20",
|
"absolute top-1/2 z-20 -translate-y-1/2 rounded-full border border-border/60 bg-background/55 shadow-[0_8px_24px_-16px_rgba(0,0,0,0.75)] backdrop-blur-xl transition-all duration-300 hover:bg-background/70",
|
||||||
direction === "rtl" ? "left-4" : "right-4",
|
direction === "rtl" ? "left-4" : "right-4",
|
||||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const NavigationBar = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute bottom-0 left-0 right-0 bg-background/70 backdrop-blur-md border-t border-border/40 transition-all duration-300 ease-in-out z-30",
|
"absolute bottom-0 left-0 right-0 z-30 border-t border-border/60 bg-background/60 backdrop-blur-xl transition-all duration-300 ease-in-out",
|
||||||
showThumbnails ? "h-52 opacity-100" : "h-0 opacity-0"
|
showThumbnails ? "h-52 opacity-100" : "h-0 opacity-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -63,7 +63,7 @@ export const NavigationBar = ({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
id="thumbnails-container"
|
id="thumbnails-container"
|
||||||
className="h-full overflow-x-auto flex items-center gap-2 px-4 scroll-smooth snap-x snap-mandatory"
|
className="flex h-full snap-x snap-mandatory items-center gap-2 overflow-x-auto px-4 scroll-smooth"
|
||||||
onTouchStart={(e) => e.stopPropagation()}
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
onTouchMove={(e) => e.stopPropagation()}
|
onTouchMove={(e) => e.stopPropagation()}
|
||||||
onTouchEnd={(e) => e.stopPropagation()}
|
onTouchEnd={(e) => e.stopPropagation()}
|
||||||
@@ -90,7 +90,7 @@ export const NavigationBar = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showControls && (
|
{showControls && (
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-full px-4 py-2 rounded-full bg-background/70 backdrop-blur-md text-sm">
|
<div className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-full rounded-full border border-border/60 bg-background/60 px-4 py-2 text-sm shadow-[0_8px_24px_-16px_rgba(0,0,0,0.75)] backdrop-blur-xl">
|
||||||
Page {currentPage} / {pages.length}
|
Page {currentPage} / {pages.length}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ export function PageDisplay({
|
|||||||
}, [currentPage, isDoublePage]);
|
}, [currentPage, isDoublePage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex-1 flex items-center justify-center overflow-hidden w-full">
|
<div className="relative flex w-full flex-1 items-center justify-center overflow-hidden">
|
||||||
<div className="relative w-full h-[calc(100vh-2rem)] flex items-center justify-center gap-1">
|
<div className="relative flex h-[calc(100vh-2.5rem)] w-full items-center justify-center gap-1 px-2 sm:px-4">
|
||||||
{/* Page 1 */}
|
{/* Page 1 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -69,7 +69,7 @@ export function PageDisplay({
|
|||||||
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
|
src={imageBlobUrls[currentPage] || getPageUrl(currentPage)}
|
||||||
alt={`Page ${currentPage}`}
|
alt={`Page ${currentPage}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-full max-w-full object-contain transition-opacity cursor-pointer",
|
"max-h-full max-w-full cursor-pointer rounded-md object-contain transition-opacity",
|
||||||
isLoading ? "opacity-0" : "opacity-100"
|
isLoading ? "opacity-0" : "opacity-100"
|
||||||
)}
|
)}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
@@ -109,7 +109,7 @@ export function PageDisplay({
|
|||||||
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)}
|
src={imageBlobUrls[currentPage + 1] || getPageUrl(currentPage + 1)}
|
||||||
alt={`Page ${currentPage + 1}`}
|
alt={`Page ${currentPage + 1}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-full max-w-full object-contain transition-opacity cursor-pointer",
|
"max-h-full max-w-full cursor-pointer rounded-md object-contain transition-opacity",
|
||||||
secondPageLoading ? "opacity-0" : "opacity-100"
|
secondPageLoading ? "opacity-0" : "opacity-100"
|
||||||
)}
|
)}
|
||||||
loading="eager"
|
loading="eager"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function ReaderContainer({ children, onContainerClick }: ReaderContainerP
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={readerRef}
|
ref={readerRef}
|
||||||
className="reader-zoom-enabled fixed inset-0 bg-background/95 backdrop-blur-sm z-50 overflow-hidden"
|
className="reader-zoom-enabled fixed inset-0 z-50 overflow-hidden bg-[radial-gradient(90%_70%_at_50%_8%,hsl(var(--primary)/0.1),transparent_50%),linear-gradient(to_bottom,hsl(var(--background)/0.97),hsl(var(--background)/0.93)_40%,hsl(var(--background)))] backdrop-blur-sm"
|
||||||
onClick={handleContainerClick}
|
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>
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export function ClientSettings({ initialConfig, initialLibraries }: ClientSettin
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 space-y-6">
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
<h1 className="text-3xl font-bold">{t("settings.title")}</h1>
|
||||||
|
|
||||||
<Tabs defaultValue="display" className="w-full">
|
<Tabs defaultValue="display" className="w-full">
|
||||||
@@ -47,5 +48,6 @@ export function ClientSettings({ initialConfig, initialLibraries }: ClientSettin
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium 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",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary/90 backdrop-blur-md text-primary-foreground hover:bg-primary/80",
|
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive/90 backdrop-blur-md text-destructive-foreground hover:bg-destructive/80",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 hover:shadow-md",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background/70 backdrop-blur-md hover:bg-accent/80 hover:text-accent-foreground",
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary/80 backdrop-blur-md text-secondary-foreground hover:bg-secondary/70",
|
"bg-secondary text-secondary-foreground hover:bg-secondary/85",
|
||||||
ghost: "hover:bg-accent/80 hover:backdrop-blur-md hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border bg-card/70 backdrop-blur-md text-card-foreground shadow-sm",
|
"rounded-lg border bg-card text-card-foreground shadow-sm transition-colors duration-200",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-md border border-input bg-background/70 backdrop-blur-md px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background transition-colors duration-200 ease-out file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:border-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const NavButton = React.forwardRef<HTMLButtonElement, NavButtonProps>(
|
|||||||
<button
|
<button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center justify-between rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground transition-colors",
|
"w-full flex items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:bg-accent hover:text-accent-foreground",
|
||||||
active && "bg-accent",
|
active && "bg-accent text-accent-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(({ className, .
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted/80 backdrop-blur-md p-1 text-muted-foreground",
|
"inline-flex h-10 items-center justify-center rounded-md border border-border bg-muted p-1 text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -86,8 +86,8 @@ const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
isSelected && "bg-background/90 backdrop-blur-md text-foreground shadow-sm",
|
isSelected && "bg-background text-foreground shadow-sm",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={() => onValueChange(value)}
|
onClick={() => onValueChange(value)}
|
||||||
|
|||||||
@@ -27,67 +27,84 @@ body.no-pinch-zoom * {
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 36 33% 97%;
|
||||||
--background-rgb: 255, 255, 255;
|
--background-rgb: 249, 246, 241;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222 33% 15%;
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222 33% 15%;
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222 33% 15%;
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
--primary: 198 78% 37%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 36 30% 92%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222 33% 15%;
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
--muted: 36 24% 90%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--muted-foreground: 220 13% 40%;
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
--accent: 198 52% 90%;
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
--accent-foreground: 222 33% 15%;
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 2 72% 48%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 32 18% 84%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 32 18% 84%;
|
||||||
--ring: 222.2 84% 4.9%;
|
--ring: 198 78% 37%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.75rem;
|
||||||
|
|
||||||
|
--surface-1: 0 0% 100%;
|
||||||
|
--surface-2: 34 27% 94%;
|
||||||
|
--elevation-1: 0 1px 2px 0 rgb(23 32 46 / 0.06);
|
||||||
|
--elevation-2: 0 8px 24px -8px rgb(23 32 46 / 0.18);
|
||||||
|
|
||||||
|
--font-ui: "Avenir Next", "Segoe UI", "Noto Sans", sans-serif;
|
||||||
|
--font-display: "Baskerville", "Times New Roman", serif;
|
||||||
|
--duration-fast: 120ms;
|
||||||
|
--duration-base: 200ms;
|
||||||
|
--duration-slow: 320ms;
|
||||||
|
--ease-standard: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222 35% 10%;
|
||||||
--background-rgb: 12, 17, 29;
|
--background-rgb: 17, 24, 38;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 38 20% 92%;
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 221 31% 13%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 38 20% 92%;
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 221 31% 13%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 38 20% 92%;
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
--primary: 194 76% 62%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 220 39% 11%;
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 221 22% 20%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 38 20% 92%;
|
||||||
|
|
||||||
--muted: 217.2 32.6% 25%;
|
--muted: 220 17% 24%;
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--muted-foreground: 218 17% 72%;
|
||||||
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
--accent: 210 34% 24%;
|
||||||
--accent-foreground: 210 40% 98%;
|
--accent-foreground: 38 20% 92%;
|
||||||
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 2 76% 58%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 219 18% 25%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 219 18% 25%;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--ring: 194 76% 62%;
|
||||||
|
|
||||||
|
--surface-1: 221 31% 13%;
|
||||||
|
--surface-2: 221 24% 17%;
|
||||||
|
--elevation-1: 0 1px 2px 0 rgb(2 8 18 / 0.35);
|
||||||
|
--elevation-2: 0 12px 30px -12px rgb(2 8 18 / 0.55);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +112,10 @@ body.no-pinch-zoom * {
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply text-foreground;
|
@apply bg-background text-foreground antialiased;
|
||||||
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Empêche le zoom automatique iOS sur les inputs */
|
/* Empêche le zoom automatique iOS sur les inputs */
|
||||||
|
|||||||
Reference in New Issue
Block a user