feat: Initial commit - Base application with Next.js - Configuration, Auth, Library navigation, CBZ/CBR reader, Cache, Responsive design

This commit is contained in:
Julien Froidefond
2025-02-11 21:04:40 +01:00
commit 33bdc43442
48 changed files with 9813 additions and 0 deletions

3
src/lib/config.ts Normal file
View File

@@ -0,0 +1,3 @@
export const config = {
serverUrl: process.env.NEXT_PUBLIC_KOMGA_URL || "http://localhost:8080",
};

View File

@@ -0,0 +1,75 @@
import { AuthError } from "@/types/auth";
import { storageService } from "./storage.service";
import { KomgaUser } from "@/types/komga";
interface AuthUser {
id: string;
email: string;
roles: string[];
authenticated: boolean;
}
// Utilisateur de développement
const DEV_USER = {
email: "demo@paniels.local",
password: "demo123",
userData: {
id: "1",
email: "demo@paniels.local",
roles: ["ROLE_USER"],
authenticated: true,
} as AuthUser,
};
class AuthService {
private static instance: AuthService;
private constructor() {}
public static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
/**
* Authentifie un utilisateur
*/
async login(email: string, password: string, remember: boolean = false): Promise<void> {
// En développement, on vérifie juste l'utilisateur de démo
if (email === DEV_USER.email && password === DEV_USER.password) {
storageService.setUserData(DEV_USER.userData, remember);
return;
}
throw {
code: "INVALID_CREDENTIALS",
message: "Email ou mot de passe incorrect",
} as AuthError;
}
/**
* Déconnecte l'utilisateur
*/
logout(): void {
storageService.clear();
}
/**
* Vérifie si l'utilisateur est connecté
*/
isAuthenticated(): boolean {
const user = storageService.getUserData<AuthUser>();
return !!user?.authenticated;
}
/**
* Récupère l'utilisateur connecté
*/
getCurrentUser(): AuthUser | null {
return storageService.getUserData<AuthUser>();
}
}
export const authService = AuthService.getInstance();

View File

@@ -0,0 +1,115 @@
class CacheService {
private static instance: CacheService;
private cacheName = "komga-cache-v1";
private defaultTTL = 5 * 60; // 5 minutes en secondes
private constructor() {}
public static getInstance(): CacheService {
if (!CacheService.instance) {
CacheService.instance = new CacheService();
}
return CacheService.instance;
}
/**
* Met en cache une réponse avec une durée de vie
*/
async set(key: string, response: Response, ttl: number = this.defaultTTL): Promise<void> {
if (typeof window === "undefined") return;
try {
const cache = await caches.open(this.cacheName);
const headers = new Headers(response.headers);
headers.append("x-cache-timestamp", Date.now().toString());
headers.append("x-cache-ttl", ttl.toString());
const cachedResponse = new Response(await response.clone().blob(), {
status: response.status,
statusText: response.statusText,
headers,
});
await cache.put(key, cachedResponse);
} catch (error) {
console.error("Erreur lors de la mise en cache:", error);
}
}
/**
* Récupère une réponse du cache si elle est valide
*/
async get(key: string): Promise<Response | null> {
if (typeof window === "undefined") return null;
try {
const cache = await caches.open(this.cacheName);
const response = await cache.match(key);
if (!response) return null;
// Vérifier si la réponse est expirée
const timestamp = parseInt(response.headers.get("x-cache-timestamp") || "0");
const ttl = parseInt(response.headers.get("x-cache-ttl") || "0");
const now = Date.now();
if (now - timestamp > ttl * 1000) {
await cache.delete(key);
return null;
}
return response;
} catch (error) {
console.error("Erreur lors de la lecture du cache:", error);
return null;
}
}
/**
* Supprime une entrée du cache
*/
async delete(key: string): Promise<void> {
if (typeof window === "undefined") return;
try {
const cache = await caches.open(this.cacheName);
await cache.delete(key);
} catch (error) {
console.error("Erreur lors de la suppression du cache:", error);
}
}
/**
* Vide le cache
*/
async clear(): Promise<void> {
if (typeof window === "undefined") return;
try {
await caches.delete(this.cacheName);
} catch (error) {
console.error("Erreur lors du nettoyage du cache:", error);
}
}
/**
* Récupère une réponse du cache ou fait l'appel API si nécessaire
*/
async getOrFetch(
key: string,
fetcher: () => Promise<Response>,
ttl: number = this.defaultTTL
): Promise<Response> {
const cachedResponse = await this.get(key);
if (cachedResponse) {
return cachedResponse;
}
const response = await fetcher();
const clonedResponse = response.clone();
await this.set(key, clonedResponse, ttl);
return response;
}
}
export const cacheService = CacheService.getInstance();

