Compare commits
9 Commits
11da2335cd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| acb12b946e | |||
| d9ffacc124 | |||
| 8cdbebaafb | |||
| c5da33d6b2 | |||
| a82ce024ee | |||
| f48d894eca | |||
| a1a986f462 | |||
| 894ea7114c | |||
| 32757a8723 |
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "preferences" ADD COLUMN "anonymousMode" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -64,6 +64,7 @@ model Preferences {
|
|||||||
displayMode Json
|
displayMode Json
|
||||||
background Json
|
background Json
|
||||||
readerPrefetchCount Int @default(5)
|
readerPrefetchCount Int @default(5)
|
||||||
|
anonymousMode Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
BIN
public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 4.3 MiB |
|
Before Width: | Height: | Size: 3.7 MiB After Width: | Height: | Size: 4.4 MiB |
BIN
public/images/splash/splash-1206x2622.png
Normal file
|
After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 3.5 MiB After Width: | Height: | Size: 4.1 MiB |
|
Before Width: | Height: | Size: 4.0 MiB After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 4.9 MiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 5.0 MiB |
BIN
public/images/splash/splash-1320x2868.png
Normal file
|
After Width: | Height: | Size: 5.1 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/splash/splash-1488x2266.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 4.1 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-1668x2420.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 4.5 MiB |
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 6.8 MiB |
BIN
public/images/splash/splash-2064x2752.png
Normal file
|
After Width: | Height: | Size: 6.9 MiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 4.0 MiB |
BIN
public/images/splash/splash-2266x1488.png
Normal file
|
After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 4.5 MiB After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 4.6 MiB After Width: | Height: | Size: 5.3 MiB |
BIN
public/images/splash/splash-2420x1668.png
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 3.3 MiB After Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 4.2 MiB |
BIN
public/images/splash/splash-2622x1206.png
Normal file
|
After Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 4.6 MiB |
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 6.7 MiB |
BIN
public/images/splash/splash-2752x2064.png
Normal file
|
After Width: | Height: | Size: 6.8 MiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 4.8 MiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 4.9 MiB |
BIN
public/images/splash/splash-2868x1320.png
Normal file
|
After Width: | Height: | Size: 5.0 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.5 MiB |
@@ -9,6 +9,9 @@ const screenshotsDir = path.join(__dirname, "../public/images/screenshots");
|
|||||||
const splashDir = path.join(__dirname, "../public/images/splash");
|
const splashDir = path.join(__dirname, "../public/images/splash");
|
||||||
const faviconPath = path.join(__dirname, "../public/favicon.png");
|
const faviconPath = path.join(__dirname, "../public/favicon.png");
|
||||||
|
|
||||||
|
// Source pour les splash screens
|
||||||
|
const splashSource = path.join(__dirname, "../public/images/Gemini_Generated_Image_wyfsoiwyfsoiwyfs.png");
|
||||||
|
|
||||||
// Configuration des splashscreens pour différents appareils
|
// Configuration des splashscreens pour différents appareils
|
||||||
const splashScreens = [
|
const splashScreens = [
|
||||||
// iPad (portrait + landscape)
|
// iPad (portrait + landscape)
|
||||||
@@ -16,8 +19,14 @@ const splashScreens = [
|
|||||||
{ width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
|
{ width: 2732, height: 2048, name: "iPad Pro 12.9 landscape" },
|
||||||
{ width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
|
{ width: 1668, height: 2388, name: "iPad Pro 11 portrait" },
|
||||||
{ width: 2388, height: 1668, name: "iPad Pro 11 landscape" },
|
{ width: 2388, height: 1668, name: "iPad Pro 11 landscape" },
|
||||||
|
{ width: 1668, height: 2420, name: "iPad Pro 11 M4 portrait" },
|
||||||
|
{ width: 2420, height: 1668, name: "iPad Pro 11 M4 landscape" },
|
||||||
|
{ width: 2064, height: 2752, name: "iPad Pro 13 M4 portrait" },
|
||||||
|
{ width: 2752, height: 2064, name: "iPad Pro 13 M4 landscape" },
|
||||||
{ width: 1536, height: 2048, name: "iPad Mini/Air portrait" },
|
{ width: 1536, height: 2048, name: "iPad Mini/Air portrait" },
|
||||||
{ width: 2048, height: 1536, name: "iPad Mini/Air landscape" },
|
{ width: 2048, height: 1536, name: "iPad Mini/Air landscape" },
|
||||||
|
{ width: 1488, height: 2266, name: "iPad Mini 6 portrait" },
|
||||||
|
{ width: 2266, height: 1488, name: "iPad Mini 6 landscape" },
|
||||||
{ width: 1620, height: 2160, name: "iPad 10.2 portrait" },
|
{ width: 1620, height: 2160, name: "iPad 10.2 portrait" },
|
||||||
{ width: 2160, height: 1620, name: "iPad 10.2 landscape" },
|
{ width: 2160, height: 1620, name: "iPad 10.2 landscape" },
|
||||||
{ width: 1640, height: 2360, name: "iPad Air 10.9 portrait" },
|
{ width: 1640, height: 2360, name: "iPad Air 10.9 portrait" },
|
||||||
@@ -40,39 +49,36 @@ const splashScreens = [
|
|||||||
{ width: 2532, height: 1170, name: "iPhone 12/13/14 landscape" },
|
{ width: 2532, height: 1170, name: "iPhone 12/13/14 landscape" },
|
||||||
{ width: 1284, height: 2778, name: "iPhone 12/13/14 Pro Max portrait" },
|
{ width: 1284, height: 2778, name: "iPhone 12/13/14 Pro Max portrait" },
|
||||||
{ width: 2778, height: 1284, name: "iPhone 12/13/14 Pro Max landscape" },
|
{ width: 2778, height: 1284, name: "iPhone 12/13/14 Pro Max landscape" },
|
||||||
{ width: 1179, height: 2556, name: "iPhone 14 Pro portrait" },
|
{ width: 1179, height: 2556, name: "iPhone 14 Pro/15 portrait" },
|
||||||
{ width: 2556, height: 1179, name: "iPhone 14 Pro landscape" },
|
{ width: 2556, height: 1179, name: "iPhone 14 Pro/15 landscape" },
|
||||||
{ width: 1290, height: 2796, name: "iPhone 14/15 Pro Max portrait" },
|
{ width: 1290, height: 2796, name: "iPhone 14/15 Pro Max portrait" },
|
||||||
{ width: 2796, height: 1290, name: "iPhone 14/15 Pro Max landscape" },
|
{ width: 2796, height: 1290, name: "iPhone 14/15 Pro Max landscape" },
|
||||||
{ width: 1179, height: 2556, name: "iPhone 15 portrait" },
|
{ width: 1206, height: 2622, name: "iPhone 16 Pro portrait" },
|
||||||
{ width: 2556, height: 1179, name: "iPhone 15 landscape" },
|
{ width: 2622, height: 1206, name: "iPhone 16 Pro landscape" },
|
||||||
|
{ width: 1320, height: 2868, name: "iPhone 16 Pro Max portrait" },
|
||||||
|
{ width: 2868, height: 1320, name: "iPhone 16 Pro Max landscape" },
|
||||||
{ width: 1170, height: 2532, name: "iPhone 16/16e portrait" },
|
{ width: 1170, height: 2532, name: "iPhone 16/16e portrait" },
|
||||||
{ width: 2532, height: 1170, name: "iPhone 16/16e landscape" },
|
{ width: 2532, height: 1170, name: "iPhone 16/16e landscape" },
|
||||||
];
|
];
|
||||||
|
|
||||||
async function generateSplashScreens() {
|
async function generateSplashScreens() {
|
||||||
await fs.mkdir(splashDir, { recursive: true });
|
await fs.mkdir(splashDir, { recursive: true });
|
||||||
|
console.log(`\n📱 Génération des splash screens...`);
|
||||||
|
|
||||||
for (const screen of splashScreens) {
|
for (const screen of splashScreens) {
|
||||||
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
|
const outputPath = path.join(splashDir, `splash-${screen.width}x${screen.height}.png`);
|
||||||
const darkOverlay = Buffer.from(
|
|
||||||
`<svg width="${screen.width}" height="${screen.height}" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="100%" height="100%" fill="rgba(4, 8, 20, 0.22)" />
|
|
||||||
</svg>`
|
|
||||||
);
|
|
||||||
|
|
||||||
await sharp(sourceLogo)
|
await sharp(splashSource)
|
||||||
.resize(screen.width, screen.height, {
|
.resize(screen.width, screen.height, {
|
||||||
fit: "cover",
|
fit: "cover",
|
||||||
position: "center",
|
position: "center",
|
||||||
})
|
})
|
||||||
.composite([{ input: darkOverlay, blend: "over" }])
|
|
||||||
.png({
|
.png({
|
||||||
compressionLevel: 9,
|
compressionLevel: 9,
|
||||||
})
|
})
|
||||||
.toFile(outputPath);
|
.toFile(outputPath);
|
||||||
|
|
||||||
console.log(`✓ Splashscreen ${screen.name} (${screen.width}x${screen.height}) générée`);
|
console.log(` ✓ ${screen.name} (${screen.width}x${screen.height})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import ClientLayout from "@/components/layout/ClientLayout";
|
import ClientLayout from "@/components/layout/ClientLayout";
|
||||||
import { PreferencesService } from "@/lib/services/preferences.service";
|
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||||
import { PreferencesProvider } from "@/contexts/PreferencesContext";
|
import { PreferencesProvider } from "@/contexts/PreferencesContext";
|
||||||
|
import { AnonymousProvider } from "@/contexts/AnonymousContext";
|
||||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||||
import { AuthProvider } from "@/components/providers/AuthProvider";
|
import { AuthProvider } from "@/components/providers/AuthProvider";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
@@ -248,6 +249,61 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
href="/images/splash/splash-2796x1290.png"
|
href="/images/splash/splash-2796x1290.png"
|
||||||
media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
media="(device-width: 932px) and (device-height: 430px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
/>
|
/>
|
||||||
|
{/* iPad Mini 6 */}
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1488x2266.png"
|
||||||
|
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2266x1488.png"
|
||||||
|
media="(device-width: 1133px) and (device-height: 744px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
{/* iPad Pro 11" M4 */}
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1668x2420.png"
|
||||||
|
media="(device-width: 834px) and (device-height: 1210px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2420x1668.png"
|
||||||
|
media="(device-width: 1210px) and (device-height: 834px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
{/* iPad Pro 13" M4 */}
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2064x2752.png"
|
||||||
|
media="(device-width: 1032px) and (device-height: 1376px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2752x2064.png"
|
||||||
|
media="(device-width: 1376px) and (device-height: 1032px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
{/* iPhone 16 Pro */}
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1206x2622.png"
|
||||||
|
media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2622x1206.png"
|
||||||
|
media="(device-width: 874px) and (device-height: 402px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
|
{/* iPhone 16 Pro Max */}
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-1320x2868.png"
|
||||||
|
media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/images/splash/splash-2868x1320.png"
|
||||||
|
media="(device-width: 956px) and (device-height: 440px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -258,13 +314,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<I18nProvider locale={locale}>
|
<I18nProvider locale={locale}>
|
||||||
<PreferencesProvider initialPreferences={preferences}>
|
<PreferencesProvider initialPreferences={preferences}>
|
||||||
<ClientLayout
|
<AnonymousProvider>
|
||||||
initialLibraries={libraries}
|
<ClientLayout
|
||||||
initialFavorites={favorites}
|
initialLibraries={libraries}
|
||||||
userIsAdmin={userIsAdmin}
|
initialFavorites={favorites}
|
||||||
>
|
userIsAdmin={userIsAdmin}
|
||||||
{children}
|
>
|
||||||
</ClientLayout>
|
{children}
|
||||||
|
</ClientLayout>
|
||||||
|
</AnonymousProvider>
|
||||||
</PreferencesProvider>
|
</PreferencesProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage";
|
|||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
import { ERROR_CODES } from "@/constants/errorCodes";
|
||||||
import { AppError } from "@/utils/errors";
|
import { AppError } from "@/utils/errors";
|
||||||
import { FavoritesService } from "@/lib/services/favorites.service";
|
import { FavoritesService } from "@/lib/services/favorites.service";
|
||||||
|
import { PreferencesService } from "@/lib/services/preferences.service";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
@@ -12,16 +13,17 @@ export default async function HomePage() {
|
|||||||
const provider = await getProvider();
|
const provider = await getProvider();
|
||||||
if (!provider) redirect("/settings");
|
if (!provider) redirect("/settings");
|
||||||
|
|
||||||
const [homeData, favorites] = await Promise.all([
|
const [homeData, favorites, preferences] = await Promise.all([
|
||||||
provider.getHomeData(),
|
provider.getHomeData(),
|
||||||
FavoritesService.getFavorites(),
|
FavoritesService.getFavorites(),
|
||||||
|
PreferencesService.getPreferences().catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const data = { ...homeData, favorites };
|
const data = { ...homeData, favorites };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeClientWrapper>
|
<HomeClientWrapper>
|
||||||
<HomeContent data={data} />
|
<HomeContent data={data} isAnonymous={preferences?.anonymousMode ?? false} />
|
||||||
</HomeClientWrapper>
|
</HomeClientWrapper>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import type { HomeData } from "@/types/home";
|
|||||||
|
|
||||||
interface HomeContentProps {
|
interface HomeContentProps {
|
||||||
data: HomeData;
|
data: HomeData;
|
||||||
|
isAnonymous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeContent({ data }: HomeContentProps) {
|
export function HomeContent({ data, isAnonymous = false }: HomeContentProps) {
|
||||||
// Merge onDeck (next unread per series) and ongoingBooks (currently reading),
|
// Merge onDeck (next unread per series) and ongoingBooks (currently reading),
|
||||||
// deduplicate by id, onDeck first
|
// deduplicate by id, onDeck first
|
||||||
const continueReading = (() => {
|
const continueReading = (() => {
|
||||||
@@ -20,7 +21,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10 pb-2">
|
<div className="space-y-10 pb-2">
|
||||||
{continueReading.length > 0 && (
|
{!isAnonymous && continueReading.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.continue_reading"
|
titleKey="home.sections.continue_reading"
|
||||||
items={continueReading}
|
items={continueReading}
|
||||||
@@ -29,7 +30,7 @@ export function HomeContent({ data }: HomeContentProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.ongoing && data.ongoing.length > 0 && (
|
{!isAnonymous && data.ongoing && data.ongoing.length > 0 && (
|
||||||
<MediaRow
|
<MediaRow
|
||||||
titleKey="home.sections.continue_series"
|
titleKey="home.sections.continue_series"
|
||||||
items={data.ongoing}
|
items={data.ongoing}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { History, Sparkles, Clock, LibraryBig, BookOpen, Heart } from "lucide-re
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||||
|
|
||||||
interface MediaRowProps {
|
interface MediaRowProps {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
@@ -78,6 +79,7 @@ interface MediaCardProps {
|
|||||||
|
|
||||||
function MediaCard({ item, onClick }: MediaCardProps) {
|
function MediaCard({ item, onClick }: MediaCardProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const { isAnonymous } = useAnonymous();
|
||||||
const isSeriesItem = isSeries(item);
|
const isSeriesItem = isSeries(item);
|
||||||
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
|
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
|
||||||
|
|
||||||
@@ -105,7 +107,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
|||||||
<div className="relative aspect-[2/3] bg-muted">
|
<div className="relative aspect-[2/3] bg-muted">
|
||||||
{isSeriesItem ? (
|
{isSeriesItem ? (
|
||||||
<>
|
<>
|
||||||
<SeriesCover series={item} alt={`Couverture de ${title}`} />
|
<SeriesCover series={item} alt={`Couverture de ${title}`} isAnonymous={isAnonymous} />
|
||||||
<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">
|
<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">
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Menu, Moon, Sun, RefreshCw, Search } from "lucide-react";
|
import { Menu, Moon, Sun, RefreshCw, Search, EyeOff, Eye } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import LanguageSelector from "@/components/LanguageSelector";
|
import LanguageSelector from "@/components/LanguageSelector";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconButton } from "@/components/ui/icon-button";
|
import { IconButton } from "@/components/ui/icon-button";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { GlobalSearch } from "@/components/layout/GlobalSearch";
|
import { GlobalSearch } from "@/components/layout/GlobalSearch";
|
||||||
|
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
@@ -19,6 +20,7 @@ export function Header({
|
|||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isAnonymous, toggleAnonymous } = useAnonymous();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
|
||||||
|
|
||||||
@@ -87,6 +89,14 @@ export function Header({
|
|||||||
className="h-9 w-9 rounded-full sm:hidden"
|
className="h-9 w-9 rounded-full sm:hidden"
|
||||||
tooltip={t("header.search.placeholder")}
|
tooltip={t("header.search.placeholder")}
|
||||||
/>
|
/>
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleAnonymous}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
icon={isAnonymous ? EyeOff : Eye}
|
||||||
|
className={`h-9 w-9 rounded-full ${isAnonymous ? "text-yellow-500 hover:text-yellow-400" : ""}`}
|
||||||
|
tooltip={t(isAnonymous ? "header.anonymousModeOn" : "header.anonymousModeOff")}
|
||||||
|
/>
|
||||||
<LanguageSelector />
|
<LanguageSelector />
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SeriesCover } from "@/components/ui/series-cover";
|
import { SeriesCover } from "@/components/ui/series-cover";
|
||||||
import { useTranslate } from "@/hooks/useTranslate";
|
import { useTranslate } from "@/hooks/useTranslate";
|
||||||
|
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||||
|
|
||||||
interface SeriesGridProps {
|
interface SeriesGridProps {
|
||||||
series: NormalizedSeries[];
|
series: NormalizedSeries[];
|
||||||
@@ -49,6 +50,7 @@ const getReadingStatusInfo = (
|
|||||||
export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const { isAnonymous } = useAnonymous();
|
||||||
|
|
||||||
if (!series.length) {
|
if (!series.length) {
|
||||||
return (
|
return (
|
||||||
@@ -73,24 +75,27 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
|||||||
onClick={() => router.push(`/series/${seriesItem.id}`)}
|
onClick={() => router.push(`/series/${seriesItem.id}`)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"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",
|
"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",
|
||||||
seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
|
!isAnonymous && seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
|
||||||
isCompact && "aspect-[3/4]"
|
isCompact && "aspect-[3/4]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
series={seriesItem}
|
series={seriesItem}
|
||||||
alt={t("series.coverAlt", { title: seriesItem.name })}
|
alt={t("series.coverAlt", { title: seriesItem.name })}
|
||||||
|
isAnonymous={isAnonymous}
|
||||||
/>
|
/>
|
||||||
<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">
|
<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">{seriesItem.name}</h3>
|
<h3 className="font-medium text-sm text-white line-clamp-2">{seriesItem.name}</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
{!isAnonymous && (
|
||||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
<span
|
||||||
getReadingStatusInfo(seriesItem, t).className
|
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||||
}`}
|
getReadingStatusInfo(seriesItem, t).className
|
||||||
>
|
}`}
|
||||||
{getReadingStatusInfo(seriesItem, t).label}
|
>
|
||||||
</span>
|
{getReadingStatusInfo(seriesItem, t).label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-xs text-white/80">
|
<span className="text-xs text-white/80">
|
||||||
{t("series.books", { count: seriesItem.bookCount })}
|
{t("series.books", { count: seriesItem.bookCount })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { BookOpen, Calendar, Tag, User } from "lucide-react";
|
import { BookOpen, Calendar, Tag, User } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||||
|
|
||||||
interface SeriesListProps {
|
interface SeriesListProps {
|
||||||
series: NormalizedSeries[];
|
series: NormalizedSeries[];
|
||||||
@@ -57,16 +58,17 @@ const getReadingStatusInfo = (
|
|||||||
function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const { isAnonymous } = useAnonymous();
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
router.push(`/series/${series.id}`);
|
router.push(`/series/${series.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCompleted = series.bookCount === series.booksReadCount;
|
const isCompleted = isAnonymous ? false : series.bookCount === series.booksReadCount;
|
||||||
const progressPercentage =
|
const progressPercentage =
|
||||||
series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0;
|
series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0;
|
||||||
|
|
||||||
const statusInfo = getReadingStatusInfo(series, t);
|
const statusInfo = isAnonymous ? null : getReadingStatusInfo(series, t);
|
||||||
|
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
return (
|
return (
|
||||||
@@ -83,6 +85,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
series={series}
|
series={series}
|
||||||
alt={t("series.coverAlt", { title: series.name })}
|
alt={t("series.coverAlt", { title: series.name })}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
|
isAnonymous={isAnonymous}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,14 +96,16 @@ 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">
|
<h3 className="font-medium text-sm sm:text-base line-clamp-1 hover:text-primary transition-colors flex-1 min-w-0">
|
||||||
{series.name}
|
{series.name}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
{statusInfo && (
|
||||||
className={cn(
|
<span
|
||||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
className={cn(
|
||||||
statusInfo.className
|
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
)}
|
statusInfo.className
|
||||||
>
|
)}
|
||||||
{statusInfo.label}
|
>
|
||||||
</span>
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Métadonnées minimales */}
|
{/* Métadonnées minimales */}
|
||||||
@@ -139,6 +144,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
series={series}
|
series={series}
|
||||||
alt={t("series.coverAlt", { title: series.name })}
|
alt={t("series.coverAlt", { title: series.name })}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
|
isAnonymous={isAnonymous}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -153,14 +159,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge de statut */}
|
{/* Badge de statut */}
|
||||||
<span
|
{statusInfo && (
|
||||||
className={cn(
|
<span
|
||||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
className={cn(
|
||||||
statusInfo.className
|
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
)}
|
statusInfo.className
|
||||||
>
|
)}
|
||||||
{statusInfo.label}
|
>
|
||||||
</span>
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Résumé */}
|
{/* Résumé */}
|
||||||
@@ -224,7 +232,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Barre de progression */}
|
{/* Barre de progression */}
|
||||||
{series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
{!isAnonymous && series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Progress value={progressPercentage} className="h-2" />
|
<Progress value={progressPercentage} className="h-2" />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -265,9 +265,7 @@ export function PhotoswipeReader({ book, pages, onClose, nextBook }: BookReaderP
|
|||||||
isDoublePage={isDoublePage}
|
isDoublePage={isDoublePage}
|
||||||
shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)}
|
shouldShowDoublePage={(page) => shouldShowDoublePage(page, pages.length)}
|
||||||
imageBlobUrls={imageBlobUrls}
|
imageBlobUrls={imageBlobUrls}
|
||||||
getPageUrl={getPageUrl}
|
|
||||||
isRTL={isRTL}
|
isRTL={isRTL}
|
||||||
isPageLoading={isPageLoading}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NavigationBar
|
<NavigationBar
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PageDisplayProps {
|
interface PageDisplayProps {
|
||||||
@@ -7,9 +7,7 @@ interface PageDisplayProps {
|
|||||||
isDoublePage: boolean;
|
isDoublePage: boolean;
|
||||||
shouldShowDoublePage: (page: number) => boolean;
|
shouldShowDoublePage: (page: number) => boolean;
|
||||||
imageBlobUrls: Record<number, string>;
|
imageBlobUrls: Record<number, string>;
|
||||||
getPageUrl: (pageNum: number) => string;
|
|
||||||
isRTL: boolean;
|
isRTL: boolean;
|
||||||
isPageLoading?: (pageNum: number) => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageDisplay({
|
export function PageDisplay({
|
||||||
@@ -18,14 +16,14 @@ export function PageDisplay({
|
|||||||
isDoublePage,
|
isDoublePage,
|
||||||
shouldShowDoublePage,
|
shouldShowDoublePage,
|
||||||
imageBlobUrls,
|
imageBlobUrls,
|
||||||
getPageUrl,
|
|
||||||
isRTL,
|
isRTL,
|
||||||
isPageLoading,
|
|
||||||
}: PageDisplayProps) {
|
}: PageDisplayProps) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [hasError, setHasError] = useState(false);
|
const [hasError, setHasError] = useState(false);
|
||||||
const [secondPageLoading, setSecondPageLoading] = useState(true);
|
const [secondPageLoading, setSecondPageLoading] = useState(true);
|
||||||
const [secondPageHasError, setSecondPageHasError] = useState(false);
|
const [secondPageHasError, setSecondPageHasError] = useState(false);
|
||||||
|
const imageBlobUrlsRef = useRef(imageBlobUrls);
|
||||||
|
imageBlobUrlsRef.current = imageBlobUrls;
|
||||||
|
|
||||||
const handleImageLoad = useCallback(() => {
|
const handleImageLoad = useCallback(() => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -45,14 +43,29 @@ export function PageDisplay({
|
|||||||
setSecondPageHasError(true);
|
setSecondPageHasError(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Reset loading when page changes
|
// Reset loading when page changes, but skip if blob URL is already available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(!imageBlobUrlsRef.current[currentPage]);
|
||||||
setHasError(false);
|
setHasError(false);
|
||||||
setSecondPageLoading(true);
|
setSecondPageLoading(!imageBlobUrlsRef.current[currentPage + 1]);
|
||||||
setSecondPageHasError(false);
|
setSecondPageHasError(false);
|
||||||
}, [currentPage, isDoublePage]);
|
}, [currentPage, isDoublePage]);
|
||||||
|
|
||||||
|
// Reset error state when blob URL becomes available
|
||||||
|
useEffect(() => {
|
||||||
|
if (imageBlobUrls[currentPage] && hasError) {
|
||||||
|
setHasError(false);
|
||||||
|
setIsLoading(true);
|
||||||
|
}
|
||||||
|
}, [imageBlobUrls[currentPage], currentPage, hasError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (imageBlobUrls[currentPage + 1] && secondPageHasError) {
|
||||||
|
setSecondPageHasError(false);
|
||||||
|
setSecondPageLoading(true);
|
||||||
|
}
|
||||||
|
}, [imageBlobUrls[currentPage + 1], currentPage, secondPageHasError]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex w-full flex-1 items-center justify-center overflow-hidden">
|
<div className="relative flex w-full flex-1 items-center justify-center overflow-hidden">
|
||||||
<div className="relative flex h-[calc(100vh-2.5rem)] w-full items-center justify-center px-2 sm:px-4">
|
<div className="relative flex h-[calc(100vh-2.5rem)] w-full items-center justify-center px-2 sm:px-4">
|
||||||
@@ -99,15 +112,12 @@ export function PageDisplay({
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm opacity-60">Image non disponible</span>
|
<span className="text-sm opacity-60">Image non disponible</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : imageBlobUrls[currentPage] ? (
|
||||||
<>
|
<>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
key={`page-${currentPage}-${imageBlobUrls[currentPage] || ""}`}
|
key={`page-${currentPage}-${imageBlobUrls[currentPage]}`}
|
||||||
src={
|
src={imageBlobUrls[currentPage]}
|
||||||
imageBlobUrls[currentPage] ||
|
|
||||||
(isPageLoading && isPageLoading(currentPage) ? undefined : getPageUrl(currentPage))
|
|
||||||
}
|
|
||||||
alt={`Page ${currentPage}`}
|
alt={`Page ${currentPage}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
||||||
@@ -124,7 +134,7 @@ export function PageDisplay({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page 2 (double page) */}
|
{/* Page 2 (double page) */}
|
||||||
@@ -166,17 +176,12 @@ export function PageDisplay({
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm opacity-60">Image non disponible</span>
|
<span className="text-sm opacity-60">Image non disponible</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : imageBlobUrls[currentPage + 1] ? (
|
||||||
<>
|
<>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1] || ""}`}
|
key={`page-${currentPage + 1}-${imageBlobUrls[currentPage + 1]}`}
|
||||||
src={
|
src={imageBlobUrls[currentPage + 1]}
|
||||||
imageBlobUrls[currentPage + 1] ||
|
|
||||||
(isPageLoading && isPageLoading(currentPage + 1)
|
|
||||||
? undefined
|
|
||||||
: getPageUrl(currentPage + 1))
|
|
||||||
}
|
|
||||||
alt={`Page ${currentPage + 1}`}
|
alt={`Page ${currentPage + 1}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
"max-h-full max-w-full cursor-pointer object-contain transition-opacity",
|
||||||
@@ -193,7 +198,7 @@ export function PageDisplay({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.serv
|
|||||||
import type { NormalizedBook } from "@/lib/providers/types";
|
import type { NormalizedBook } from "@/lib/providers/types";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
import { updateReadProgress } from "@/app/actions/read-progress";
|
import { updateReadProgress } from "@/app/actions/read-progress";
|
||||||
|
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||||
|
|
||||||
interface UsePageNavigationProps {
|
interface UsePageNavigationProps {
|
||||||
book: NormalizedBook;
|
book: NormalizedBook;
|
||||||
@@ -23,6 +24,13 @@ export function usePageNavigation({
|
|||||||
nextBook,
|
nextBook,
|
||||||
}: UsePageNavigationProps) {
|
}: UsePageNavigationProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { isAnonymous } = useAnonymous();
|
||||||
|
const isAnonymousRef = useRef(isAnonymous);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isAnonymousRef.current = isAnonymous;
|
||||||
|
}, [isAnonymous]);
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(() => {
|
const [currentPage, setCurrentPage] = useState(() => {
|
||||||
const saved = ClientOfflineBookService.getCurrentPage(book);
|
const saved = ClientOfflineBookService.getCurrentPage(book);
|
||||||
return saved < 1 ? 1 : saved;
|
return saved < 1 ? 1 : saved;
|
||||||
@@ -48,8 +56,10 @@ export function usePageNavigation({
|
|||||||
async (page: number) => {
|
async (page: number) => {
|
||||||
try {
|
try {
|
||||||
ClientOfflineBookService.setCurrentPage(bookRef.current, page);
|
ClientOfflineBookService.setCurrentPage(bookRef.current, page);
|
||||||
const completed = page === pagesLengthRef.current;
|
if (!isAnonymousRef.current) {
|
||||||
await updateReadProgress(bookRef.current.id, page, completed);
|
const completed = page === pagesLengthRef.current;
|
||||||
|
await updateReadProgress(bookRef.current.id, page, completed);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Sync error:");
|
logger.error({ err: error }, "Sync error:");
|
||||||
}
|
}
|
||||||
@@ -89,7 +99,7 @@ export function usePageNavigation({
|
|||||||
const handleNextPage = useCallback(() => {
|
const handleNextPage = useCallback(() => {
|
||||||
if (currentPage === pages.length) {
|
if (currentPage === pages.length) {
|
||||||
if (nextBook) {
|
if (nextBook) {
|
||||||
router.push(`/books/${nextBook.id}`);
|
router.replace(`/books/${nextBook.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setShowEndMessage(true);
|
setShowEndMessage(true);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { FileText } from "lucide-react";
|
|||||||
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
|
import { MarkAsReadButton } from "@/components/ui/mark-as-read-button";
|
||||||
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
|
import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button";
|
||||||
import { BookOfflineButton } from "@/components/ui/book-offline-button";
|
import { BookOfflineButton } from "@/components/ui/book-offline-button";
|
||||||
|
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||||
|
|
||||||
interface BookListProps {
|
interface BookListProps {
|
||||||
books: NormalizedBook[];
|
books: NormalizedBook[];
|
||||||
@@ -30,6 +31,7 @@ interface BookListItemProps {
|
|||||||
|
|
||||||
function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookListItemProps) {
|
function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookListItemProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const { isAnonymous } = useAnonymous();
|
||||||
const { isAccessible } = useBookOfflineStatus(book.id);
|
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@@ -37,9 +39,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
onBookClick(book);
|
onBookClick(book);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isRead = book.readProgress?.completed || false;
|
const isRead = isAnonymous ? false : (book.readProgress?.completed || false);
|
||||||
const hasReadProgress = book.readProgress !== null;
|
const hasReadProgress = isAnonymous ? false : book.readProgress !== null;
|
||||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
const currentPage = isAnonymous ? 0 : ClientOfflineBookService.getCurrentPage(book);
|
||||||
const totalPages = book.pageCount;
|
const totalPages = book.pageCount;
|
||||||
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
|
const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0;
|
||||||
|
|
||||||
@@ -118,14 +120,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
{!isAnonymous && (
|
||||||
className={cn(
|
<span
|
||||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
className={cn(
|
||||||
statusInfo.className
|
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
)}
|
statusInfo.className
|
||||||
>
|
)}
|
||||||
{statusInfo.label}
|
>
|
||||||
</span>
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Métadonnées minimales */}
|
{/* Métadonnées minimales */}
|
||||||
@@ -191,14 +195,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge de statut */}
|
{/* Badge de statut */}
|
||||||
<span
|
{!isAnonymous && (
|
||||||
className={cn(
|
<span
|
||||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
className={cn(
|
||||||
statusInfo.className
|
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||||
)}
|
statusInfo.className
|
||||||
>
|
)}
|
||||||
{statusInfo.label}
|
>
|
||||||
</span>
|
{statusInfo.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Métadonnées */}
|
{/* Métadonnées */}
|
||||||
@@ -224,7 +230,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 mt-auto pt-2">
|
<div className="flex items-center gap-2 mt-auto pt-2">
|
||||||
{!isRead && (
|
{!isAnonymous && !isRead && (
|
||||||
<MarkAsReadButton
|
<MarkAsReadButton
|
||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
pagesCount={book.pageCount}
|
pagesCount={book.pageCount}
|
||||||
@@ -233,7 +239,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
|||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasReadProgress && (
|
{!isAnonymous && hasReadProgress && (
|
||||||
<MarkAsUnreadButton
|
<MarkAsUnreadButton
|
||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
onSuccess={() => onSuccess(book, "unread")}
|
onSuccess={() => onSuccess(book, "unread")}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Book, BookOpen, BookMarked, Star, StarOff } from "lucide-react";
|
import { Book, BookOpen, BookMarked, BookX, Star, StarOff, User } from "lucide-react";
|
||||||
import type { NormalizedSeries } from "@/lib/providers/types";
|
import type { NormalizedSeries } from "@/lib/providers/types";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useToast } from "@/components/ui/use-toast";
|
import { useToast } from "@/components/ui/use-toast";
|
||||||
@@ -14,6 +14,7 @@ import { StatusBadge } from "@/components/ui/status-badge";
|
|||||||
import { IconButton } from "@/components/ui/icon-button";
|
import { IconButton } from "@/components/ui/icon-button";
|
||||||
import logger from "@/lib/logger";
|
import logger from "@/lib/logger";
|
||||||
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
|
import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites";
|
||||||
|
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||||
|
|
||||||
interface SeriesHeaderProps {
|
interface SeriesHeaderProps {
|
||||||
series: NormalizedSeries;
|
series: NormalizedSeries;
|
||||||
@@ -23,7 +24,9 @@ interface SeriesHeaderProps {
|
|||||||
|
|
||||||
export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: SeriesHeaderProps) => {
|
export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: SeriesHeaderProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { isAnonymous } = useAnonymous();
|
||||||
const [isFavorite, setIsFavorite] = useState(initialIsFavorite);
|
const [isFavorite, setIsFavorite] = useState(initialIsFavorite);
|
||||||
|
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -99,10 +102,13 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusInfo = getReadingStatusInfo();
|
const statusInfo = isAnonymous ? null : getReadingStatusInfo();
|
||||||
|
const authorsText = series.authors?.length
|
||||||
|
? series.authors.map((a) => a.name).join(", ")
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-[300px] md:h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
<div className="relative min-h-[300px] w-screen -ml-[calc((100vw-100%)/2)] overflow-hidden">
|
||||||
{/* Image de fond */}
|
{/* Image de fond */}
|
||||||
<div className="absolute inset-0">
|
<div className="absolute inset-0">
|
||||||
<SeriesCover
|
<SeriesCover
|
||||||
@@ -128,20 +134,41 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
{/* Informations */}
|
{/* Informations */}
|
||||||
<div className="flex-1 text-white space-y-2 text-center md:text-left">
|
<div className="flex-1 text-white space-y-2 text-center md:text-left">
|
||||||
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
|
<h1 className="text-2xl md:text-3xl font-bold">{series.name}</h1>
|
||||||
{series.summary && (
|
{authorsText && (
|
||||||
<p className="text-white/80 line-clamp-3 text-sm md:text-base">
|
<p className="text-white/70 text-sm flex items-center gap-1 justify-center md:justify-start">
|
||||||
{series.summary}
|
<User className="h-3.5 w-3.5 flex-shrink-0" />
|
||||||
|
{authorsText}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{series.summary && (
|
||||||
|
<div>
|
||||||
|
<p className={`text-white/80 text-sm md:text-base ${isDescriptionExpanded ? "max-h-[200px] overflow-y-auto" : "line-clamp-3"}`}>
|
||||||
|
{series.summary}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDescriptionExpanded(!isDescriptionExpanded)}
|
||||||
|
className="text-white/60 hover:text-white/90 text-xs mt-1 transition-colors"
|
||||||
|
>
|
||||||
|
{t(isDescriptionExpanded ? "series.header.showLess" : "series.header.showMore")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
|
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
|
||||||
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
|
{statusInfo && (
|
||||||
{statusInfo.label}
|
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
|
||||||
</StatusBadge>
|
{statusInfo.label}
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
<span className="text-sm text-white/80">
|
<span className="text-sm text-white/80">
|
||||||
{series.bookCount === 1
|
{series.bookCount === 1
|
||||||
? t("series.header.books", { count: series.bookCount })
|
? t("series.header.books", { count: series.bookCount })
|
||||||
: t("series.header.books_plural", { count: series.bookCount })}
|
: t("series.header.books_plural", { count: series.bookCount })}
|
||||||
</span>
|
</span>
|
||||||
|
{series.missingCount != null && series.missingCount > 0 && (
|
||||||
|
<StatusBadge status="warning" icon={BookX}>
|
||||||
|
{t("series.header.missing", { count: series.missingCount })}
|
||||||
|
</StatusBadge>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -158,6 +185,7 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useTranslate } from "@/hooks/useTranslate";
|
|||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||||
import { WifiOff } from "lucide-react";
|
import { WifiOff } from "lucide-react";
|
||||||
|
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||||
|
|
||||||
// Fonction utilitaire pour obtenir les informations de statut de lecture
|
// Fonction utilitaire pour obtenir les informations de statut de lecture
|
||||||
const getReadingStatusInfo = (
|
const getReadingStatusInfo = (
|
||||||
@@ -60,17 +61,18 @@ export function BookCover({
|
|||||||
overlayVariant = "default",
|
overlayVariant = "default",
|
||||||
}: BookCoverProps) {
|
}: BookCoverProps) {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const { isAnonymous } = useAnonymous();
|
||||||
const { isAccessible } = useBookOfflineStatus(book.id);
|
const { isAccessible } = useBookOfflineStatus(book.id);
|
||||||
|
|
||||||
const isCompleted = book.readProgress?.completed || false;
|
const isCompleted = isAnonymous ? false : (book.readProgress?.completed || false);
|
||||||
|
|
||||||
const currentPage = ClientOfflineBookService.getCurrentPage(book);
|
const currentPage = isAnonymous ? 0 : ClientOfflineBookService.getCurrentPage(book);
|
||||||
const totalPages = book.pageCount;
|
const totalPages = book.pageCount;
|
||||||
const showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
|
const showProgress = Boolean(!isAnonymous && showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted);
|
||||||
|
|
||||||
const statusInfo = getReadingStatusInfo(book, t);
|
const statusInfo = isAnonymous ? { label: "", className: "" } : getReadingStatusInfo(book, t);
|
||||||
const isRead = book.readProgress?.completed || false;
|
const isRead = isAnonymous ? false : (book.readProgress?.completed || false);
|
||||||
const hasReadProgress = book.readProgress !== null || currentPage > 0;
|
const hasReadProgress = isAnonymous ? false : (book.readProgress !== null || currentPage > 0);
|
||||||
|
|
||||||
// Détermine si le livre doit être grisé (non accessible hors ligne)
|
// Détermine si le livre doit être grisé (non accessible hors ligne)
|
||||||
const isUnavailable = !isAccessible;
|
const isUnavailable = !isAccessible;
|
||||||
@@ -115,7 +117,7 @@ export function BookCover({
|
|||||||
{showControls && (
|
{showControls && (
|
||||||
// Boutons en haut à droite avec un petit décalage
|
// Boutons en haut à droite avec un petit décalage
|
||||||
<div className="absolute top-2 right-2 pointer-events-auto flex gap-1">
|
<div className="absolute top-2 right-2 pointer-events-auto flex gap-1">
|
||||||
{!isRead && (
|
{!isAnonymous && !isRead && (
|
||||||
<MarkAsReadButton
|
<MarkAsReadButton
|
||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
pagesCount={book.pageCount}
|
pagesCount={book.pageCount}
|
||||||
@@ -124,7 +126,7 @@ export function BookCover({
|
|||||||
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasReadProgress && (
|
{!isAnonymous && hasReadProgress && (
|
||||||
<MarkAsUnreadButton
|
<MarkAsUnreadButton
|
||||||
bookId={book.id}
|
bookId={book.id}
|
||||||
onSuccess={() => handleMarkAsUnread()}
|
onSuccess={() => handleMarkAsUnread()}
|
||||||
@@ -145,11 +147,13 @@ export function BookCover({
|
|||||||
? t("navigation.volume", { number: book.number })
|
? t("navigation.volume", { number: book.number })
|
||||||
: "")}
|
: "")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
{!isAnonymous && (
|
||||||
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
|
<div className="flex items-center gap-2">
|
||||||
{statusInfo.label}
|
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
|
||||||
</span>
|
{statusInfo.label}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,12 +166,14 @@ export function BookCover({
|
|||||||
? t("navigation.volume", { number: book.number })
|
? t("navigation.volume", { number: book.number })
|
||||||
: "")}
|
: "")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-white/80 mt-1">
|
{!isAnonymous && (
|
||||||
{t("books.status.progress", {
|
<p className="text-xs text-white/80 mt-1">
|
||||||
current: currentPage,
|
{t("books.status.progress", {
|
||||||
total: book.pageCount,
|
current: currentPage,
|
||||||
})}
|
total: book.pageCount,
|
||||||
</p>
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ export interface BookCoverProps extends BaseCoverProps {
|
|||||||
|
|
||||||
export interface SeriesCoverProps extends BaseCoverProps {
|
export interface SeriesCoverProps extends BaseCoverProps {
|
||||||
series: NormalizedSeries;
|
series: NormalizedSeries;
|
||||||
|
isAnonymous?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ProgressBar } from "./progress-bar";
|
import { ProgressBar } from "./progress-bar";
|
||||||
|
import { BookX } from "lucide-react";
|
||||||
import type { SeriesCoverProps } from "./cover-utils";
|
import type { SeriesCoverProps } from "./cover-utils";
|
||||||
|
|
||||||
export function SeriesCover({
|
export function SeriesCover({
|
||||||
@@ -6,12 +7,14 @@ export function SeriesCover({
|
|||||||
alt = "Image de couverture",
|
alt = "Image de couverture",
|
||||||
className,
|
className,
|
||||||
showProgressUi = true,
|
showProgressUi = true,
|
||||||
|
isAnonymous = false,
|
||||||
}: SeriesCoverProps) {
|
}: SeriesCoverProps) {
|
||||||
const isCompleted = series.bookCount === series.booksReadCount;
|
const isCompleted = isAnonymous ? false : series.bookCount === series.booksReadCount;
|
||||||
|
|
||||||
const readBooks = series.booksReadCount;
|
const readBooks = isAnonymous ? 0 : series.booksReadCount;
|
||||||
const totalBooks = series.bookCount;
|
const totalBooks = series.bookCount;
|
||||||
const showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
|
const showProgress = Boolean(!isAnonymous && showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted);
|
||||||
|
const missingCount = series.missingCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
@@ -27,6 +30,12 @@ export function SeriesCover({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
/>
|
/>
|
||||||
|
{showProgressUi && missingCount != null && missingCount > 0 && (
|
||||||
|
<div className="absolute top-1.5 right-1.5 flex items-center gap-0.5 rounded-full bg-orange-500/90 px-1.5 py-0.5 text-white shadow-md backdrop-blur-sm">
|
||||||
|
<BookX className="h-3 w-3" />
|
||||||
|
<span className="text-[10px] font-bold leading-none">{missingCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showProgress ? <ProgressBar progress={readBooks} total={totalBooks} type="series" /> : null}
|
{showProgress ? <ProgressBar progress={readBooks} total={totalBooks} type="series" /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
34
src/contexts/AnonymousContext.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useMemo, useCallback } from "react";
|
||||||
|
import { usePreferences } from "@/contexts/PreferencesContext";
|
||||||
|
|
||||||
|
interface AnonymousContextType {
|
||||||
|
isAnonymous: boolean;
|
||||||
|
toggleAnonymous: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AnonymousContext = createContext<AnonymousContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AnonymousProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { preferences, updatePreferences } = usePreferences();
|
||||||
|
|
||||||
|
const toggleAnonymous = useCallback(() => {
|
||||||
|
updatePreferences({ anonymousMode: !preferences.anonymousMode });
|
||||||
|
}, [preferences.anonymousMode, updatePreferences]);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({ isAnonymous: preferences.anonymousMode, toggleAnonymous }),
|
||||||
|
[preferences.anonymousMode, toggleAnonymous]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AnonymousContext.Provider value={contextValue}>{children}</AnonymousContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAnonymous() {
|
||||||
|
const context = useContext(AnonymousContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useAnonymous must be used within an AnonymousProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -261,6 +261,9 @@
|
|||||||
"add": "Added to favorites",
|
"add": "Added to favorites",
|
||||||
"remove": "Removed from favorites"
|
"remove": "Removed from favorites"
|
||||||
},
|
},
|
||||||
|
"showMore": "Show more",
|
||||||
|
"showLess": "Show less",
|
||||||
|
"missing": "{{count}} missing",
|
||||||
"toggleSidebar": "Toggle sidebar",
|
"toggleSidebar": "Toggle sidebar",
|
||||||
"toggleTheme": "Toggle theme"
|
"toggleTheme": "Toggle theme"
|
||||||
}
|
}
|
||||||
@@ -449,6 +452,9 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "Toggle sidebar",
|
"toggleSidebar": "Toggle sidebar",
|
||||||
"toggleTheme": "Toggle theme",
|
"toggleTheme": "Toggle theme",
|
||||||
|
"anonymousMode": "Anonymous mode",
|
||||||
|
"anonymousModeOn": "Anonymous mode enabled",
|
||||||
|
"anonymousModeOff": "Anonymous mode disabled",
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search series and books...",
|
"placeholder": "Search series and books...",
|
||||||
"empty": "No results",
|
"empty": "No results",
|
||||||
|
|||||||
@@ -260,7 +260,10 @@
|
|||||||
"favorite": {
|
"favorite": {
|
||||||
"add": "Ajouté aux favoris",
|
"add": "Ajouté aux favoris",
|
||||||
"remove": "Retiré des favoris"
|
"remove": "Retiré des favoris"
|
||||||
}
|
},
|
||||||
|
"showMore": "Voir plus",
|
||||||
|
"showLess": "Voir moins",
|
||||||
|
"missing": "{{count}} manquant(s)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"books": {
|
"books": {
|
||||||
@@ -447,6 +450,9 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"toggleSidebar": "Afficher/masquer le menu latéral",
|
"toggleSidebar": "Afficher/masquer le menu latéral",
|
||||||
"toggleTheme": "Changer le thème",
|
"toggleTheme": "Changer le thème",
|
||||||
|
"anonymousMode": "Mode anonyme",
|
||||||
|
"anonymousModeOn": "Mode anonyme activé",
|
||||||
|
"anonymousModeOff": "Mode anonyme désactivé",
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Rechercher séries et tomes...",
|
"placeholder": "Rechercher séries et tomes...",
|
||||||
"empty": "Aucun résultat",
|
"empty": "Aucun résultat",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class KomgaAdapter {
|
|||||||
bookCount: series.booksCount,
|
bookCount: series.booksCount,
|
||||||
booksReadCount: series.booksReadCount,
|
booksReadCount: series.booksReadCount,
|
||||||
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
|
thumbnailUrl: `/api/komga/images/series/${series.id}/thumbnail`,
|
||||||
|
libraryId: series.libraryId,
|
||||||
summary: series.metadata?.summary ?? null,
|
summary: series.metadata?.summary ?? null,
|
||||||
authors: series.booksMetadata?.authors ?? [],
|
authors: series.booksMetadata?.authors ?? [],
|
||||||
genres: series.metadata?.genres ?? [],
|
genres: series.metadata?.genres ?? [],
|
||||||
|
|||||||
@@ -73,11 +73,13 @@ export class StripstreamAdapter {
|
|||||||
bookCount: series.book_count,
|
bookCount: series.book_count,
|
||||||
booksReadCount: series.books_read_count,
|
booksReadCount: series.books_read_count,
|
||||||
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
|
thumbnailUrl: `/api/stripstream/images/books/${series.first_book_id}/thumbnail`,
|
||||||
|
libraryId: series.library_id,
|
||||||
summary: null,
|
summary: null,
|
||||||
authors: [],
|
authors: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
|
missingCount: series.missing_count ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import type {
|
|||||||
import type { HomeData } from "@/types/home";
|
import type { HomeData } from "@/types/home";
|
||||||
import { StripstreamClient } from "./stripstream.client";
|
import { StripstreamClient } from "./stripstream.client";
|
||||||
import { StripstreamAdapter } from "./stripstream.adapter";
|
import { StripstreamAdapter } from "./stripstream.adapter";
|
||||||
import { ERROR_CODES } from "@/constants/errorCodes";
|
|
||||||
import { AppError } from "@/utils/errors";
|
|
||||||
import type {
|
import type {
|
||||||
StripstreamLibraryResponse,
|
StripstreamLibraryResponse,
|
||||||
StripstreamBooksPage,
|
StripstreamBooksPage,
|
||||||
@@ -23,6 +21,7 @@ import type {
|
|||||||
StripstreamBookDetails,
|
StripstreamBookDetails,
|
||||||
StripstreamReadingProgressResponse,
|
StripstreamReadingProgressResponse,
|
||||||
StripstreamSearchResponse,
|
StripstreamSearchResponse,
|
||||||
|
StripstreamSeriesMetadata,
|
||||||
} from "@/types/stripstream";
|
} from "@/types/stripstream";
|
||||||
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
import { HOME_CACHE_TAG, LIBRARY_SERIES_CACHE_TAG, SERIES_BOOKS_CACHE_TAG } from "@/constants/cacheConstants";
|
||||||
|
|
||||||
@@ -91,18 +90,20 @@ export class StripstreamProvider implements IMediaProvider {
|
|||||||
const seriesInfo = await this.findSeriesByName(book.series, book.library_id);
|
const seriesInfo = await this.findSeriesByName(book.series, book.library_id);
|
||||||
if (seriesInfo) return seriesInfo;
|
if (seriesInfo) return seriesInfo;
|
||||||
|
|
||||||
return {
|
const fallback: NormalizedSeries = {
|
||||||
id: seriesId,
|
id: seriesId,
|
||||||
name: book.series,
|
name: book.series,
|
||||||
bookCount: 0,
|
bookCount: 0,
|
||||||
booksReadCount: 0,
|
booksReadCount: 0,
|
||||||
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
|
thumbnailUrl: `/api/stripstream/images/books/${seriesId}/thumbnail`,
|
||||||
|
libraryId: book.library_id,
|
||||||
summary: null,
|
summary: null,
|
||||||
authors: [],
|
authors: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
};
|
};
|
||||||
|
return this.enrichSeriesWithMetadata(fallback, book.library_id, book.series);
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back: treat seriesId as a series name, find its first book
|
// Fall back: treat seriesId as a series name, find its first book
|
||||||
try {
|
try {
|
||||||
@@ -117,18 +118,20 @@ export class StripstreamProvider implements IMediaProvider {
|
|||||||
const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id);
|
const seriesInfo = await this.findSeriesByName(seriesId, firstBook.library_id);
|
||||||
if (seriesInfo) return seriesInfo;
|
if (seriesInfo) return seriesInfo;
|
||||||
|
|
||||||
return {
|
const fallback: NormalizedSeries = {
|
||||||
id: firstBook.id,
|
id: firstBook.id,
|
||||||
name: seriesId,
|
name: seriesId,
|
||||||
bookCount: 0,
|
bookCount: 0,
|
||||||
booksReadCount: 0,
|
booksReadCount: 0,
|
||||||
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
|
thumbnailUrl: `/api/stripstream/images/books/${firstBook.id}/thumbnail`,
|
||||||
|
libraryId: firstBook.library_id,
|
||||||
summary: null,
|
summary: null,
|
||||||
authors: [],
|
authors: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
createdAt: null,
|
createdAt: null,
|
||||||
};
|
};
|
||||||
|
return this.enrichSeriesWithMetadata(fallback, firstBook.library_id, seriesId);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -143,13 +146,49 @@ export class StripstreamProvider implements IMediaProvider {
|
|||||||
{ revalidate: CACHE_TTL_MED }
|
{ revalidate: CACHE_TTL_MED }
|
||||||
);
|
);
|
||||||
const match = seriesPage.items.find((s) => s.name === seriesName);
|
const match = seriesPage.items.find((s) => s.name === seriesName);
|
||||||
if (match) return StripstreamAdapter.toNormalizedSeries(match);
|
if (match) {
|
||||||
|
const normalized = StripstreamAdapter.toNormalizedSeries(match);
|
||||||
|
return this.enrichSeriesWithMetadata(normalized, libraryId, seriesName);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async enrichSeriesWithMetadata(
|
||||||
|
series: NormalizedSeries,
|
||||||
|
libraryId: string,
|
||||||
|
seriesName: string
|
||||||
|
): Promise<NormalizedSeries> {
|
||||||
|
try {
|
||||||
|
const metadata = await this.client.fetch<StripstreamSeriesMetadata>(
|
||||||
|
`libraries/${libraryId}/series/${encodeURIComponent(seriesName)}/metadata`,
|
||||||
|
undefined,
|
||||||
|
{ revalidate: CACHE_TTL_MED }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...series,
|
||||||
|
summary: metadata.description ?? null,
|
||||||
|
authors: metadata.authors.map((name) => ({ name, role: "writer" })),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return series;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveSeriesInfo(seriesId: string): Promise<{ libraryId: string; seriesName: string } | null> {
|
||||||
|
try {
|
||||||
|
const book = await this.client.fetch<StripstreamBookDetails>(`books/${seriesId}`, undefined, {
|
||||||
|
revalidate: CACHE_TTL_MED,
|
||||||
|
});
|
||||||
|
if (!book.series) return null;
|
||||||
|
return { libraryId: book.library_id, seriesName: book.series };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
async getBooks(filter: BookListFilter): Promise<NormalizedBooksPage> {
|
||||||
const limit = filter.limit ?? 24;
|
const limit = filter.limit ?? 24;
|
||||||
const params: Record<string, string | undefined> = { limit: String(limit) };
|
const params: Record<string, string | undefined> = { limit: String(limit) };
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ export interface NormalizedSeries {
|
|||||||
bookCount: number;
|
bookCount: number;
|
||||||
booksReadCount: number;
|
booksReadCount: number;
|
||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
|
libraryId?: string;
|
||||||
// Optional metadata (Komga-rich, Stripstream-sparse)
|
// Optional metadata (Komga-rich, Stripstream-sparse)
|
||||||
summary?: string | null;
|
summary?: string | null;
|
||||||
authors?: Array<{ name: string; role: string }>;
|
authors?: Array<{ name: string; role: string }>;
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt?: string | null;
|
createdAt?: string | null;
|
||||||
|
missingCount?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedBook {
|
export interface NormalizedBook {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export class PreferencesService {
|
|||||||
return {
|
return {
|
||||||
showThumbnails: preferences.showThumbnails,
|
showThumbnails: preferences.showThumbnails,
|
||||||
showOnlyUnread: preferences.showOnlyUnread,
|
showOnlyUnread: preferences.showOnlyUnread,
|
||||||
|
anonymousMode: preferences.anonymousMode,
|
||||||
displayMode: {
|
displayMode: {
|
||||||
...defaultPreferences.displayMode,
|
...defaultPreferences.displayMode,
|
||||||
...displayMode,
|
...displayMode,
|
||||||
@@ -72,6 +73,8 @@ export class PreferencesService {
|
|||||||
}
|
}
|
||||||
if (preferences.readerPrefetchCount !== undefined)
|
if (preferences.readerPrefetchCount !== undefined)
|
||||||
updateData.readerPrefetchCount = preferences.readerPrefetchCount;
|
updateData.readerPrefetchCount = preferences.readerPrefetchCount;
|
||||||
|
if (preferences.anonymousMode !== undefined)
|
||||||
|
updateData.anonymousMode = preferences.anonymousMode;
|
||||||
|
|
||||||
const updatedPreferences = await prisma.preferences.upsert({
|
const updatedPreferences = await prisma.preferences.upsert({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -80,6 +83,7 @@ export class PreferencesService {
|
|||||||
userId,
|
userId,
|
||||||
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
|
showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails,
|
||||||
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
|
showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread,
|
||||||
|
anonymousMode: preferences.anonymousMode ?? defaultPreferences.anonymousMode,
|
||||||
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
|
displayMode: preferences.displayMode ?? defaultPreferences.displayMode,
|
||||||
background: (preferences.background ??
|
background: (preferences.background ??
|
||||||
defaultPreferences.background) as unknown as Prisma.InputJsonValue,
|
defaultPreferences.background) as unknown as Prisma.InputJsonValue,
|
||||||
@@ -90,6 +94,7 @@ export class PreferencesService {
|
|||||||
return {
|
return {
|
||||||
showThumbnails: updatedPreferences.showThumbnails,
|
showThumbnails: updatedPreferences.showThumbnails,
|
||||||
showOnlyUnread: updatedPreferences.showOnlyUnread,
|
showOnlyUnread: updatedPreferences.showOnlyUnread,
|
||||||
|
anonymousMode: updatedPreferences.anonymousMode,
|
||||||
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
|
displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"],
|
||||||
background: {
|
background: {
|
||||||
...defaultPreferences.background,
|
...defaultPreferences.background,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface BackgroundPreferences {
|
|||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
showThumbnails: boolean;
|
showThumbnails: boolean;
|
||||||
showOnlyUnread: boolean;
|
showOnlyUnread: boolean;
|
||||||
|
anonymousMode: boolean;
|
||||||
displayMode: {
|
displayMode: {
|
||||||
compact: boolean;
|
compact: boolean;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
@@ -24,6 +25,7 @@ export interface UserPreferences {
|
|||||||
export const defaultPreferences: UserPreferences = {
|
export const defaultPreferences: UserPreferences = {
|
||||||
showThumbnails: true,
|
showThumbnails: true,
|
||||||
showOnlyUnread: false,
|
showOnlyUnread: false,
|
||||||
|
anonymousMode: false,
|
||||||
displayMode: {
|
displayMode: {
|
||||||
compact: false,
|
compact: false,
|
||||||
itemsPerPage: 20,
|
itemsPerPage: 20,
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface StripstreamSeriesItem {
|
|||||||
book_count: number;
|
book_count: number;
|
||||||
books_read_count: number;
|
books_read_count: number;
|
||||||
first_book_id: string;
|
first_book_id: string;
|
||||||
|
library_id: string;
|
||||||
|
missing_count?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StripstreamSeriesPage {
|
export interface StripstreamSeriesPage {
|
||||||
@@ -80,6 +82,15 @@ export interface StripstreamUpdateReadingProgressRequest {
|
|||||||
current_page?: number | null;
|
current_page?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StripstreamSeriesMetadata {
|
||||||
|
authors: string[];
|
||||||
|
publishers: string[];
|
||||||
|
description?: string | null;
|
||||||
|
start_year?: number | null;
|
||||||
|
book_author?: string | null;
|
||||||
|
book_language?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StripstreamSearchResponse {
|
export interface StripstreamSearchResponse {
|
||||||
hits: StripstreamSearchHit[];
|
hits: StripstreamSearchHit[];
|
||||||
series_hits: StripstreamSeriesHit[];
|
series_hits: StripstreamSeriesHit[];
|
||||||
|
|||||||