mise en place de l'application de base
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
# Ma librairie
|
||||||
|
|
||||||
|
Application React (Vite) : livres enregistrés localement (équivalent métier `POST /api/books`), React Router, `useContext`, Axios (Open Library).
|
||||||
|
|
||||||
|
## Les membres du projet
|
||||||
|
|
||||||
|
Marvin Aubert, Maxime Lebreton et Patrick Felix-Vimalaratnam
|
||||||
|
|
||||||
|
## Comment installer le projet
|
||||||
|
|
||||||
|
À la racine du dépôt :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Cette commande installe les dépendances listées dans `package.json` (React, Vite, React Router, Axios, etc.).
|
||||||
|
|
||||||
|
## Comment lancer le projet
|
||||||
|
|
||||||
|
**Mode développement** (rechargement à chaud, URL affichée dans le terminal) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Ouvre ensuite l’adresse indiquée (souvent `http://localhost:5173`).
|
||||||
|
|
||||||
|
**Prévisualiser le build de production** (après `npm run build`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comment tester le projet
|
||||||
|
|
||||||
|
**Vérifications automatisées (lint)** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build de production** (vérifie que le projet compile) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tests manuels dans le navigateur** (après `npm run dev`) :
|
||||||
|
|
||||||
|
1. **Mes livres** (`/`) : enregistrer un nouveau livre (libellés + `POST /api/books` côté UI), supprimer, filtrer (Tous / Lus / À lire), rechercher dans la liste.
|
||||||
|
2. **Fiche livre** : cliquer sur le titre d’un livre ou aller sur `/<id>` ; vérifier lu / non lu et suppression avec retour à **Mes livres**.
|
||||||
|
3. **Recherche** (`/recherche`) : recherche Open Library (Internet), enregistrer un résultat (même logique que `POST /api/books` en local), puis vérifier qu’il apparaît sous **Mes livres**.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import react from 'eslint-plugin-react'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
react,
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: { version: 'detect' },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...js.configs.recommended.rules,
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true, allowExportNames: ['useBooks'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=Source+Sans+3:wght@400;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Ma librairie</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+4635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "2026-dev-but3",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/react": "^19.0.8",
|
||||||
|
"@types/react-dom": "^19.0.3",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"vite": "^6.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
+595
File diff suppressed because it is too large
Load Diff
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { Route, Routes } from 'react-router-dom'
|
||||||
|
import { RootLayout } from './layout/RootLayout.jsx'
|
||||||
|
import BookDetailPage from './pages/BookDetailPage.jsx'
|
||||||
|
import MesLivresPage from './pages/MesLivresPage.jsx'
|
||||||
|
import SearchPage from './pages/SearchPage.jsx'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<RootLayout />}>
|
||||||
|
<Route index element={<MesLivresPage />} />
|
||||||
|
<Route path="recherche" element={<SearchPage />} />
|
||||||
|
<Route path=":id" element={<BookDetailPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
const emptyForm = () => ({
|
||||||
|
title: '',
|
||||||
|
author: '',
|
||||||
|
year: new Date().getFullYear(),
|
||||||
|
genre: '',
|
||||||
|
read: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function BookForm({ onSubmit }) {
|
||||||
|
const [form, setForm] = useState(() => emptyForm())
|
||||||
|
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!form.title.trim() || !form.author.trim()) return
|
||||||
|
onSubmit(form)
|
||||||
|
setForm(emptyForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="book-form" onSubmit={handleSubmit}>
|
||||||
|
<h2>Enregistrer un nouveau livre</h2>
|
||||||
|
<p className="book-form-story">Je veux enregistrer un nouveau livre</p>
|
||||||
|
<p className="book-form-api">
|
||||||
|
<code>POST /api/books</code>
|
||||||
|
<span className="book-form-api-note">
|
||||||
|
{' '}
|
||||||
|
(dans cette appli : enregistrement local, pas de serveur)
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>
|
||||||
|
Titre
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.title}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
|
||||||
|
placeholder="Ex. Les Misérables"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Auteur·ice
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.author}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
|
||||||
|
placeholder="Ex. Victor Hugo"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Année
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1000}
|
||||||
|
max={2100}
|
||||||
|
value={form.year}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
year: Number(e.target.value) || f.year,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Genre
|
||||||
|
<input
|
||||||
|
value={form.genre}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, genre: e.target.value }))}
|
||||||
|
placeholder="Roman, essai…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.read}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, read: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Déjà lu
|
||||||
|
</label>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button type="submit" className="btn primary">
|
||||||
|
Enregistrer (POST)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
['all', 'Tous'],
|
||||||
|
['read', 'Lus'],
|
||||||
|
['unread', 'À lire'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export function BookList({
|
||||||
|
books,
|
||||||
|
filter,
|
||||||
|
query,
|
||||||
|
onFilterChange,
|
||||||
|
onQueryChange,
|
||||||
|
onToggleRead,
|
||||||
|
onRemove,
|
||||||
|
}) {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const filtered = books.filter((b) => {
|
||||||
|
if (filter === 'read' && !b.read) return false
|
||||||
|
if (filter === 'unread' && b.read) return false
|
||||||
|
if (!q) return true
|
||||||
|
return (
|
||||||
|
b.title.toLowerCase().includes(q) ||
|
||||||
|
b.author.toLowerCase().includes(q) ||
|
||||||
|
(b.genre && b.genre.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="book-list-section">
|
||||||
|
<div className="toolbar">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="search"
|
||||||
|
placeholder="Rechercher par titre, auteur ou genre…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
|
aria-label="Recherche"
|
||||||
|
/>
|
||||||
|
<div className="filter-tabs" role="tablist">
|
||||||
|
{TABS.map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={filter === value}
|
||||||
|
className={filter === value ? 'tab active' : 'tab'}
|
||||||
|
onClick={() => onFilterChange(value)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="empty-state">
|
||||||
|
{books.length === 0
|
||||||
|
? 'Aucun livre enregistré. Utilisez le formulaire à gauche : Je veux enregistrer un nouveau livre (POST /api/books).'
|
||||||
|
: 'Aucun résultat pour ces critères.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="book-grid">
|
||||||
|
{filtered.map((book) => (
|
||||||
|
<li key={book.id} className="book-card">
|
||||||
|
<div className="book-card-top">
|
||||||
|
<span className={`badge ${book.read ? 'read' : 'unread'}`}>
|
||||||
|
{book.read ? 'Lu' : 'À lire'}
|
||||||
|
</span>
|
||||||
|
{book.genre ? <span className="genre">{book.genre}</span> : null}
|
||||||
|
</div>
|
||||||
|
<h3>
|
||||||
|
<Link className="book-title-link" to={`/${book.id}`}>
|
||||||
|
{book.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="author">{book.author}</p>
|
||||||
|
<p className="year">{book.year}</p>
|
||||||
|
<div className="card-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small"
|
||||||
|
onClick={() => onToggleRead(book.id)}
|
||||||
|
>
|
||||||
|
{book.read ? 'Marquer non lu' : 'Marquer lu'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small danger"
|
||||||
|
onClick={() => onRemove(book.id)}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'librairie-books'
|
||||||
|
|
||||||
|
const demoBooks = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Le Petit Prince',
|
||||||
|
author: 'Antoine de Saint-Exupéry',
|
||||||
|
year: 1943,
|
||||||
|
genre: 'Conte',
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: '1984',
|
||||||
|
author: 'George Orwell',
|
||||||
|
year: 1949,
|
||||||
|
genre: 'Science-fiction',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function loadBooks() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (raw === null) return demoBooks
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) ? parsed : demoBooks
|
||||||
|
} catch {
|
||||||
|
return demoBooks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBooks(books) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(books))
|
||||||
|
}
|
||||||
|
|
||||||
|
const BooksContext = createContext(null)
|
||||||
|
|
||||||
|
export function BooksProvider({ children }) {
|
||||||
|
const [books, setBooks] = useState(loadBooks)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveBooks(books)
|
||||||
|
}, [books])
|
||||||
|
|
||||||
|
/** Équivalent métier d’un POST /api/books (ici : persistance locale + id généré). */
|
||||||
|
const postBook = useCallback((book) => {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
setBooks((prev) => [...prev, { ...book, id }])
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const removeBook = useCallback((id) => {
|
||||||
|
setBooks((prev) => prev.filter((b) => b.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleRead = useCallback((id) => {
|
||||||
|
setBooks((prev) =>
|
||||||
|
prev.map((b) => (b.id === id ? { ...b, read: !b.read } : b)),
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = { books, postBook, removeBook, toggleRead }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BooksContext.Provider value={value}>{children}</BooksContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBooks() {
|
||||||
|
const ctx = useContext(BooksContext)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useBooks doit être utilisé dans un BooksProvider')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
:root {
|
||||||
|
--paper: #faf6ef;
|
||||||
|
--ink: #1c1917;
|
||||||
|
--ink-muted: #57534e;
|
||||||
|
--accent: #b45309;
|
||||||
|
--accent-hover: #92400e;
|
||||||
|
--accent-soft: rgba(180, 83, 9, 0.12);
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #e7e5e4;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--danger-soft: rgba(185, 28, 28, 0.1);
|
||||||
|
--radius: 12px;
|
||||||
|
--shadow: 0 4px 24px rgba(28, 25, 23, 0.08);
|
||||||
|
--code-bg: #f0ebe3;
|
||||||
|
--font-sans: 'Source Sans 3', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-display: 'Libre Baskerville', Georgia, 'Times New Roman', serif;
|
||||||
|
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--paper);
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--paper: #1c1917;
|
||||||
|
--ink: #fafaf9;
|
||||||
|
--ink-muted: #a8a29e;
|
||||||
|
--accent: #fbbf24;
|
||||||
|
--accent-hover: #fcd34d;
|
||||||
|
--accent-soft: rgba(251, 191, 36, 0.15);
|
||||||
|
--card: #292524;
|
||||||
|
--border: #44403c;
|
||||||
|
--danger: #f87171;
|
||||||
|
--danger-soft: rgba(248, 113, 113, 0.12);
|
||||||
|
--shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
--code-bg: #292524;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function RootLayout() {
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="header-row">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="logo" aria-hidden>
|
||||||
|
📚
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1>Ma librairie</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="main-nav" aria-label="Navigation principale">
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
end
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive ? 'nav-link active' : 'nav-link'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Mes livres
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/recherche"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
isActive ? 'nav-link active' : 'nav-link'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Recherche (Axios)
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="route-outlet">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="app-footer">
|
||||||
|
<p>
|
||||||
|
React + Vite · React Router (<code>Outlet</code>) ·{' '}
|
||||||
|
<code>useContext</code> · Axios (Open Library)
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { BooksProvider } from './context/BooksContext.jsx'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<BooksProvider>
|
||||||
|
<App />
|
||||||
|
</BooksProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useBooks } from '../context/BooksContext.jsx'
|
||||||
|
|
||||||
|
export default function BookDetailPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { books, toggleRead, removeBook } = useBooks()
|
||||||
|
const book = books.find((b) => b.id === id)
|
||||||
|
|
||||||
|
if (!book) {
|
||||||
|
return (
|
||||||
|
<div className="book-detail">
|
||||||
|
<p className="book-detail-missing">Livre introuvable.</p>
|
||||||
|
<Link to="/" className="nav-link">
|
||||||
|
← Retour à mes livres
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
removeBook(book.id)
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="book-detail">
|
||||||
|
<Link to="/" className="nav-link book-detail-back">
|
||||||
|
← Catalogue
|
||||||
|
</Link>
|
||||||
|
<span className={`badge ${book.read ? 'read' : 'unread'}`}>
|
||||||
|
{book.read ? 'Lu' : 'À lire'}
|
||||||
|
</span>
|
||||||
|
<h2 className="book-detail-title">{book.title}</h2>
|
||||||
|
<p className="book-detail-author">{book.author}</p>
|
||||||
|
<dl className="book-detail-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Année</dt>
|
||||||
|
<dd>{book.year}</dd>
|
||||||
|
</div>
|
||||||
|
{book.genre ? (
|
||||||
|
<div>
|
||||||
|
<dt>Genre</dt>
|
||||||
|
<dd>{book.genre}</dd>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div>
|
||||||
|
<dt>Identifiant</dt>
|
||||||
|
<dd>
|
||||||
|
<code>{book.id}</code>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="book-detail-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() => toggleRead(book.id)}
|
||||||
|
>
|
||||||
|
{book.read ? 'Marquer non lu' : 'Marquer lu'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn danger" onClick={handleDelete}>
|
||||||
|
Supprimer de mes livres
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { BookForm } from '../components/BookForm.jsx'
|
||||||
|
import { BookList } from '../components/BookList.jsx'
|
||||||
|
import { useBooks } from '../context/BooksContext.jsx'
|
||||||
|
|
||||||
|
export default function MesLivresPage() {
|
||||||
|
const { books, postBook, removeBook, toggleRead } = useBooks()
|
||||||
|
const [filter, setFilter] = useState('all')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const read = books.filter((b) => b.read).length
|
||||||
|
return { total: books.length, read, unread: books.length - read }
|
||||||
|
}, [books])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mes-livres-page">
|
||||||
|
<div className="mes-livres-intro">
|
||||||
|
<p className="tagline mes-livres-tagline">
|
||||||
|
{stats.total} livre{stats.total !== 1 ? 's' : ''} enregistré
|
||||||
|
{stats.total !== 1 ? 's' : ''} — {stats.read} lu
|
||||||
|
{stats.read !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="app-main">
|
||||||
|
<aside className="sidebar">
|
||||||
|
<BookForm onSubmit={postBook} />
|
||||||
|
</aside>
|
||||||
|
<BookList
|
||||||
|
books={books}
|
||||||
|
filter={filter}
|
||||||
|
query={query}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
onToggleRead={toggleRead}
|
||||||
|
onRemove={removeBook}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useBooks } from '../context/BooksContext.jsx'
|
||||||
|
|
||||||
|
const OPEN_LIBRARY_SEARCH = 'https://openlibrary.org/search.json'
|
||||||
|
|
||||||
|
function docToBookPayload(doc) {
|
||||||
|
return {
|
||||||
|
title: doc.title || 'Sans titre',
|
||||||
|
author: Array.isArray(doc.author_name)
|
||||||
|
? doc.author_name[0]
|
||||||
|
: 'Auteur inconnu',
|
||||||
|
year:
|
||||||
|
typeof doc.first_publish_year === 'number'
|
||||||
|
? doc.first_publish_year
|
||||||
|
: new Date().getFullYear(),
|
||||||
|
genre: Array.isArray(doc.subject) ? doc.subject[0] : '',
|
||||||
|
read: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultKey(doc, index) {
|
||||||
|
return doc.key || doc.cover_edition_key || `${doc.title || 'work'}-${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
const { postBook } = useBooks()
|
||||||
|
const [q, setQ] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [docs, setDocs] = useState([])
|
||||||
|
const [addedKeys, setAddedKeys] = useState(() => new Set())
|
||||||
|
const [lastQuery, setLastQuery] = useState('')
|
||||||
|
|
||||||
|
async function handleSearch(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const term = q.trim()
|
||||||
|
if (!term) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setDocs([])
|
||||||
|
setAddedKeys(() => new Set())
|
||||||
|
setLastQuery(term)
|
||||||
|
try {
|
||||||
|
const { data } = await axios.get(OPEN_LIBRARY_SEARCH, {
|
||||||
|
params: { q: term, limit: 15 },
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
setDocs(Array.isArray(data.docs) ? data.docs : [])
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
err.message ||
|
||||||
|
'Impossible de contacter Open Library.',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd(doc, index) {
|
||||||
|
postBook(docToBookPayload(doc))
|
||||||
|
const k = resultKey(doc, index)
|
||||||
|
setAddedKeys((prev) => new Set(prev).add(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-page">
|
||||||
|
<h2 className="search-page-title">Recherche Open Library</h2>
|
||||||
|
<p className="search-page-lead">
|
||||||
|
Interroge l’API publique Open Library avec Axios, puis ajoute un
|
||||||
|
ouvrage à ton catalogue local.
|
||||||
|
</p>
|
||||||
|
<form className="search-form" onSubmit={handleSearch}>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
className="search search-wide"
|
||||||
|
placeholder="Titre, auteur, sujet…"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
aria-label="Requête Open Library"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn primary" disabled={loading}>
|
||||||
|
{loading ? 'Recherche…' : 'Rechercher'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="search-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && docs.length === 0 && !error && !lastQuery ? (
|
||||||
|
<p className="search-hint">Saisis un mot-clé et lance la recherche.</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!loading && docs.length === 0 && !error && lastQuery ? (
|
||||||
|
<p className="search-hint">Aucun résultat pour « {lastQuery} ».</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{docs.length > 0 ? (
|
||||||
|
<ul className="ol-results">
|
||||||
|
{docs.map((doc, i) => {
|
||||||
|
const key = resultKey(doc, i)
|
||||||
|
const year =
|
||||||
|
typeof doc.first_publish_year === 'number'
|
||||||
|
? doc.first_publish_year
|
||||||
|
: '—'
|
||||||
|
const authors = Array.isArray(doc.author_name)
|
||||||
|
? doc.author_name.slice(0, 2).join(', ')
|
||||||
|
: '—'
|
||||||
|
const justAdded = addedKeys.has(key)
|
||||||
|
return (
|
||||||
|
<li key={key} className="ol-result-card">
|
||||||
|
<h3 className="ol-result-title">
|
||||||
|
{doc.title || 'Sans titre'}
|
||||||
|
</h3>
|
||||||
|
<p className="ol-result-meta">
|
||||||
|
{authors} · {year}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn small primary"
|
||||||
|
onClick={() => handleAdd(doc, i)}
|
||||||
|
disabled={justAdded}
|
||||||
|
>
|
||||||
|
{justAdded ? 'Enregistré' : 'Enregistrer (POST /api/books)'}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user