View File

@@ -0,0 +1,153 @@
import { KomgaUser, KomgaLibrary, KomgaSeries, KomgaBook, ReadProgress } from "@/types/komga";
import { AuthConfig } from "@/types/auth";
import { storageService } from "./storage.service";
class KomgaService {
private static instance: KomgaService;
private constructor() {}
public static getInstance(): KomgaService {
if (!KomgaService.instance) {
KomgaService.instance = new KomgaService();
}
return KomgaService.instance;
}
/**
* Crée les headers d'authentification
*/
private getAuthHeaders(config?: AuthConfig): Headers {
const headers = new Headers();
const credentials = config || storageService.getCredentials();
if (credentials?.credentials) {
const { username, password } = credentials.credentials;
headers.set("Authorization", `Basic ${btoa(`${username}:${password}`)}`);
}
return headers;
}
/**
* Vérifie les credentials en récupérant l'utilisateur courant
*/
async checkCredentials(config: AuthConfig): Promise<KomgaUser> {
const response = await fetch(`${config.serverUrl}/api/v1/libraries`, {
headers: this.getAuthHeaders(config),
});
if (!response.ok) {
throw new Error("Invalid credentials");
}
return response.json();
}
/**
* Récupère les bibliothèques
*/
async getLibraries(): Promise<KomgaLibrary[]> {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
const response = await fetch(`${credentials.serverUrl}/api/v1/libraries`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch libraries");
}
return response.json();
}
/**
* Récupère l'URL de la couverture d'une bibliothèque
*/
getLibraryThumbnailUrl(libraryId: string): string {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
return `${credentials.serverUrl}/api/v1/libraries/${libraryId}/thumbnail`;
}
/**
* Récupère les séries d'une bibliothèque
*/
async getLibrarySeries(libraryId: string): Promise<KomgaSeries[]> {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
const response = await fetch(`${credentials.serverUrl}/api/v1/libraries/${libraryId}/series`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch series");
}
return response.json();
}
/**
* Récupère les livres d'une série
*/
async getSeriesBooks(seriesId: string): Promise<KomgaBook[]> {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
const response = await fetch(`${credentials.serverUrl}/api/v1/series/${seriesId}/books`, {
headers: this.getAuthHeaders(),
});
if (!response.ok) {
throw new Error("Failed to fetch books");
}
return response.json();
}
/**
* Récupère l'URL de la couverture d'un livre
*/
getBookThumbnailUrl(bookId: string): string {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
return `${credentials.serverUrl}/api/v1/books/${bookId}/thumbnail`;
}
/**
* Récupère l'URL de lecture d'un livre
*/
getBookReadingUrl(bookId: string): string {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
return `${credentials.serverUrl}/api/v1/books/${bookId}/pages/1`;
}
/**
* Récupère la progression de lecture d'une série
*/
async getSeriesReadProgress(seriesId: string): Promise<ReadProgress> {
const credentials = storageService.getCredentials();
if (!credentials) throw new Error("Not authenticated");
const response = await fetch(
`${credentials.serverUrl}/api/v1/series/${seriesId}/read-progress`,
{
headers: this.getAuthHeaders(),
}
);
if (!response.ok) {
throw new Error("Failed to fetch series read progress");
}
return response.json();
}
}
export const komgaService = KomgaService.getInstance();

View File

