From 65e62f5800d7716a4e8609de5b3b7bed32b52eb5 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Thu, 13 Feb 2025 22:28:32 +0100 Subject: [PATCH] feat(favorites): add button and local store choice --- devbook.md | 9 +- package-lock.json | 16 +- package.json | 8 +- src/components/series/SeriesHeader.tsx | 231 ++++++++++++++++++------- src/components/ui/button.tsx | 48 +++++ src/lib/constants.ts | 1 + src/lib/services/favorite.service.ts | 31 ++++ src/lib/services/storage.service.ts | 48 ++++- src/types/komga.ts | 4 + 9 files changed, 319 insertions(+), 77 deletions(-) create mode 100644 src/components/ui/button.tsx create mode 100644 src/lib/services/favorite.service.ts diff --git a/devbook.md b/devbook.md index ad7df38..1b58582 100644 --- a/devbook.md +++ b/devbook.md @@ -8,11 +8,10 @@ Application web moderne pour la lecture de BD/mangas/comics via un serveur Komga ### 📚 Gestion des séries -- [ ] Système de favoris - - [ ] Ajout/suppression des favoris +- [x] Système de favoris + - [x] Ajout/suppression des favoris (stockage local) - [ ] Menu dédié dans la sidebar - [ ] Carousel dédié dans sur la homepage de toutes les séries favorites - - [ ] Synchronisation avec Komga - [ ] Vue liste/grille configurable - [ ] Filtres et tri avancés - [ ] Recherche globale @@ -260,3 +259,7 @@ Application web moderne pour la lecture de BD/mangas/comics via un serveur Komga - [x] Tomes - [x] Progression de lecture - [x] Images et miniatures + +### Gestion des séries + +- [x] Système de favoris (ajout/retrait d'une série des favoris) diff --git a/package-lock.json b/package-lock.json index d5fa89b..f25d241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "dependencies": { "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-toast": "^1.2.6", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.323.0", "next": "^14.1.0", @@ -20,6 +20,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "sharp": "^0.33.2", + "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" @@ -1296,7 +1297,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", - "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, @@ -2394,7 +2394,6 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", - "license": "Apache-2.0", "dependencies": { "clsx": "^2.1.1" }, @@ -5966,6 +5965,15 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", + "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 483c5f5..1d6aae0 100644 --- a/package.json +++ b/package.json @@ -12,19 +12,19 @@ "dependencies": { "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-toast": "^1.2.6", - "class-variance-authority": "^0.7.0", + "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.323.0", "next": "^14.1.0", "next-themes": "^0.4.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "sharp": "^0.33.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4", - "sharp": "^0.33.2" + "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^20.11.16", diff --git a/src/components/series/SeriesHeader.tsx b/src/components/series/SeriesHeader.tsx index 1c8ad4a..85c05d8 100644 --- a/src/components/series/SeriesHeader.tsx +++ b/src/components/series/SeriesHeader.tsx @@ -1,17 +1,28 @@ "use client"; import Image from "next/image"; -import { ImageOff } from "lucide-react"; +import { ImageOff, Book, BookOpen, BookMarked } from "lucide-react"; import { KomgaSeries } from "@/types/komga"; import { useState, useEffect } from "react"; +import { FavoriteService } from "@/lib/services/favorite.service"; +import { Star, StarOff } from "lucide-react"; +import { Button } from "../ui/button"; +import { useToast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; interface SeriesHeaderProps { series: KomgaSeries; - serverUrl: string; + onSeriesUpdate?: (series: KomgaSeries) => void; +} + +interface ReadingStatusInfo { + label: string; + className: string; + icon: React.ElementType; } // Fonction utilitaire pour obtenir les informations de lecture d'une série -const getReadingStatusInfo = (series: KomgaSeries) => { +const getReadingStatusInfo = (series: KomgaSeries): ReadingStatusInfo => { const { booksCount, booksReadCount, booksUnreadCount } = series; const booksInProgressCount = booksCount - (booksReadCount + booksUnreadCount); @@ -19,6 +30,7 @@ const getReadingStatusInfo = (series: KomgaSeries) => { return { label: "Lu", className: "bg-green-500/10 text-green-500", + icon: BookOpen, }; } @@ -26,22 +38,31 @@ const getReadingStatusInfo = (series: KomgaSeries) => { return { label: `${booksReadCount}/${booksCount}`, className: "bg-blue-500/10 text-blue-500", + icon: BookMarked, }; } return { label: "Non lu", className: "bg-yellow-500/10 text-yellow-500", + icon: Book, }; }; -export function SeriesHeader({ series, serverUrl }: SeriesHeaderProps) { +export const SeriesHeader = ({ series, onSeriesUpdate }: SeriesHeaderProps) => { + const { toast } = useToast(); const [languageDisplay, setLanguageDisplay] = useState(series.metadata.language); const [imageError, setImageError] = useState(false); - const [readingStatus, setReadingStatus] = useState<{ - label: string; - className: string; - } | null>(null); + const [readingStatus, setReadingStatus] = useState( + getReadingStatusInfo(series) + ); + const [isFavorite, setIsFavorite] = useState(FavoriteService.isFavorite(series.id)); + const [isLoading, setIsLoading] = useState(false); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); useEffect(() => { setReadingStatus(getReadingStatusInfo(series)); @@ -61,78 +82,158 @@ export function SeriesHeader({ series, serverUrl }: SeriesHeaderProps) { } }, [series.metadata.language]); - const getSeriesThumbnailUrl = (seriesId: string) => { - return `/api/komga/images/series/${seriesId}/thumbnail`; + const handleToggleFavorite = () => { + try { + setIsLoading(true); + if (isFavorite) { + FavoriteService.removeFromFavorites(series.id); + } else { + FavoriteService.addToFavorites(series.id); + } + setIsFavorite(!isFavorite); + if (onSeriesUpdate) { + onSeriesUpdate({ ...series, favorite: !isFavorite }); + } + toast({ + title: isFavorite ? "Retiré des favoris" : "Ajouté aux favoris", + variant: "default", + }); + } catch (error) { + toast({ + title: "Une erreur est survenue", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } }; + const StatusIcon = readingStatus.icon; + return ( -
- {/* Couverture */} -
-
- {!imageError ? ( +
+ {/* Fond flou */} +
+ {!imageError && ( +
{`Couverture setImageError(true)} + className="object-cover opacity-10 blur-2xl scale-110" + priority + unoptimized /> - ) : ( -
- -
- )} -
+
+ )}
- {/* Informations */} -
-
-

{series.metadata.title}

-
- {readingStatus && ( - - {readingStatus.label} - + {/* Contenu */} +
+
+ {/* Image de couverture */} +
+ {!imageError ? ( +
+ {series.name} setImageError(true)} + priority + unoptimized + /> +
+ ) : ( +
+ +
)}
-
- {series.metadata.summary && ( -

{series.metadata.summary}

- )} + {/* Informations */} +
+
+

{series.name}

+ {mounted && ( + + )} +
-
- {series.metadata.publisher && ( -
- Éditeur : {series.metadata.publisher} + {/* Métadonnées */} +
+ {series.metadata.publisher && ( + {series.metadata.publisher} + )} + {languageDisplay && ( + {languageDisplay} + )} + {series.metadata.status && ( + + {series.metadata.status.toLowerCase()} + + )}
- )} - {series.metadata.genres?.length > 0 && ( -
- Genres : {series.metadata.genres.join(", ")} + + {/* Stats */} +
+
+ + {readingStatus.label} +
+
+ {series.booksCount} tomes + {series.booksReadCount} lus + {series.booksInProgressCount} en cours +
- )} - {series.metadata.tags?.length > 0 && ( -
- Tags : {series.metadata.tags.join(", ")} -
- )} - {series.metadata.language && ( -
- Langue : {languageDisplay} -
- )} - {series.metadata.ageRating && ( -
- Âge recommandé : {series.metadata.ageRating}+ -
- )} + + {/* Description */} + {series.metadata.summary && ( +

+ {series.metadata.summary} +

+ )} + + {/* Tags et genres */} + {(series.metadata.tags?.length > 0 || series.metadata.genres?.length > 0) && ( +
+ {[...(series.metadata.genres || []), ...(series.metadata.tags || [])].map((tag) => ( + + {tag} + + ))} +
+ )} +
); -} +}; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..b16205e --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 569943a..a9311cb 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -3,4 +3,5 @@ export const STORAGE_KEYS = { CREDENTIALS: "komgaCredentials", USER: "stripUser", TTL_CONFIG: "ttlConfig", + FAVORITES: "stripstream_favorites", } as const; diff --git a/src/lib/services/favorite.service.ts b/src/lib/services/favorite.service.ts new file mode 100644 index 0000000..cec2cef --- /dev/null +++ b/src/lib/services/favorite.service.ts @@ -0,0 +1,31 @@ +import { storageService } from "./storage.service"; + +export class FavoriteService { + /** + * Vérifie si une série est dans les favoris + */ + static isFavorite(seriesId: string): boolean { + return storageService.isFavorite(seriesId); + } + + /** + * Ajoute une série aux favoris + */ + static addToFavorites(seriesId: string): void { + storageService.addFavorite(seriesId); + } + + /** + * Retire une série des favoris + */ + static removeFromFavorites(seriesId: string): void { + storageService.removeFavorite(seriesId); + } + + /** + * Récupère tous les IDs des séries favorites + */ + static getAllFavoriteIds(): string[] { + return storageService.getFavorites(); + } +} diff --git a/src/lib/services/storage.service.ts b/src/lib/services/storage.service.ts index 538a845..2c59615 100644 --- a/src/lib/services/storage.service.ts +++ b/src/lib/services/storage.service.ts @@ -5,6 +5,7 @@ const { CREDENTIALS: KOMGACREDENTIALS_KEY, USER: USER_KEY, TTL_CONFIG: TTL_CONFIG_KEY, + FAVORITES: FAVORITES_KEY, } = STORAGE_KEYS; interface TTLConfig { @@ -16,7 +17,7 @@ interface TTLConfig { imagesTTL: number; } -class StorageService { +export class StorageService { private static instance: StorageService; private constructor() {} @@ -170,6 +171,51 @@ class StorageService { ttlConfig: TTL_CONFIG_KEY, }; } + + getFavorites(): string[] { + try { + const favorites = localStorage.getItem(FAVORITES_KEY); + return favorites ? JSON.parse(favorites) : []; + } catch (error) { + console.error("Erreur lors de la récupération des favoris:", error); + return []; + } + } + + addFavorite(seriesId: string): void { + try { + const favorites = this.getFavorites(); + if (!favorites.includes(seriesId)) { + favorites.push(seriesId); + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)); + } + } catch (error) { + console.error("Erreur lors de l'ajout aux favoris:", error); + } + } + + removeFavorite(seriesId: string): void { + try { + const favorites = this.getFavorites(); + const index = favorites.indexOf(seriesId); + if (index > -1) { + favorites.splice(index, 1); + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)); + } + } catch (error) { + console.error("Erreur lors de la suppression des favoris:", error); + } + } + + isFavorite(seriesId: string): boolean { + try { + const favorites = this.getFavorites(); + return favorites.includes(seriesId); + } catch (error) { + console.error("Erreur lors de la vérification des favoris:", error); + return false; + } + } } export const storageService = StorageService.getInstance(); diff --git a/src/types/komga.ts b/src/types/komga.ts index aee1c80..3011318 100644 --- a/src/types/komga.ts +++ b/src/types/komga.ts @@ -30,8 +30,12 @@ export interface KomgaSeries { booksCount: number; booksReadCount: number; booksUnreadCount: number; + booksInProgressCount: number; metadata: SeriesMetadata; booksMetadata: BooksMetadata; + deleted: boolean; + oneshot: boolean; + favorite: boolean; } export interface SeriesMetadata {