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