@@ -0,0 +1,83 @@
type CacheEntry = {
data: any;
timestamp: number;
ttl: number;
};
class ServerCacheService {
private static instance: ServerCacheService;
private cache: Map<string, CacheEntry>;
private defaultTTL = 5 * 60; // 5 minutes en secondes
private constructor() {
this.cache = new Map();
}
public static getInstance(): ServerCacheService {
if (!ServerCacheService.instance) {
ServerCacheService.instance = new ServerCacheService();
}
return ServerCacheService.instance;
}
/**
* Met en cache des données avec une durée de vie
*/
set(key: string, data: any, ttl: number = this.defaultTTL): void {
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl,
});
}
/**
* Récupère des données du cache si elles sont valides
*/
get(key: string): any | null {
const entry = this.cache.get(key);
if (!entry) return null;
const now = Date.now();
if (now - entry.timestamp > entry.ttl * 1000) {
this.cache.delete(key);
return null;
}
return entry.data;
}
/**
* Supprime une entrée du cache
*/
delete(key: string): void {
this.cache.delete(key);
}
/**
* Vide le cache
*/
clear(): void {
this.cache.clear();
}
/**
* Récupère des données du cache ou exécute la fonction si nécessaire
*/
async getOrSet<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = this.defaultTTL
): Promise<T> {
const cachedData = this.get(key);
if (cachedData) {
return cachedData;
}
const data = await fetcher();
this.set(key, data, ttl);
return data;
}
}
export const serverCacheService = ServerCacheService.getInstance();

View File

@@ -0,0 +1,116 @@
import { AuthConfig } from "@/types/auth";
const CREDENTIALS_KEY = "komgaCredentials";
const USER_KEY = "komgaUser";
class StorageService {
private static instance: StorageService;
private constructor() {}
public static getInstance(): StorageService {
if (!StorageService.instance) {
StorageService.instance = new StorageService();
}
return StorageService.instance;
}
/**
* Stocke les credentials de manière sécurisée
*/
setCredentials(config: AuthConfig, remember: boolean = false): void {
const storage = remember ? localStorage : sessionStorage;
// Encodage basique des credentials en base64
const encoded = btoa(JSON.stringify(config));
console.log("StorageService - Stockage des credentials:", {
storage: remember ? "localStorage" : "sessionStorage",
config: {
serverUrl: config.serverUrl,
hasCredentials: !!config.credentials,
},
});
storage.setItem(CREDENTIALS_KEY, encoded);
// Définir aussi un cookie pour le middleware
const cookieValue = `${CREDENTIALS_KEY}=${encoded}; path=/; samesite=strict`;
const maxAge = remember ? `; max-age=${30 * 24 * 60 * 60}` : "";
document.cookie = cookieValue + maxAge;
console.log("StorageService - Cookie défini:", cookieValue + maxAge);
}
/**
* Récupère les credentials stockés
*/
getCredentials(): AuthConfig | null {
if (typeof window === "undefined") return null;
const storage =
localStorage.getItem(CREDENTIALS_KEY) || sessionStorage.getItem(CREDENTIALS_KEY);
console.log("StorageService - Lecture des credentials:", {
fromLocalStorage: !!localStorage.getItem(CREDENTIALS_KEY),
fromSessionStorage: !!sessionStorage.getItem(CREDENTIALS_KEY),
value: storage,
});
if (!storage) return null;
try {
const config = JSON.parse(atob(storage));
console.log("StorageService - Credentials décodés:", {
serverUrl: config.serverUrl,
hasCredentials: !!config.credentials,
});
return config;
} catch (error) {
console.error("StorageService - Erreur de décodage des credentials:", error);
return null;
}
}
/**
* Stocke les données utilisateur
*/
setUserData<T>(data: T, remember: boolean = false): void {
const storage = remember ? localStorage : sessionStorage;
const encoded = btoa(JSON.stringify(data));
storage.setItem(USER_KEY, encoded);
// Définir aussi un cookie pour le middleware
document.cookie = `${USER_KEY}=${encoded}; path=/; samesite=strict; ${
remember ? `max-age=${30 * 24 * 60 * 60}` : ""
}`;
}
/**
* Récupère les données utilisateur
*/
getUserData<T>(): T | null {
if (typeof window === "undefined") return null;
const storage = localStorage.getItem(USER_KEY) || sessionStorage.getItem(USER_KEY);
if (!storage) return null;
try {
return JSON.parse(atob(storage));
} catch {
return null;
}
}
/**
* Efface toutes les données stockées
*/
clear(): void {
localStorage.removeItem(CREDENTIALS_KEY);
localStorage.removeItem(USER_KEY);
sessionStorage.removeItem(CREDENTIALS_KEY);
sessionStorage.removeItem(USER_KEY);
document.cookie = `${CREDENTIALS_KEY}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
document.cookie = `${USER_KEY}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
}
}
export const storageService = StorageService.getInstance();

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}