chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)
This commit is contained in:
23
apps/web/Dockerfile
Normal file
23
apps/web/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
FROM node:22-bookworm-slim AS base
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS build
|
||||
COPY packages/shared packages/shared
|
||||
COPY apps/web apps/web
|
||||
RUN pnpm --filter @readabook/shared build
|
||||
RUN pnpm --filter @readabook/web build
|
||||
|
||||
FROM nginx:1.27-alpine AS runtime
|
||||
COPY apps/web/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
15
apps/web/index.html
Normal file
15
apps/web/index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#20150e" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icons/readabook.svg" type="image/svg+xml" />
|
||||
<title>ReadaBook</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
40
apps/web/nginx/default.conf
Normal file
40
apps/web/nginx/default.conf
Normal file
@ -0,0 +1,40 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /auth/ {
|
||||
proxy_pass http://api:3000/auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://api:3000/admin/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /books/ {
|
||||
proxy_pass http://api:3000/books/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /progress/ {
|
||||
proxy_pass http://api:3000/progress/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /healthz {
|
||||
proxy_pass http://api:3000/healthz;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
30
apps/web/package.json
Normal file
30
apps/web/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@readabook/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"lint": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@readabook/shared": "workspace:*",
|
||||
"@vitejs/plugin-react": "^6.1.0",
|
||||
"foliate-js": "^1.0.1",
|
||||
"lucide-react": "^1.33.0",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
7
apps/web/public/icons/readabook.svg
Normal file
7
apps/web/public/icons/readabook.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="ReadaBook">
|
||||
<rect width="512" height="512" rx="96" fill="#20150e"/>
|
||||
<path d="M96 112h128c31 0 56 25 56 56v232c0 9-10 14-17 8-15-13-34-20-54-20H96z" fill="#e8d2a6"/>
|
||||
<path d="M416 112H288c-31 0-56 25-56 56v232c0 9 10 14 17 8 15-13 34-20 54-20h113z" fill="#c05a3a"/>
|
||||
<circle cx="256" cy="220" r="46" fill="#29524a"/>
|
||||
<path d="M256 154l14 42 45 1-36 27 13 43-36-25-36 25 13-43-36-27 45-1z" fill="#f5c84b"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 506 B |
18
apps/web/public/manifest.webmanifest
Normal file
18
apps/web/public/manifest.webmanifest
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "ReadaBook",
|
||||
"short_name": "ReadaBook",
|
||||
"description": "Cabinet de curiosites numerique pour bibliotheques EPUB et PDF.",
|
||||
"start_url": "/home",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#20150e",
|
||||
"theme_color": "#20150e",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/readabook.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
apps/web/public/sw.js
Normal file
22
apps/web/public/sw.js
Normal file
@ -0,0 +1,22 @@
|
||||
const CACHE_NAME = "readabook-shell-v1";
|
||||
const SHELL = ["/", "/home", "/manifest.webmanifest", "/icons/readabook.svg"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
if (event.request.method !== "GET" || ["/auth", "/admin", "/books", "/progress"].some((path) => url.pathname.startsWith(path))) {
|
||||
return;
|
||||
}
|
||||
event.respondWith(fetch(event.request).catch(() => caches.match(event.request).then((hit) => hit || caches.match("/"))));
|
||||
});
|
||||
59
apps/web/src/App.tsx
Normal file
59
apps/web/src/App.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Session } from "./api/types";
|
||||
import { api } from "./api/client";
|
||||
import { AppShell } from "./layout/AppShell";
|
||||
import { AdminPage } from "./pages/AdminPage";
|
||||
import { BookPage } from "./pages/BookPage";
|
||||
import { HomePage } from "./pages/HomePage";
|
||||
import { LibraryPage } from "./pages/LibraryPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { ProfilePage } from "./pages/ProfilePage";
|
||||
import { ReaderPage } from "./pages/ReaderPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { SetupPage } from "./pages/SetupPage";
|
||||
import { parseRoute, type Route } from "./router";
|
||||
|
||||
function renderRoute(route: Route, session: Session, refreshSession: () => Promise<void>) {
|
||||
if (route.name === "login") return <LoginPage onSessionChange={refreshSession} />;
|
||||
if (route.name === "setup") return <SetupPage />;
|
||||
|
||||
const content =
|
||||
route.name === "home" ? (
|
||||
<HomePage />
|
||||
) : route.name === "library" ? (
|
||||
<LibraryPage libraryId={route.libraryId} />
|
||||
) : route.name === "book" ? (
|
||||
<BookPage bookId={route.bookId} />
|
||||
) : route.name === "reader" ? (
|
||||
<ReaderPage bookId={route.bookId} />
|
||||
) : route.name === "search" ? (
|
||||
<SearchPage />
|
||||
) : route.name === "me" ? (
|
||||
<ProfilePage session={session} onSessionChange={refreshSession} />
|
||||
) : (
|
||||
<AdminPage />
|
||||
);
|
||||
|
||||
return <AppShell session={session}>{content}</AppShell>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [route, setRoute] = useState(parseRoute());
|
||||
const [session, setSession] = useState<Session>({ user: null, degraded: false });
|
||||
|
||||
async function refreshSession() {
|
||||
setSession(await api.session());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refreshSession();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => setRoute(parseRoute());
|
||||
window.addEventListener("popstate", listener);
|
||||
return () => window.removeEventListener("popstate", listener);
|
||||
}, []);
|
||||
|
||||
return renderRoute(route, session, refreshSession);
|
||||
}
|
||||
140
apps/web/src/api/client.ts
Normal file
140
apps/web/src/api/client.ts
Normal file
@ -0,0 +1,140 @@
|
||||
import type {
|
||||
BookDto,
|
||||
BookQueryDto,
|
||||
BootstrapAdminDto,
|
||||
CreateLibraryDto,
|
||||
JobDto,
|
||||
LibraryDto,
|
||||
LoginDto,
|
||||
ProgressDto,
|
||||
UpdateProgressDto,
|
||||
UserDto
|
||||
} from "@readabook/shared";
|
||||
import { mockBooks, mockContinue, mockJobs, mockLibraries, mockProgress, mockUser } from "./mockData";
|
||||
import type { ContinueItem, Session } from "./types";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
fallback?: unknown;
|
||||
};
|
||||
|
||||
export class ApiFallbackError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly fallback: unknown
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error) {
|
||||
if (options.fallback !== undefined) {
|
||||
throw new ApiFallbackError(error instanceof Error ? error.message : String(error), options.fallback);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function queryString(query: Partial<BookQueryDto>): string {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") params.set(key, String(value));
|
||||
});
|
||||
const value = params.toString();
|
||||
return value ? `?${value}` : "";
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async session(): Promise<Session> {
|
||||
try {
|
||||
const result = await request<{ user: UserDto }>("/auth/me");
|
||||
return { user: result.user, degraded: false };
|
||||
} catch {
|
||||
return { user: null, degraded: false };
|
||||
}
|
||||
},
|
||||
async bootstrap(input: BootstrapAdminDto): Promise<UserDto> {
|
||||
const result = await request<UserDto>("/auth/bootstrap", { method: "POST", body: JSON.stringify(input) });
|
||||
return result;
|
||||
},
|
||||
async login(input: LoginDto): Promise<UserDto> {
|
||||
const result = await request<{ user: UserDto }>("/auth/login", { method: "POST", body: JSON.stringify(input) });
|
||||
return result.user;
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
await request<{ ok: true }>("/auth/logout", { method: "POST" });
|
||||
},
|
||||
async books(query: Partial<BookQueryDto> = {}): Promise<BookDto[]> {
|
||||
return request<BookDto[]>(`/books${queryString({ limit: 50, offset: 0, ...query })}`, { fallback: mockBooks });
|
||||
},
|
||||
async search(query: string): Promise<BookDto[]> {
|
||||
return request<BookDto[]>(`/books/search${queryString({ q: query, limit: 50, offset: 0 })}`, { fallback: mockBooks });
|
||||
},
|
||||
async book(id: number): Promise<BookDto> {
|
||||
const fallback = mockBooks.find((book) => book.id === id) ?? mockBooks[0];
|
||||
return request<BookDto>(`/books/${id}`, { fallback });
|
||||
},
|
||||
bookFileUrl(id: number): string {
|
||||
return `${API_BASE}/books/${id}/file`;
|
||||
},
|
||||
bookCoverUrl(id: number): string {
|
||||
return `${API_BASE}/books/${id}/cover`;
|
||||
},
|
||||
async progress(bookId: number): Promise<ProgressDto | null> {
|
||||
try {
|
||||
return await request<ProgressDto>(`/progress/${bookId}`, {
|
||||
fallback: mockProgress.find((progress) => progress.bookId === bookId) ?? null
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiFallbackError) return error.fallback as ProgressDto | null;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async saveProgress(bookId: number, input: UpdateProgressDto): Promise<ProgressDto> {
|
||||
return request<ProgressDto>(`/progress/${bookId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
fallback: { bookId, ...input, updatedAt: new Date().toISOString() }
|
||||
});
|
||||
},
|
||||
async continueReading(): Promise<ContinueItem[]> {
|
||||
return request<ContinueItem[]>("/progress/continue", { fallback: mockContinue });
|
||||
},
|
||||
async libraries(): Promise<LibraryDto[]> {
|
||||
return request<LibraryDto[]>("/admin/libraries", { fallback: mockLibraries });
|
||||
},
|
||||
async createLibrary(input: CreateLibraryDto): Promise<LibraryDto> {
|
||||
return request<LibraryDto>("/admin/libraries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
fallback: { id: Date.now(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...input }
|
||||
});
|
||||
},
|
||||
async scanLibrary(id: number): Promise<JobDto> {
|
||||
return request<JobDto>(`/admin/libraries/${id}/scan`, { method: "POST", fallback: mockJobs[0] });
|
||||
},
|
||||
async jobs(): Promise<JobDto[]> {
|
||||
return request<JobDto[]>("/admin/jobs", { fallback: mockJobs });
|
||||
},
|
||||
async users(): Promise<UserDto[]> {
|
||||
return request<UserDto[]>("/admin/users", { fallback: [mockUser] });
|
||||
}
|
||||
};
|
||||
70
apps/web/src/api/mockData.ts
Normal file
70
apps/web/src/api/mockData.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared";
|
||||
import type { ContinueItem } from "./types";
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
export const mockUser: UserDto = {
|
||||
id: 1,
|
||||
email: "admin@readabook.local",
|
||||
name: "Conservateur",
|
||||
role: "admin",
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
export const mockLibraries: LibraryDto[] = [
|
||||
{ id: 1, name: "Reserve des EPUB", path: "/library/epub", enabled: true, createdAt: now, updatedAt: now },
|
||||
{ id: 2, name: "Atlas PDF", path: "/library/pdf", enabled: true, createdAt: now, updatedAt: now }
|
||||
];
|
||||
|
||||
export const mockBooks: BookDto[] = [
|
||||
{
|
||||
id: 1,
|
||||
libraryId: 1,
|
||||
title: "L'Herbier des machines",
|
||||
author: "M. Valrose",
|
||||
description: "Fragments, croquis et notes rassemblees autour d'automates introuvables.",
|
||||
isbn: null,
|
||||
language: "fr",
|
||||
publisher: "Cabinet ReadaBook",
|
||||
publishedDate: "1908",
|
||||
format: "epub",
|
||||
filePath: "/library/epub/herbier.epub",
|
||||
coverPath: null,
|
||||
fileSize: 4300000,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
libraryId: 2,
|
||||
title: "Cartographie des songes",
|
||||
author: "I. Nadir",
|
||||
description: "Un atlas annote ou chaque page devient une vitrine de lecture.",
|
||||
isbn: null,
|
||||
language: "fr",
|
||||
publisher: "ReadaBook",
|
||||
publishedDate: "1921",
|
||||
format: "pdf",
|
||||
filePath: "/library/pdf/cartographie.pdf",
|
||||
coverPath: null,
|
||||
fileSize: 9100000,
|
||||
fileMtime: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
];
|
||||
|
||||
export const mockProgress: ProgressDto[] = [
|
||||
{ bookId: 1, locator: "mock:chapter-3", percent: 42, updatedAt: now },
|
||||
{ bookId: 2, locator: "mock:page-12", percent: 18, updatedAt: now }
|
||||
];
|
||||
|
||||
export const mockContinue: ContinueItem[] = mockProgress.map((progress) => ({
|
||||
progress,
|
||||
book: mockBooks.find((book) => book.id === progress.bookId) ?? mockBooks[0]
|
||||
}));
|
||||
|
||||
export const mockJobs: JobDto[] = [
|
||||
{ id: 1, type: "scan-library", status: "succeeded", detail: "2 ouvrages indexes", error: null, createdAt: now, updatedAt: now }
|
||||
];
|
||||
23
apps/web/src/api/types.ts
Normal file
23
apps/web/src/api/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import type { BookDto, JobDto, LibraryDto, ProgressDto, UserDto } from "@readabook/shared";
|
||||
|
||||
export type ApiState<T> =
|
||||
| { status: "loading"; data?: T; error?: undefined; fallback?: false }
|
||||
| { status: "ready"; data: T; error?: undefined; fallback?: boolean }
|
||||
| { status: "error"; data: T; error: string; fallback: true };
|
||||
|
||||
export type Session = {
|
||||
user: UserDto | null;
|
||||
degraded: boolean;
|
||||
};
|
||||
|
||||
export type ContinueItem = {
|
||||
book: BookDto;
|
||||
progress: ProgressDto;
|
||||
};
|
||||
|
||||
export type DashboardData = {
|
||||
books: BookDto[];
|
||||
continueReading: ContinueItem[];
|
||||
libraries: LibraryDto[];
|
||||
jobs: JobDto[];
|
||||
};
|
||||
34
apps/web/src/components/BookCard.tsx
Normal file
34
apps/web/src/components/BookCard.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { BookOpen, Eye } from "lucide-react";
|
||||
import type { BookDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
import { navigate } from "../router";
|
||||
import { FormatPill } from "./ui";
|
||||
|
||||
export function BookCard({ book, compact = false }: { book: BookDto; compact?: boolean }) {
|
||||
return (
|
||||
<article className={`book-card ${compact ? "book-card-compact" : ""}`}>
|
||||
<button className="cover-button" onClick={() => navigate(`/book/${book.id}`)} aria-label={`Ouvrir ${book.title}`}>
|
||||
{book.coverPath ? <img src={api.bookCoverUrl(book.id)} alt="" /> : <BookOpen size={34} />}
|
||||
</button>
|
||||
<div className="book-card-body">
|
||||
<div className="book-card-meta">
|
||||
<FormatPill format={book.format} />
|
||||
<span>{book.language ?? "langue inconnue"}</span>
|
||||
</div>
|
||||
<h3>{book.title}</h3>
|
||||
<p>{book.author ?? "Auteur inconnu"}</p>
|
||||
{!compact && <p className="book-card-description">{book.description ?? "Notice absente du catalogue."}</p>}
|
||||
<div className="book-card-actions">
|
||||
<button className="ghost-button" onClick={() => navigate(`/book/${book.id}`)}>
|
||||
<Eye size={16} />
|
||||
Fiche
|
||||
</button>
|
||||
<button className="primary-button" onClick={() => navigate(`/reader/${book.id}`)}>
|
||||
<BookOpen size={16} />
|
||||
Lire
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/components/ui.tsx
Normal file
41
apps/web/src/components/ui.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Panel({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return <section className={`panel ${className}`}>{children}</section>;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, detail }: { title: string; detail: string }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<span className="specimen-mark">?</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label = "Inventaire en cours" }: { label?: string }) {
|
||||
return (
|
||||
<div className="loading-state">
|
||||
<span className="spinner" />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorRibbon({ message }: { message?: string }) {
|
||||
if (!message) return null;
|
||||
return <div className="error-ribbon">{message}</div>;
|
||||
}
|
||||
|
||||
export function FormatPill({ format }: { format: "epub" | "pdf" }) {
|
||||
return <span className={`format-pill format-${format}`}>{format.toUpperCase()}</span>;
|
||||
}
|
||||
|
||||
export function Meter({ value }: { value: number }) {
|
||||
return (
|
||||
<span className="meter" aria-label={`${Math.round(value)}%`}>
|
||||
<span style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/layout/AppShell.tsx
Normal file
37
apps/web/src/layout/AppShell.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { Archive, Home, Search, Settings, UserRound } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Session } from "../api/types";
|
||||
import { navigate } from "../router";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/home", label: "Accueil", icon: Home },
|
||||
{ href: "/search", label: "Recherche", icon: Search },
|
||||
{ href: "/admin/libraries", label: "Admin", icon: Settings },
|
||||
{ href: "/me", label: "Profil", icon: UserRound }
|
||||
];
|
||||
|
||||
export function AppShell({ children, session }: { children: ReactNode; session: Session }) {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="side-rail">
|
||||
<button className="brand-button" onClick={() => navigate("/home")} aria-label="ReadaBook">
|
||||
<Archive size={22} />
|
||||
<span>ReadaBook</span>
|
||||
</button>
|
||||
<nav>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button key={item.href} onClick={() => navigate(item.href)} title={item.label}>
|
||||
<Icon size={19} />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="session-chip">{session.user ? session.user.email : "Mode vitrine"}</div>
|
||||
</aside>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/web/src/main.tsx
Normal file
17
apps/web/src/main.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/reader/EpubReader.tsx
Normal file
37
apps/web/src/reader/EpubReader.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type FoliateModule = {
|
||||
EPUB?: unknown;
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
export function EpubReader({ url, locator, onLocatorChange }: { url: string; locator?: string; onLocatorChange: (locator: string, percent: number) => void }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState("Ouverture EPUB");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function mount() {
|
||||
try {
|
||||
const module = (await import("foliate-js/epub.js")) as FoliateModule;
|
||||
if (cancelled || !hostRef.current) return;
|
||||
hostRef.current.dataset.engine = module.EPUB || module.default ? "foliate-js" : "fallback";
|
||||
setStatus("EPUB pret");
|
||||
onLocatorChange(locator ?? "epub:start", locator ? 35 : 1);
|
||||
} catch {
|
||||
setStatus("Apercu EPUB indisponible dans ce navigateur");
|
||||
}
|
||||
}
|
||||
mount();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locator, onLocatorChange, url]);
|
||||
|
||||
return (
|
||||
<div className="epub-reader" ref={hostRef}>
|
||||
<iframe title="EPUB" src={url} />
|
||||
<div className="reader-fallback">{status}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
apps/web/src/reader/PdfReader.tsx
Normal file
56
apps/web/src/reader/PdfReader.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as pdfjs from "pdfjs-dist";
|
||||
import workerUrl from "pdfjs-dist/build/pdf.worker.mjs?url";
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
|
||||
export function PdfReader({ url, page, onPageChange }: { url: string; page: number; onPageChange: (page: number, pages: number) => void }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function render() {
|
||||
try {
|
||||
const loadingTask = pdfjs.getDocument({ url, withCredentials: true });
|
||||
const document = await loadingTask.promise;
|
||||
if (cancelled) return;
|
||||
setPages(document.numPages);
|
||||
const pdfPage = await document.getPage(Math.max(1, Math.min(page, document.numPages)));
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const viewport = pdfPage.getViewport({ scale: Math.min(1.6, window.devicePixelRatio || 1) });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
await pdfPage.render({ canvas, canvasContext: context, viewport }).promise;
|
||||
onPageChange(Math.max(1, Math.min(page, document.numPages)), document.numPages);
|
||||
} catch (renderError) {
|
||||
setError(renderError instanceof Error ? renderError.message : "PDF indisponible");
|
||||
}
|
||||
}
|
||||
render();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url, page, onPageChange]);
|
||||
|
||||
return (
|
||||
<div className="pdf-reader">
|
||||
{error ? <div className="reader-fallback">{error}</div> : <canvas ref={canvasRef} />}
|
||||
<div className="reader-stepper">
|
||||
<button className="ghost-button" onClick={() => onPageChange(Math.max(1, page - 1), pages)}>
|
||||
Precedent
|
||||
</button>
|
||||
<span>
|
||||
{page} / {pages}
|
||||
</span>
|
||||
<button className="ghost-button" onClick={() => onPageChange(Math.min(pages, page + 1), pages)}>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/web/src/reader/useReaderProgress.ts
Normal file
27
apps/web/src/reader/useReaderProgress.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ProgressDto } from "@readabook/shared";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export function useReaderProgress(bookId: number) {
|
||||
const [progress, setProgress] = useState<ProgressDto | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.progress(bookId).then(setProgress);
|
||||
}, [bookId]);
|
||||
|
||||
const save = useCallback(
|
||||
async (locator: string, percent: number) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const next = await api.saveProgress(bookId, { locator, percent });
|
||||
setProgress(next);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[bookId]
|
||||
);
|
||||
|
||||
return { progress, saving, save };
|
||||
}
|
||||
28
apps/web/src/router.ts
Normal file
28
apps/web/src/router.ts
Normal file
@ -0,0 +1,28 @@
|
||||
export type Route =
|
||||
| { name: "login" }
|
||||
| { name: "setup"; step: string }
|
||||
| { name: "home" }
|
||||
| { name: "library"; libraryId: number }
|
||||
| { name: "book"; bookId: number }
|
||||
| { name: "reader"; bookId: number }
|
||||
| { name: "search" }
|
||||
| { name: "me" }
|
||||
| { name: "admin"; section: string };
|
||||
|
||||
export function parseRoute(pathname = window.location.pathname): Route {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
if (parts[0] === "login") return { name: "login" };
|
||||
if (parts[0] === "setup") return { name: "setup", step: parts[1] ?? "admin" };
|
||||
if (parts[0] === "library") return { name: "library", libraryId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "book") return { name: "book", bookId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "reader") return { name: "reader", bookId: Number(parts[1] ?? 0) };
|
||||
if (parts[0] === "search") return { name: "search" };
|
||||
if (parts[0] === "me") return { name: "me" };
|
||||
if (parts[0] === "admin") return { name: "admin", section: parts[1] ?? "libraries" };
|
||||
return { name: "home" };
|
||||
}
|
||||
|
||||
export function navigate(to: string): void {
|
||||
window.history.pushState({}, "", to);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
512
apps/web/src/styles/app.css
Normal file
512
apps/web/src/styles/app.css
Normal file
@ -0,0 +1,512 @@
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 236px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.side-rail {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
height: 100vh;
|
||||
padding: 18px;
|
||||
border-right: 1px solid var(--line);
|
||||
background: rgba(23, 17, 13, 0.78);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand-button,
|
||||
.side-rail nav button,
|
||||
.ghost-button,
|
||||
.primary-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.brand-button {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
color: var(--brass);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.side-rail nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.side-rail nav button {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-chip {
|
||||
margin-top: auto;
|
||||
overflow: hidden;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.82rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
main {
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.span-3 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
background: rgba(38, 26, 18, 0.82);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-band {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
min-height: 270px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(120deg, rgba(32, 21, 14, 0.48), rgba(32, 21, 14, 0.92)),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='900' height='420' viewBox='0 0 900 420'%3E%3Crect width='900' height='420' fill='%2320150e'/%3E%3Cg fill='none' stroke='%23d5a84d' stroke-opacity='.28'%3E%3Cpath d='M68 326h764M82 286h736M116 120h668M134 84h632'/%3E%3Cpath d='M138 84v242M274 84v242M418 84v242M572 84v242M724 84v242'/%3E%3C/g%3E%3Cg fill='%23a94834' fill-opacity='.72'%3E%3Crect x='166' y='126' width='48' height='156'/%3E%3Crect x='304' y='102' width='34' height='184'/%3E%3Crect x='614' y='134' width='58' height='150'/%3E%3C/g%3E%3Cg fill='%232d6f63' fill-opacity='.72'%3E%3Ccircle cx='492' cy='194' r='48'/%3E%3Cpath d='M742 124l34 92h-68z'/%3E%3C/g%3E%3C/svg%3E") center / cover;
|
||||
}
|
||||
|
||||
.hero-band p,
|
||||
.auth-hero p {
|
||||
margin: 0 0 8px;
|
||||
color: var(--brass);
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 8px;
|
||||
font-size: clamp(2.1rem, 6vw, 5rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
border-color: rgba(213, 168, 77, 0.62);
|
||||
background: linear-gradient(180deg, #d5a84d, #a94834);
|
||||
color: #17110d;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ghost-button:hover,
|
||||
.side-rail nav button:hover,
|
||||
.brand-button:hover {
|
||||
border-color: rgba(213, 168, 77, 0.55);
|
||||
}
|
||||
|
||||
.book-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.book-card {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
min-height: 220px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(180deg, rgba(49, 34, 24, 0.94), rgba(23, 17, 13, 0.94));
|
||||
}
|
||||
|
||||
.cover-button,
|
||||
.book-portrait {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 156px;
|
||||
border: 1px solid rgba(213, 168, 77, 0.35);
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(213, 168, 77, 0.2), rgba(45, 111, 99, 0.22)),
|
||||
var(--paper-soft);
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.cover-button img,
|
||||
.book-portrait img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.book-card h3 {
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.book-card p,
|
||||
.book-facts p,
|
||||
.section-heading p {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.book-card-description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
.book-card-meta,
|
||||
.book-card-actions,
|
||||
.section-heading,
|
||||
.reader-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.book-card-actions,
|
||||
.section-heading {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.format-pill {
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
color: #17110d;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
background: var(--brass);
|
||||
}
|
||||
|
||||
.format-pdf {
|
||||
background: var(--lacquer);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.continue-grid,
|
||||
.library-list,
|
||||
.job-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.continue-tile,
|
||||
.library-list button,
|
||||
.job-list div,
|
||||
.library-table > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.library-table > div {
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meter {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
.meter span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--verdigris), var(--brass));
|
||||
}
|
||||
|
||||
.auth-surface {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 420px);
|
||||
gap: 24px;
|
||||
min-height: 100vh;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.auth-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 28px;
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 17, 13, 0.12), rgba(23, 17, 13, 0.9)),
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='720' height='960' viewBox='0 0 720 960'%3E%3Crect width='720' height='960' fill='%23261a12'/%3E%3Cpath d='M80 190h560v590H80z' fill='none' stroke='%23d5a84d' stroke-opacity='.38' stroke-width='6'/%3E%3Ccircle cx='360' cy='426' r='112' fill='%232d6f63' fill-opacity='.64'/%3E%3Cpath d='M210 676h300M244 728h232M180 250h360' stroke='%23e8d2a6' stroke-opacity='.36' stroke-width='10'/%3E%3C/svg%3E") center / cover;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.stack-form,
|
||||
.admin-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 12px;
|
||||
color: var(--ink);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.error-ribbon {
|
||||
margin: 10px 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(169, 72, 52, 0.72);
|
||||
border-radius: var(--radius);
|
||||
color: #ffd8cf;
|
||||
background: rgba(169, 72, 52, 0.18);
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.loading-state {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
min-height: 160px;
|
||||
color: var(--ink-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.specimen-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--brass);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.13);
|
||||
border-top-color: var(--brass);
|
||||
border-radius: 999px;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.book-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 360px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.book-portrait {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.reader-page {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 100vh;
|
||||
padding: 14px;
|
||||
background: #120e0b;
|
||||
}
|
||||
|
||||
.reader-topbar {
|
||||
justify-content: space-between;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(38, 26, 18, 0.84);
|
||||
}
|
||||
|
||||
.reader-topbar div {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.reader-topbar span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.pdf-reader,
|
||||
.epub-reader {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.pdf-reader canvas,
|
||||
.epub-reader iframe {
|
||||
max-width: min(100%, 980px);
|
||||
max-height: calc(100vh - 170px);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: #f7f0df;
|
||||
}
|
||||
|
||||
.epub-reader iframe {
|
||||
width: min(100%, 980px);
|
||||
height: calc(100vh - 170px);
|
||||
}
|
||||
|
||||
.reader-fallback {
|
||||
padding: 12px;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.reader-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-shell,
|
||||
.auth-surface,
|
||||
.book-detail {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side-rail {
|
||||
position: fixed;
|
||||
inset: auto 0 0;
|
||||
z-index: 10;
|
||||
flex-direction: row;
|
||||
height: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.side-rail nav {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.side-rail nav button span,
|
||||
.brand-button span,
|
||||
.session-chip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 16px 16px 86px;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.span-2,
|
||||
.span-3 {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.hero-band {
|
||||
min-height: 220px;
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.auth-surface {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.auth-hero {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.book-card {
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.library-table > div,
|
||||
.search-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
42
apps/web/src/styles/tokens.css
Normal file
42
apps/web/src/styles/tokens.css
Normal file
@ -0,0 +1,42 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--ink: #f3ead9;
|
||||
--ink-muted: #bfae94;
|
||||
--paper: #261a12;
|
||||
--paper-soft: #312218;
|
||||
--cabinet: #17110d;
|
||||
--brass: #d5a84d;
|
||||
--verdigris: #2d6f63;
|
||||
--lacquer: #a94834;
|
||||
--violet-glass: #514069;
|
||||
--line: rgba(243, 234, 217, 0.14);
|
||||
--shadow: 0 22px 70px rgba(0, 0, 0, 0.36);
|
||||
--radius: 8px;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--cabinet);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(213, 168, 77, 0.07) 1px, transparent 1px) 0 0 / 44px 44px,
|
||||
radial-gradient(circle at 20% 10%, rgba(45, 111, 99, 0.28), transparent 34%),
|
||||
linear-gradient(135deg, #17110d 0%, #251910 52%, #1b1518 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
7
apps/web/src/vite-env.d.ts
vendored
Normal file
7
apps/web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "foliate-js/epub.js" {
|
||||
const module: unknown;
|
||||
export default module;
|
||||
export const EPUB: unknown;
|
||||
}
|
||||
12
apps/web/tsconfig.json
Normal file
12
apps/web/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
16
apps/web/vite.config.ts
Normal file
16
apps/web/vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/auth": "http://127.0.0.1:3000",
|
||||
"/admin": "http://127.0.0.1:3000",
|
||||
"/books": "http://127.0.0.1:3000",
|
||||
"/progress": "http://127.0.0.1:3000",
|
||||
"/healthz": "http://127.0.0.1:3000"
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user