diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7d05ba2..779f84a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,7 @@ model Preferences { displayMode Json background Json readerPrefetchCount Int @default(5) + anonymousMode Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d4a3ca9..55c7f93 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import { cn } from "@/lib/utils"; import ClientLayout from "@/components/layout/ClientLayout"; import { PreferencesService } from "@/lib/services/preferences.service"; import { PreferencesProvider } from "@/contexts/PreferencesContext"; +import { AnonymousProvider } from "@/contexts/AnonymousContext"; import { I18nProvider } from "@/components/providers/I18nProvider"; import { AuthProvider } from "@/components/providers/AuthProvider"; import { cookies, headers } from "next/headers"; @@ -313,13 +314,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo - - {children} - + + + {children} + + diff --git a/src/app/page.tsx b/src/app/page.tsx index 7e395ed..809b853 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,7 @@ import { ErrorMessage } from "@/components/ui/ErrorMessage"; import { ERROR_CODES } from "@/constants/errorCodes"; import { AppError } from "@/utils/errors"; import { FavoritesService } from "@/lib/services/favorites.service"; +import { PreferencesService } from "@/lib/services/preferences.service"; import { redirect } from "next/navigation"; export default async function HomePage() { @@ -12,16 +13,17 @@ export default async function HomePage() { const provider = await getProvider(); if (!provider) redirect("/settings"); - const [homeData, favorites] = await Promise.all([ + const [homeData, favorites, preferences] = await Promise.all([ provider.getHomeData(), FavoritesService.getFavorites(), + PreferencesService.getPreferences().catch(() => null), ]); const data = { ...homeData, favorites }; return ( - + ); } catch (error) { diff --git a/src/components/home/HomeContent.tsx b/src/components/home/HomeContent.tsx index 83565ca..6eaa8eb 100644 --- a/src/components/home/HomeContent.tsx +++ b/src/components/home/HomeContent.tsx @@ -3,9 +3,10 @@ import type { HomeData } from "@/types/home"; interface HomeContentProps { 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), // deduplicate by id, onDeck first const continueReading = (() => { @@ -20,7 +21,7 @@ export function HomeContent({ data }: HomeContentProps) { return (
- {continueReading.length > 0 && ( + {!isAnonymous && continueReading.length > 0 && ( )} - {data.ongoing && data.ongoing.length > 0 && ( + {!isAnonymous && data.ongoing && data.ongoing.length > 0 && ( {isSeriesItem ? ( <> - +

{title}

diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 7c035a3..ded170b 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -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 LanguageSelector from "@/components/LanguageSelector"; import { useTranslation } from "react-i18next"; import { IconButton } from "@/components/ui/icon-button"; import { useState } from "react"; import { GlobalSearch } from "@/components/layout/GlobalSearch"; +import { useAnonymous } from "@/contexts/AnonymousContext"; interface HeaderProps { onToggleSidebar: () => void; @@ -19,6 +20,7 @@ export function Header({ }: HeaderProps) { const { theme, setTheme } = useTheme(); const { t } = useTranslation(); + const { isAnonymous, toggleAnonymous } = useAnonymous(); const [isRefreshing, setIsRefreshing] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); @@ -87,6 +89,14 @@ export function Header({ className="h-9 w-9 rounded-full sm:hidden" tooltip={t("header.search.placeholder")} /> +

@@ -153,14 +159,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
{/* Badge de statut */} - - {statusInfo.label} - + {statusInfo && ( + + {statusInfo.label} + + )} {/* Résumé */} @@ -224,7 +232,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) { {/* Barre de progression */} - {series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && ( + {!isAnonymous && series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (

diff --git a/src/components/reader/hooks/usePageNavigation.ts b/src/components/reader/hooks/usePageNavigation.ts index 35161ba..2663414 100644 --- a/src/components/reader/hooks/usePageNavigation.ts +++ b/src/components/reader/hooks/usePageNavigation.ts @@ -4,6 +4,7 @@ import { ClientOfflineBookService } from "@/lib/services/client-offlinebook.serv import type { NormalizedBook } from "@/lib/providers/types"; import logger from "@/lib/logger"; import { updateReadProgress } from "@/app/actions/read-progress"; +import { useAnonymous } from "@/contexts/AnonymousContext"; interface UsePageNavigationProps { book: NormalizedBook; @@ -23,6 +24,13 @@ export function usePageNavigation({ nextBook, }: UsePageNavigationProps) { const router = useRouter(); + const { isAnonymous } = useAnonymous(); + const isAnonymousRef = useRef(isAnonymous); + + useEffect(() => { + isAnonymousRef.current = isAnonymous; + }, [isAnonymous]); + const [currentPage, setCurrentPage] = useState(() => { const saved = ClientOfflineBookService.getCurrentPage(book); return saved < 1 ? 1 : saved; @@ -48,8 +56,10 @@ export function usePageNavigation({ async (page: number) => { try { ClientOfflineBookService.setCurrentPage(bookRef.current, page); - const completed = page === pagesLengthRef.current; - await updateReadProgress(bookRef.current.id, page, completed); + if (!isAnonymousRef.current) { + const completed = page === pagesLengthRef.current; + await updateReadProgress(bookRef.current.id, page, completed); + } } catch (error) { logger.error({ err: error }, "Sync error:"); } diff --git a/src/components/series/BookList.tsx b/src/components/series/BookList.tsx index da47bb9..de34b38 100644 --- a/src/components/series/BookList.tsx +++ b/src/components/series/BookList.tsx @@ -13,6 +13,7 @@ import { FileText } from "lucide-react"; import { MarkAsReadButton } from "@/components/ui/mark-as-read-button"; import { MarkAsUnreadButton } from "@/components/ui/mark-as-unread-button"; import { BookOfflineButton } from "@/components/ui/book-offline-button"; +import { useAnonymous } from "@/contexts/AnonymousContext"; interface BookListProps { books: NormalizedBook[]; @@ -30,6 +31,7 @@ interface BookListItemProps { function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookListItemProps) { const { t } = useTranslate(); + const { isAnonymous } = useAnonymous(); const { isAccessible } = useBookOfflineStatus(book.id); const handleClick = () => { @@ -37,9 +39,9 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL onBookClick(book); }; - const isRead = book.readProgress?.completed || false; - const hasReadProgress = book.readProgress !== null; - const currentPage = ClientOfflineBookService.getCurrentPage(book); + const isRead = isAnonymous ? false : (book.readProgress?.completed || false); + const hasReadProgress = isAnonymous ? false : book.readProgress !== null; + const currentPage = isAnonymous ? 0 : ClientOfflineBookService.getCurrentPage(book); const totalPages = book.pageCount; const progressPercentage = totalPages > 0 ? (currentPage / totalPages) * 100 : 0; @@ -118,14 +120,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL > {title} - - {statusInfo.label} - + {!isAnonymous && ( + + {statusInfo.label} + + )}

{/* Métadonnées minimales */} @@ -191,14 +195,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL {/* Badge de statut */} - - {statusInfo.label} - + {!isAnonymous && ( + + {statusInfo.label} + + )} {/* Métadonnées */} @@ -224,7 +230,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL {/* Actions */}
- {!isRead && ( + {!isAnonymous && !isRead && ( )} - {hasReadProgress && ( + {!isAnonymous && hasReadProgress && ( onSuccess(book, "unread")} diff --git a/src/components/series/SeriesHeader.tsx b/src/components/series/SeriesHeader.tsx index 6f8dd27..e761086 100644 --- a/src/components/series/SeriesHeader.tsx +++ b/src/components/series/SeriesHeader.tsx @@ -14,6 +14,7 @@ import { StatusBadge } from "@/components/ui/status-badge"; import { IconButton } from "@/components/ui/icon-button"; import logger from "@/lib/logger"; import { addToFavorites, removeFromFavorites } from "@/app/actions/favorites"; +import { useAnonymous } from "@/contexts/AnonymousContext"; interface SeriesHeaderProps { series: NormalizedSeries; @@ -23,6 +24,7 @@ interface SeriesHeaderProps { export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: SeriesHeaderProps) => { const { toast } = useToast(); + const { isAnonymous } = useAnonymous(); const [isFavorite, setIsFavorite] = useState(initialIsFavorite); const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); const { t } = useTranslate(); @@ -100,7 +102,7 @@ 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; @@ -152,9 +154,11 @@ export const SeriesHeader = ({ series, refreshSeries, initialIsFavorite }: Serie
)}
- - {statusInfo.label} - + {statusInfo && ( + + {statusInfo.label} + + )} {series.bookCount === 1 ? t("series.header.books", { count: series.bookCount }) diff --git a/src/components/ui/book-cover.tsx b/src/components/ui/book-cover.tsx index 64b0f87..911f896 100644 --- a/src/components/ui/book-cover.tsx +++ b/src/components/ui/book-cover.tsx @@ -10,6 +10,7 @@ import { useTranslate } from "@/hooks/useTranslate"; import { formatDate } from "@/lib/utils"; import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus"; import { WifiOff } from "lucide-react"; +import { useAnonymous } from "@/contexts/AnonymousContext"; // Fonction utilitaire pour obtenir les informations de statut de lecture const getReadingStatusInfo = ( @@ -60,17 +61,18 @@ export function BookCover({ overlayVariant = "default", }: BookCoverProps) { const { t } = useTranslate(); + const { isAnonymous } = useAnonymous(); 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 showProgress = Boolean(showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted); + const showProgress = Boolean(!isAnonymous && showProgressUi && totalPages > 0 && currentPage > 0 && !isCompleted); - const statusInfo = getReadingStatusInfo(book, t); - const isRead = book.readProgress?.completed || false; - const hasReadProgress = book.readProgress !== null || currentPage > 0; + const statusInfo = isAnonymous ? { label: "", className: "" } : getReadingStatusInfo(book, t); + const isRead = isAnonymous ? false : (book.readProgress?.completed || false); + const hasReadProgress = isAnonymous ? false : (book.readProgress !== null || currentPage > 0); // Détermine si le livre doit être grisé (non accessible hors ligne) const isUnavailable = !isAccessible; @@ -115,7 +117,7 @@ export function BookCover({ {showControls && ( // Boutons en haut à droite avec un petit décalage
- {!isRead && ( + {!isAnonymous && !isRead && ( )} - {hasReadProgress && ( + {!isAnonymous && hasReadProgress && ( handleMarkAsUnread()} @@ -145,11 +147,13 @@ export function BookCover({ ? t("navigation.volume", { number: book.number }) : "")}

-
- - {statusInfo.label} - -
+ {!isAnonymous && ( +
+ + {statusInfo.label} + +
+ )}
)}
@@ -162,12 +166,14 @@ export function BookCover({ ? t("navigation.volume", { number: book.number }) : "")} -

- {t("books.status.progress", { - current: currentPage, - total: book.pageCount, - })} -

+ {!isAnonymous && ( +

+ {t("books.status.progress", { + current: currentPage, + total: book.pageCount, + })} +

+ )} )} diff --git a/src/components/ui/cover-utils.tsx b/src/components/ui/cover-utils.tsx index 0f7f44a..3e4a1dd 100644 --- a/src/components/ui/cover-utils.tsx +++ b/src/components/ui/cover-utils.tsx @@ -18,4 +18,5 @@ export interface BookCoverProps extends BaseCoverProps { export interface SeriesCoverProps extends BaseCoverProps { series: NormalizedSeries; + isAnonymous?: boolean; } diff --git a/src/components/ui/series-cover.tsx b/src/components/ui/series-cover.tsx index a606feb..8c9a428 100644 --- a/src/components/ui/series-cover.tsx +++ b/src/components/ui/series-cover.tsx @@ -7,12 +7,13 @@ export function SeriesCover({ alt = "Image de couverture", className, showProgressUi = true, + isAnonymous = false, }: 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 showProgress = Boolean(showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted); + const showProgress = Boolean(!isAnonymous && showProgressUi && totalBooks > 0 && readBooks > 0 && !isCompleted); const missingCount = series.missingCount; return ( diff --git a/src/contexts/AnonymousContext.tsx b/src/contexts/AnonymousContext.tsx new file mode 100644 index 0000000..61e82aa --- /dev/null +++ b/src/contexts/AnonymousContext.tsx @@ -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(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 {children}; +} + +export function useAnonymous() { + const context = useContext(AnonymousContext); + if (context === undefined) { + throw new Error("useAnonymous must be used within an AnonymousProvider"); + } + return context; +} diff --git a/src/i18n/messages/en/common.json b/src/i18n/messages/en/common.json index 4ce593e..2a869af 100644 --- a/src/i18n/messages/en/common.json +++ b/src/i18n/messages/en/common.json @@ -452,6 +452,9 @@ "header": { "toggleSidebar": "Toggle sidebar", "toggleTheme": "Toggle theme", + "anonymousMode": "Anonymous mode", + "anonymousModeOn": "Anonymous mode enabled", + "anonymousModeOff": "Anonymous mode disabled", "search": { "placeholder": "Search series and books...", "empty": "No results", diff --git a/src/i18n/messages/fr/common.json b/src/i18n/messages/fr/common.json index d5d75d3..f29a41e 100644 --- a/src/i18n/messages/fr/common.json +++ b/src/i18n/messages/fr/common.json @@ -450,6 +450,9 @@ "header": { "toggleSidebar": "Afficher/masquer le menu latéral", "toggleTheme": "Changer le thème", + "anonymousMode": "Mode anonyme", + "anonymousModeOn": "Mode anonyme activé", + "anonymousModeOff": "Mode anonyme désactivé", "search": { "placeholder": "Rechercher séries et tomes...", "empty": "Aucun résultat", diff --git a/src/lib/services/preferences.service.ts b/src/lib/services/preferences.service.ts index a1387a4..88fa128 100644 --- a/src/lib/services/preferences.service.ts +++ b/src/lib/services/preferences.service.ts @@ -34,6 +34,7 @@ export class PreferencesService { return { showThumbnails: preferences.showThumbnails, showOnlyUnread: preferences.showOnlyUnread, + anonymousMode: preferences.anonymousMode, displayMode: { ...defaultPreferences.displayMode, ...displayMode, @@ -72,6 +73,8 @@ export class PreferencesService { } if (preferences.readerPrefetchCount !== undefined) updateData.readerPrefetchCount = preferences.readerPrefetchCount; + if (preferences.anonymousMode !== undefined) + updateData.anonymousMode = preferences.anonymousMode; const updatedPreferences = await prisma.preferences.upsert({ where: { userId }, @@ -80,6 +83,7 @@ export class PreferencesService { userId, showThumbnails: preferences.showThumbnails ?? defaultPreferences.showThumbnails, showOnlyUnread: preferences.showOnlyUnread ?? defaultPreferences.showOnlyUnread, + anonymousMode: preferences.anonymousMode ?? defaultPreferences.anonymousMode, displayMode: preferences.displayMode ?? defaultPreferences.displayMode, background: (preferences.background ?? defaultPreferences.background) as unknown as Prisma.InputJsonValue, @@ -90,6 +94,7 @@ export class PreferencesService { return { showThumbnails: updatedPreferences.showThumbnails, showOnlyUnread: updatedPreferences.showOnlyUnread, + anonymousMode: updatedPreferences.anonymousMode, displayMode: updatedPreferences.displayMode as UserPreferences["displayMode"], background: { ...defaultPreferences.background, diff --git a/src/types/preferences.ts b/src/types/preferences.ts index dde30d6..40bbb7b 100644 --- a/src/types/preferences.ts +++ b/src/types/preferences.ts @@ -12,6 +12,7 @@ export interface BackgroundPreferences { export interface UserPreferences { showThumbnails: boolean; showOnlyUnread: boolean; + anonymousMode: boolean; displayMode: { compact: boolean; itemsPerPage: number; @@ -24,6 +25,7 @@ export interface UserPreferences { export const defaultPreferences: UserPreferences = { showThumbnails: true, showOnlyUnread: false, + anonymousMode: false, displayMode: { compact: false, itemsPerPage: 20,