chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)

This commit is contained in:
Git Agent
2026-08-23 09:56:53 +02:00
commit 8f1140127f
79 changed files with 6456 additions and 0 deletions

View File

@ -0,0 +1,107 @@
import { FormEvent, useEffect, useState } from "react";
import { Play, Plus } from "lucide-react";
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
import { api } from "../api/client";
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
export function AdminPage() {
const [libraries, setLibraries] = useState<LibraryDto[] | null>(null);
const [jobs, setJobs] = useState<JobDto[]>([]);
const [users, setUsers] = useState<UserDto[]>([]);
const [name, setName] = useState("Bibliotheque locale");
const [path, setPath] = useState("/library");
const [error, setError] = useState<string>();
async function refresh() {
const [nextLibraries, nextJobs, nextUsers] = await Promise.all([api.libraries(), api.jobs(), api.users()]);
setLibraries(nextLibraries);
setJobs(nextJobs);
setUsers(nextUsers);
}
useEffect(() => {
refresh().catch((refreshError) => setError(refreshError instanceof Error ? refreshError.message : "Administration indisponible"));
}, []);
async function createLibrary(event: FormEvent) {
event.preventDefault();
setError(undefined);
try {
await api.createLibrary({ name, path, enabled: true });
await refresh();
} catch (createError) {
setError(createError instanceof Error ? createError.message : "Creation impossible");
}
}
async function scan(id: number) {
setError(undefined);
try {
await api.scanLibrary(id);
await refresh();
} catch (scanError) {
setError(scanError instanceof Error ? scanError.message : "Scan impossible");
}
}
if (!libraries) return <LoadingState />;
return (
<div className="page-grid">
<Panel className="span-2">
<div className="section-heading">
<h1>Administration</h1>
<span>{users.length} comptes</span>
</div>
<ErrorRibbon message={error} />
<form className="admin-form" onSubmit={createLibrary}>
<label>
Nom du rayon
<input value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Chemin serveur
<input value={path} onChange={(event) => setPath(event.target.value)} required />
</label>
<button className="primary-button" type="submit">
<Plus size={17} />
Ajouter
</button>
</form>
</Panel>
<Panel>
<div className="section-heading">
<h2>Travaux</h2>
<span>{jobs.length}</span>
</div>
<div className="job-list">
{jobs.map((job) => (
<div key={job.id}>
<strong>{job.type}</strong>
<span>{job.status}</span>
</div>
))}
</div>
</Panel>
<Panel className="span-3">
<div className="library-table">
{libraries.map((library) => (
<div key={library.id}>
<div>
<strong>{library.name}</strong>
<span>{library.path}</span>
</div>
<span>{library.enabled ? "actif" : "pause"}</span>
<button className="ghost-button" onClick={() => scan(library.id)}>
<Play size={16} />
Scanner
</button>
</div>
))}
</div>
</Panel>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import { BookOpen, LibraryBig } from "lucide-react";
import type { BookDto, ProgressDto } from "@readabook/shared";
import { api } from "../api/client";
import { FormatPill, LoadingState, Meter, Panel } from "../components/ui";
import { navigate } from "../router";
export function BookPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null);
const [progress, setProgress] = useState<ProgressDto | null>(null);
useEffect(() => {
let alive = true;
Promise.all([api.book(bookId), api.progress(bookId)]).then(([nextBook, nextProgress]) => {
if (!alive) return;
setBook(nextBook);
setProgress(nextProgress);
});
return () => {
alive = false;
};
}, [bookId]);
if (!book) return <LoadingState />;
return (
<div className="book-detail">
<section className="book-portrait">
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={72} />}
</section>
<Panel className="book-facts">
<div className="book-card-meta">
<FormatPill format={book.format} />
<span>{book.language ?? "langue inconnue"}</span>
</div>
<h1>{book.title}</h1>
<p className="lead">{book.author ?? "Auteur inconnu"}</p>
<p>{book.description ?? "Notice absente du catalogue."}</p>
{progress && <Meter value={progress.percent} />}
<div className="book-card-actions">
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
<BookOpen size={18} />
Lire
</button>
<button className="ghost-button" onClick={() => navigate(`/library/${book.libraryId}`)}>
<LibraryBig size={18} />
Rayon
</button>
</div>
</Panel>
</div>
);
}

View File

