chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)
This commit is contained in:
107
apps/web/src/pages/AdminPage.tsx
Normal file
107
apps/web/src/pages/AdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/pages/BookPage.tsx
Normal file
53
apps/web/src/pages/BookPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
apps/web/src/pages/HomePage.tsx
Normal file
87
apps/web/src/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/pages/LibraryPage.tsx
Normal file
50
apps/web/src/pages/LibraryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/pages/LoginPage.tsx
Normal file
55
apps/web/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/pages/ProfilePage.tsx
Normal file
28
apps/web/src/pages/ProfilePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/pages/ReaderPage.tsx
Normal file
57
apps/web/src/pages/ReaderPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/pages/SearchPage.tsx
Normal file
48
apps/web/src/pages/SearchPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/pages/SetupPage.tsx
Normal file
55
apps/web/src/pages/SetupPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user