mise en place de l'application de base

This commit is contained in:
felix-vi
2026-03-22 18:45:37 +01:00
commit eddb103755
18 changed files with 6038 additions and 0 deletions
+53
View File
@@ -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 ladresse 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 dun 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 quil apparaît sous **Mes livres**.
+36
View File
@@ -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
View File
@@ -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>
+4635
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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>
)
}
+90
View File
@@ -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>
)
}
+102
View File
@@ -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>
)
}
+83
View File
@@ -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 dun 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
}
+58
View File
@@ -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;
}
+50
View File
@@ -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>
)
}
+16
View File
@@ -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>,
)
+68
View File
@@ -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>
)
}
+41
View File
@@ -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>
)
}
+137
View File
@@ -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 lAPI 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>
)
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})