@ -0,0 +1,87 @@
import { useEffect, useState } from "react";
import { LibraryBig, ScanLine } from "lucide-react";
import { api } from "../api/client";
import type { DashboardData } from "../api/types";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Meter, Panel } from "../components/ui";
import { navigate } from "../router";
export function HomePage() {
const [state, setState] = useState<DashboardData | null>(null);
const [fallback, setFallback] = useState(false);
useEffect(() => {
let alive = true;
Promise.all([api.books(), api.continueReading(), api.libraries(), api.jobs()])
.then(([books, continueReading, libraries, jobs]) => {
if (!alive) return;
setFallback(books.some((book) => book.filePath.startsWith("/library/")) && jobs.length === 1);
setState({ books, continueReading, libraries, jobs });
})
.catch(() => {
if (alive) setState({ books: [], continueReading: [], libraries: [], jobs: [] });
});
return () => {
alive = false;
};
}, []);
if (!state) return <LoadingState />;
return (
<div className="page-grid">
<section className="hero-band">
<div>
<p>Cabinet de curiosites numerique</p>
<h1>Ouvrir, classer, reprendre.</h1>
<span>{fallback ? "API absente ou incomplete : specimens de demonstration actifs." : "Catalogue branche sur le serveur local."}</span>
</div>
<button className="primary-button" onClick={() => navigate("/search")}>
<ScanLine size={18} />
Explorer
</button>
</section>
<Panel className="span-2">
<div className="section-heading">
<h2>Reprise de lecture</h2>
<span>{state.continueReading.length} traces</span>
</div>
{state.continueReading.length ? (
<div className="continue-grid">
{state.continueReading.map((item) => (
<button key={item.book.id} className="continue-tile" onClick={() => navigate(`/reader/${item.book.id}`)}>
<strong>{item.book.title}</strong>
<span>{item.book.author ?? "Auteur inconnu"}</span>
<Meter value={item.progress.percent} />
</button>
))}
</div>
) : (
<EmptyState title="Aucune trace" detail="Les lectures reprises apparaitront ici." />
)}
</Panel>
<Panel>
<div className="section-heading">
<h2>Bibliotheques</h2>
<LibraryBig size={20} />
</div>
<div className="library-list">
{state.libraries.map((library) => (
<button key={library.id} onClick={() => navigate(`/library/${library.id}`)}>
<strong>{library.name}</strong>
<span>{library.path}</span>
</button>
))}
</div>
</Panel>
<section className="book-grid span-3">
{state.books.map((book) => (
<BookCard key={book.id} book={book} />
))}
</section>
</div>
);
}

View File

