feat: add anonymous mode toggle to hide reading progress and tracking
Adds a toggleable anonymous mode (eye icon in header) that: - Stops syncing read progress to the server while reading - Hides mark as read/unread buttons on book covers and lists - Hides reading status badges on series and books - Hides progress bars on series and book covers - Hides "continue reading" and "continue series" sections on home - Persists the setting server-side in user preferences (anonymousMode) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
<AuthProvider>
|
||||
<I18nProvider locale={locale}>
|
||||
<PreferencesProvider initialPreferences={preferences}>
|
||||
<ClientLayout
|
||||
initialLibraries={libraries}
|
||||
initialFavorites={favorites}
|
||||
userIsAdmin={userIsAdmin}
|
||||
>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
<AnonymousProvider>
|
||||
<ClientLayout
|
||||
initialLibraries={libraries}
|
||||
initialFavorites={favorites}
|
||||
userIsAdmin={userIsAdmin}
|
||||
>
|
||||
{children}
|
||||
</ClientLayout>
|
||||
</AnonymousProvider>
|
||||
</PreferencesProvider>
|
||||
</I18nProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -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 (
|
||||
<HomeClientWrapper>
|
||||
<HomeContent data={data} />
|
||||
<HomeContent data={data} isAnonymous={preferences?.anonymousMode ?? false} />
|
||||
</HomeClientWrapper>
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-10 pb-2">
|
||||
{continueReading.length > 0 && (
|
||||
{!isAnonymous && continueReading.length > 0 && (
|
||||
<MediaRow
|
||||
titleKey="home.sections.continue_reading"
|
||||
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
|
||||
titleKey="home.sections.continue_series"
|
||||
items={data.ongoing}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { History, Sparkles, Clock, LibraryBig, BookOpen, Heart } from "lucide-re
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { useBookOfflineStatus } from "@/hooks/useBookOfflineStatus";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface MediaRowProps {
|
||||
titleKey: string;
|
||||
@@ -78,6 +79,7 @@ interface MediaCardProps {
|
||||
|
||||
function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
const { t } = useTranslate();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
const isSeriesItem = isSeries(item);
|
||||
const { isAccessible } = useBookOfflineStatus(isSeriesItem ? "" : item.id);
|
||||
|
||||
@@ -105,7 +107,7 @@ function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
<div className="relative aspect-[2/3] bg-muted">
|
||||
{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">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{title}</h3>
|
||||
<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 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")}
|
||||
/>
|
||||
<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 />
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SeriesCover } from "@/components/ui/series-cover";
|
||||
import { useTranslate } from "@/hooks/useTranslate";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface SeriesGridProps {
|
||||
series: NormalizedSeries[];
|
||||
@@ -49,6 +50,7 @@ const getReadingStatusInfo = (
|
||||
export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
|
||||
if (!series.length) {
|
||||
return (
|
||||
@@ -73,24 +75,27 @@ export function SeriesGrid({ series, isCompact = false }: SeriesGridProps) {
|
||||
onClick={() => router.push(`/series/${seriesItem.id}`)}
|
||||
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",
|
||||
seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
|
||||
!isAnonymous && seriesItem.bookCount === seriesItem.booksReadCount && "opacity-50",
|
||||
isCompact && "aspect-[3/4]"
|
||||
)}
|
||||
>
|
||||
<SeriesCover
|
||||
series={seriesItem}
|
||||
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">
|
||||
<h3 className="font-medium text-sm text-white line-clamp-2">{seriesItem.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
getReadingStatusInfo(seriesItem, t).className
|
||||
}`}
|
||||
>
|
||||
{getReadingStatusInfo(seriesItem, t).label}
|
||||
</span>
|
||||
{!isAnonymous && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
getReadingStatusInfo(seriesItem, t).className
|
||||
}`}
|
||||
>
|
||||
{getReadingStatusInfo(seriesItem, t).label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-white/80">
|
||||
{t("series.books", { count: seriesItem.bookCount })}
|
||||
</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { BookOpen, Calendar, Tag, User } from "lucide-react";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useAnonymous } from "@/contexts/AnonymousContext";
|
||||
|
||||
interface SeriesListProps {
|
||||
series: NormalizedSeries[];
|
||||
@@ -57,16 +58,17 @@ const getReadingStatusInfo = (
|
||||
function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
const { isAnonymous } = useAnonymous();
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/series/${series.id}`);
|
||||
};
|
||||
|
||||
const isCompleted = series.bookCount === series.booksReadCount;
|
||||
const isCompleted = isAnonymous ? false : series.bookCount === series.booksReadCount;
|
||||
const progressPercentage =
|
||||
series.bookCount > 0 ? (series.booksReadCount / series.bookCount) * 100 : 0;
|
||||
|
||||
const statusInfo = getReadingStatusInfo(series, t);
|
||||
const statusInfo = isAnonymous ? null : getReadingStatusInfo(series, t);
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
@@ -83,6 +85,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
series={series}
|
||||
alt={t("series.coverAlt", { title: series.name })}
|
||||
className="w-full h-full"
|
||||
isAnonymous={isAnonymous}
|
||||
/>
|
||||
</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">
|
||||
{series.name}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{statusInfo && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Métadonnées minimales */}
|
||||
@@ -139,6 +144,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
series={series}
|
||||
alt={t("series.coverAlt", { title: series.name })}
|
||||
className="w-full h-full"
|
||||
isAnonymous={isAnonymous}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -153,14 +159,16 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
</div>
|
||||
|
||||
{/* Badge de statut */}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{statusInfo && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Résumé */}
|
||||
@@ -224,7 +232,7 @@ function SeriesListItem({ series, isCompact = false }: SeriesListItemProps) {
|
||||
</div>
|
||||
|
||||
{/* Barre de progression */}
|
||||
{series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
||||
{!isAnonymous && series.bookCount > 0 && !isCompleted && series.booksReadCount > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={progressPercentage} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -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:");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{!isAnonymous && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Métadonnées minimales */}
|
||||
@@ -191,14 +195,16 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
</div>
|
||||
|
||||
{/* Badge de statut */}
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{!isAnonymous && (
|
||||
<span
|
||||
className={cn(
|
||||
"px-2 py-1 rounded-full text-xs font-medium flex-shrink-0",
|
||||
statusInfo.className
|
||||
)}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Métadonnées */}
|
||||
@@ -224,7 +230,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-auto pt-2">
|
||||
{!isRead && (
|
||||
{!isAnonymous && !isRead && (
|
||||
<MarkAsReadButton
|
||||
bookId={book.id}
|
||||
pagesCount={book.pageCount}
|
||||
@@ -233,7 +239,7 @@ function BookListItem({ book, onBookClick, onSuccess, isCompact = false }: BookL
|
||||
className="text-xs"
|
||||
/>
|
||||
)}
|
||||
{hasReadProgress && (
|
||||
{!isAnonymous && hasReadProgress && (
|
||||
<MarkAsUnreadButton
|
||||
bookId={book.id}
|
||||
onSuccess={() => onSuccess(book, "unread")}
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-4 justify-center md:justify-start flex-wrap">
|
||||
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
|
||||
{statusInfo.label}
|
||||
</StatusBadge>
|
||||
{statusInfo && (
|
||||
<StatusBadge status={statusInfo.status} icon={statusInfo.icon}>
|
||||
{statusInfo.label}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<span className="text-sm text-white/80">
|
||||
{series.bookCount === 1
|
||||
? t("series.header.books", { count: series.bookCount })
|
||||
|
||||
@@ -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
|
||||
<div className="absolute top-2 right-2 pointer-events-auto flex gap-1">
|
||||
{!isRead && (
|
||||
{!isAnonymous && !isRead && (
|
||||
<MarkAsReadButton
|
||||
bookId={book.id}
|
||||
pagesCount={book.pageCount}
|
||||
@@ -124,7 +126,7 @@ export function BookCover({
|
||||
className="bg-white/90 hover:bg-white text-black shadow-sm"
|
||||
/>
|
||||
)}
|
||||
{hasReadProgress && (
|
||||
{!isAnonymous && hasReadProgress && (
|
||||
<MarkAsUnreadButton
|
||||
bookId={book.id}
|
||||
onSuccess={() => handleMarkAsUnread()}
|
||||
@@ -145,11 +147,13 @@ export function BookCover({
|
||||
? t("navigation.volume", { number: book.number })
|
||||
: "")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
{!isAnonymous && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${statusInfo.className}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -162,12 +166,14 @@ export function BookCover({
|
||||
? t("navigation.volume", { number: book.number })
|
||||
: "")}
|
||||
</h3>
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("books.status.progress", {
|
||||
current: currentPage,
|
||||
total: book.pageCount,
|
||||
})}
|
||||
</p>
|
||||
{!isAnonymous && (
|
||||
<p className="text-xs text-white/80 mt-1">
|
||||
{t("books.status.progress", {
|
||||
current: currentPage,
|
||||
total: book.pageCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -18,4 +18,5 @@ export interface BookCoverProps extends BaseCoverProps {
|
||||
|
||||
export interface SeriesCoverProps extends BaseCoverProps {
|
||||
series: NormalizedSeries;
|
||||
isAnonymous?: boolean;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
34
src/contexts/AnonymousContext.tsx
Normal file
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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user