feat: Initial commit - Base application with Next.js - Configuration, Auth, Library navigation, CBZ/CBR reader, Cache, Responsive design
This commit is contained in:
3
src/lib/config.ts
Normal file
3
src/lib/config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const config = {
|
||||
serverUrl: process.env.NEXT_PUBLIC_KOMGA_URL || "http://localhost:8080",
|
||||
};
|
||||
75
src/lib/services/auth.service.ts
Normal file
75
src/lib/services/auth.service.ts
Normal 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();
|
||||
115
src/lib/services/cache.service.ts
Normal file
115
src/lib/services/cache.service.ts
Normal 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();
|
||||
153
src/lib/services/komga.service.ts
Normal file
153
src/lib/services/komga.service.ts
Normal 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();
|
||||
83
src/lib/services/server-cache.service.ts
Normal file
83
src/lib/services/server-cache.service.ts
Normal 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();
|
||||
116
src/lib/services/storage.service.ts
Normal file
116
src/lib/services/storage.service.ts
Normal 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
6
src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user