@ -0,0 +1,50 @@
import { useEffect, useState } from "react";
import type { BookDto, LibraryDto } from "@readabook/shared";
import { api } from "../api/client";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui";
export function LibraryPage({ libraryId }: { libraryId: number }) {
const [books, setBooks] = useState<BookDto[] | null>(null);
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
useEffect(() => {
let alive = true;
Promise.all([api.books({ libraryId }), api.libraries()]).then(([nextBooks, nextLibraries]) => {
if (!alive) return;
setBooks(nextBooks);
setLibraries(nextLibraries);
});
return () => {
alive = false;
};
}, [libraryId]);
if (!books) return <LoadingState />;
const library = libraries.find((item) => item.id === libraryId);
return (
<div className="page-grid">
<Panel className="span-3">
<div className="section-heading">
<div>
<h1>{library?.name ?? "Bibliotheque"}</h1>
<p>{library?.path ?? "Rayonnage non identifie"}</p>
</div>
<span>{books.length} ouvrages</span>
</div>
</Panel>
{books.length ? (
<section className="book-grid span-3">
{books.map((book) => (
<BookCard key={book.id} book={book} />
))}
</section>
) : (
<Panel className="span-3">
<EmptyState title="Rayon vide" detail="Lance un scan depuis l'administration." />
</Panel>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { FormEvent, useState } from "react";
import { KeyRound, LogIn } from "lucide-react";
import { api } from "../api/client";
import { navigate } from "../router";
import { ErrorRibbon, Panel } from "../components/ui";
export function LoginPage({ onSessionChange }: { onSessionChange: () => Promise<void> }) {
const [email, setEmail] = useState("admin@readabook.local");
const [password, setPassword] = useState("");
const [error, setError] = useState<string>();
async function submit(event: FormEvent) {
event.preventDefault();
setError(undefined);
try {
await api.login({ email, password });
await onSessionChange();
navigate("/home");
} catch (loginError) {
setError(loginError instanceof Error ? loginError.message : "Connexion impossible");
}
}
return (
<div className="auth-surface">
<section className="auth-hero">
<p>Cabinet de curiosites numerique</p>
<h1>ReadaBook</h1>
<span>Bibliotheques EPUB et PDF, rangees comme des specimens vivants.</span>
</section>
<Panel className="auth-panel">
<KeyRound size={24} />
<h2>Entrer dans le cabinet</h2>
<ErrorRibbon message={error} />
<form onSubmit={submit} className="stack-form">
<label>
Email
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
</label>
<label>
Mot de passe
<input value={password} onChange={(event) => setPassword(event.target.value)} type="password" required />
</label>
<button className="primary-button" type="submit">
<LogIn size={17} />
Se connecter
</button>
</form>
<button className="ghost-button full-width" onClick={() => navigate("/setup/admin")}>
Initialiser le premier admin
</button>
</Panel>
</div>
);
}

View File

@ -0,0 +1,28 @@
import { LogOut, UserRound } from "lucide-react";
import type { Session } from "../api/types";
import { api } from "../api/client";
import { Panel } from "../components/ui";
import { navigate } from "../router";
export function ProfilePage({ session, onSessionChange }: { session: Session; onSessionChange: () => Promise<void> }) {
async function logout() {
await api.logout();
await onSessionChange();
navigate("/login");
}
return (
<div className="page-grid">
<Panel className="span-2 profile-panel">
<UserRound size={28} />
<h1>{session.user?.name ?? "Lecteur invite"}</h1>
<p>{session.user?.email ?? "Session non connectee"}</p>
<span>{session.user?.role ?? "vitrine"}</span>
<button className="ghost-button" onClick={logout}>
<LogOut size={17} />
Sortir
</button>
</Panel>
</div>
);
}

View File

@ -0,0 +1,57 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, Save } from "lucide-react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { LoadingState, Meter } from "../components/ui";
import { navigate } from "../router";
import { EpubReader } from "../reader/EpubReader";
import { PdfReader } from "../reader/PdfReader";
import { useReaderProgress } from "../reader/useReaderProgress";
export function ReaderPage({ bookId }: { bookId: number }) {
const [book, setBook] = useState<BookDto | null>(null);
const [page, setPage] = useState(1);
const { progress, saving, save } = useReaderProgress(bookId);
useEffect(() => {
api.book(bookId).then(setBook);
}, [bookId]);
useEffect(() => {
if (progress?.locator.startsWith("pdf:page:")) setPage(Number(progress.locator.split(":").at(-1)) || 1);
}, [progress]);
const fileUrl = useMemo(() => api.bookFileUrl(bookId), [bookId]);
const savePdfPage = useCallback(
(nextPage: number, pages: number) => {
setPage(nextPage);
void save(`pdf:page:${nextPage}`, Math.round((nextPage / pages) * 100));
},
[save]
);
const saveEpubLocator = useCallback((locator: string, percent: number) => void save(locator, percent), [save]);
if (!book) return <LoadingState label="Ouverture du lecteur" />;
return (
<div className="reader-page">
<header className="reader-topbar">
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
<ArrowLeft size={17} />
Fiche
</button>
<div>
<strong>{book.title}</strong>
<span>{saving ? "Sauvegarde" : "Progression synchronisee"}</span>
</div>
<Save size={18} />
</header>
<Meter value={progress?.percent ?? 0} />
{book.format === "pdf" ? (
<PdfReader url={fileUrl} page={page} onPageChange={savePdfPage} />
) : (
<EpubReader url={fileUrl} locator={progress?.locator} onLocatorChange={saveEpubLocator} />
)}
</div>
);
}

View File

@ -0,0 +1,48 @@
import { FormEvent, useEffect, useState } from "react";
import { Search } from "lucide-react";
import type { BookDto } from "@readabook/shared";
import { api } from "../api/client";
import { BookCard } from "../components/BookCard";
import { EmptyState, LoadingState, Panel } from "../components/ui";
export function SearchPage() {
const [query, setQuery] = useState("");
const [books, setBooks] = useState<BookDto[] | null>(null);
useEffect(() => {
api.books().then(setBooks);
}, []);
async function submit(event: FormEvent) {
event.preventDefault();
setBooks(null);
setBooks(query.trim() ? await api.search(query.trim()) : await api.books());
}
return (
<div className="page-grid">
<Panel className="span-3">
<form className="search-form" onSubmit={submit}>
<Search size={20} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Titre, auteur, ISBN" />
<button className="primary-button" type="submit">
Chercher
</button>
</form>
</Panel>
{!books ? (
<LoadingState />
) : books.length ? (
<section className="book-grid span-3">
{books.map((book) => (
<BookCard key={book.id} book={book} />
))}
</section>
) : (
<Panel className="span-3">
<EmptyState title="Aucun specimen" detail="Essaie un autre terme ou relance l'indexation." />
</Panel>
)}
</div>
);
}

View File

@ -0,0 +1,55 @@
import { FormEvent, useState } from "react";
import { Sparkles } from "lucide-react";
import { api } from "../api/client";
import { navigate } from "../router";
import { ErrorRibbon, Panel } from "../components/ui";
export function SetupPage() {
const [email, setEmail] = useState("admin@readabook.local");
const [name, setName] = useState("Conservateur");
const [password, setPassword] = useState("");
const [error, setError] = useState<string>();
async function submit(event: FormEvent) {
event.preventDefault();
setError(undefined);
try {
await api.bootstrap({ email, name, password });
navigate("/login");
} catch (setupError) {
setError(setupError instanceof Error ? setupError.message : "Initialisation impossible");
}
}
return (
<div className="auth-surface">
<section className="auth-hero">
<p>Premiere cle</p>
<h1>Installer le cabinet</h1>
<span>Un administrateur, puis les rayonnages.</span>
</section>
<Panel className="auth-panel">
<Sparkles size={24} />
<h2>Premier administrateur</h2>
<ErrorRibbon message={error} />
<form onSubmit={submit} className="stack-form">
<label>
Nom
<input value={name} onChange={(event) => setName(event.target.value)} required />
</label>
<label>
Email
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
</label>
<label>
Mot de passe
<input value={password} onChange={(event) => setPassword(event.target.value)} type="password" minLength={8} required />
</label>
<button className="primary-button" type="submit">
Creer la cle
</button>
</form>
</Panel>
</div>
);
}