Compare commits

..
10 Commits
17 changed files with 460 additions and 122 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
coverage
.git
.gitignore
Dockerfile
docker-compose*.yml
*.md
+22
View File
@@ -0,0 +1,22 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY public ./public
COPY . .
RUN pnpm build
FROM nginx:1.27-alpine AS production
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+70 -5
View File
@@ -1,10 +1,32 @@
# new # Notes — Application web SPA PWA
Application web SPA PWA (React + TypeScript + Tailwind CSS + Vite). Application de prise de notes personnelle, installable et utilisable hors connexion.
Les données restent dans le **localStorage** du navigateur : aucune donnée n'est partagée ni transmise à un serveur externe.
## Fonctionnalités
- Créer, lire, modifier et supprimer des notes
- Stockage local (localStorage) — données 100 % privées
- Modale RGPD accessible depuis le bouton **?** dans le header
- Modale de confirmation avant suppression
- PWA installable avec service worker (hors connexion)
- Design responsive mobile-first (Tailwind CSS)
- Protection des routes inconnues (page d'erreur 404)
## Stack technique
| Technologie | Rôle |
| --- | --- |
| React 19 + TypeScript | UI |
| Vite 8 | Build tool |
| Tailwind CSS 4 | Style |
| Vitest | Tests unitaires |
| vite-plugin-pwa | Service worker + manifest |
| nginx | Serveur de production (Docker) |
## Prérequis ## Prérequis
- Node.js >= 20.19.0 (ou >= 22.12.0) - Node.js >= 20.19.0
- pnpm >= 9 - pnpm >= 9
## Installation ## Installation
@@ -28,9 +50,52 @@ pnpm install
| `pnpm typecheck` | Vérification TypeScript | | `pnpm typecheck` | Vérification TypeScript |
| `pnpm check` | Chaîne qualité : lint → format → typecheck → tests → coverage | | `pnpm check` | Chaîne qualité : lint → format → typecheck → tests → coverage |
## Environnement ## Docker
Copiez `.env.example` vers `.env` pour définir les variables (préfixe `VITE_`). ### Build et exécution locale
```bash
docker build -t notes .
docker run -p 8080:80 notes
```
L'app sera accessible sur http://localhost:8080.
### Publication sur Docker Hub
```bash
# 1. Construire l'image avec le tag Docker Hub
docker build -t <votre-pseudo>/notes:latest .
# 2. Se connecter à Docker Hub
docker login
# 3. Pousser l'image
docker push <votre-pseudo>/notes:latest
```
Remplacez `<votre-pseudo>` par votre identifiant Docker Hub.
## Architecture
```
src/
├── components/ # Composants React (UI)
│ ├── ErrorPage # Page d'erreur 404
│ ├── FloatingButton # Bouton d'ajout de note
│ ├── DeleteModal # Modale de confirmation suppression
│ ├── NoteEditor # Formulaire de création/édition
│ ├── NoteList # Liste des notes
│ ├── NoteReadView # Vue lecture d'une note
│ └── RgpdInfo # Modal RGPD (informations données)
├── hooks/ # Hook personnalisé useNotes
├── interfaces/ # Types des props (typeScript)
├── lib/ # Logique métier (validation, stockage)
├── types/ # Types shared (Note, NotePatch)
├── App.tsx # Composant racine
├── main.tsx # Point d'entrée
└── style.css # Tailwind + styles globaux
```
## Licence ## Licence
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
+14 -4
View File
@@ -4,11 +4,13 @@ import { useNotes } from './hooks/useNotes'
import NoteList from './components/NoteList' import NoteList from './components/NoteList'
import NoteEditor from './components/NoteEditor' import NoteEditor from './components/NoteEditor'
import FloatingButton from './components/FloatingButton' import FloatingButton from './components/FloatingButton'
import RgpdInfo from './components/RgpdInfo'
function App() { function App() {
const { notes, addNote, updateNote, deleteNote } = useNotes() const { notes, addNote, updateNote, deleteNote } = useNotes()
const [selectedId, setSelectedId] = useState<number | null>(null) const [selectedId, setSelectedId] = useState<number | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [showRgpd, setShowRgpd] = useState(false)
const selectedNote = const selectedNote =
selectedId === null ? null : (notes.find((note) => note.id === selectedId) ?? null) selectedId === null ? null : (notes.find((note) => note.id === selectedId) ?? null)
@@ -39,8 +41,8 @@ function App() {
return ( return (
<main className="mx-auto flex w-full max-w-2xl min-h-dvh flex-col pb-[calc(env(safe-area-inset-bottom)+7rem)]"> <main className="mx-auto flex w-full max-w-2xl min-h-dvh flex-col pb-[calc(env(safe-area-inset-bottom)+7rem)]">
<header className="sticky top-0 z-10 border-b border-gray-200 bg-white/95 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)] backdrop-blur"> <header className="sticky top-0 z-10 border-b border-gray-200 bg-white/95 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)] backdrop-blur">
<div className="flex h-11 items-center gap-1">
{creating || selectedNote ? ( {creating || selectedNote ? (
<div className="flex items-center gap-1">
<button <button
type="button" type="button"
onClick={goBack} onClick={goBack}
@@ -49,11 +51,17 @@ function App() {
> >
← ←
</button> </button>
) : null}
<h1 className="text-lg font-semibold">Notes</h1> <h1 className="text-lg font-semibold">Notes</h1>
<button
type="button"
onClick={() => setShowRgpd(true)}
aria-label="Informations sur la protection des données"
className="ml-auto flex items-center justify-center w-11 h-11 rounded-full text-xl font-semibold text-gray-700 hover:bg-gray-100 active:bg-gray-200"
>
?
</button>
</div> </div>
) : (
<h1 className="text-lg font-semibold">Notes</h1>
)}
</header> </header>
<section className="flex-1 px-4 py-4"> <section className="flex-1 px-4 py-4">
@@ -78,6 +86,8 @@ function App() {
</section> </section>
{!creating && !selectedNote && <FloatingButton onClick={createNote} />} {!creating && !selectedNote && <FloatingButton onClick={createNote} />}
<RgpdInfo open={showRgpd} onClose={() => setShowRgpd(false)} />
</main> </main>
) )
} }
+27
View File
@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import ErrorPage from './ErrorPage'
describe('ErrorPage', () => {
it('renders default 404 content', () => {
render(<ErrorPage />)
expect(screen.getByText('404')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Page introuvable' })).toBeInTheDocument()
})
it('renders a link back to the home page', () => {
render(<ErrorPage />)
const link = screen.getByRole('link', { name: "Retour à l'accueil" })
expect(link).toHaveAttribute('href', '/')
})
it('uses custom code, title and message when provided', () => {
render(<ErrorPage code={500} title="Erreur serveur" message="Une erreur est survenue." />)
expect(screen.getByText('500')).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Erreur serveur' })).toBeInTheDocument()
expect(screen.getByText('Une erreur est survenue.')).toBeInTheDocument()
})
})
+25
View File
@@ -0,0 +1,25 @@
import type { ErrorPageProps } from '../interfaces/error-page'
function ErrorPage({
code = 404,
title = 'Page introuvable',
message = "Désolé, la page que vous cherchez n'existe pas.",
}: ErrorPageProps) {
return (
<main className="mx-auto flex w-full max-w-2xl min-h-dvh flex-col items-center justify-center gap-6 px-4 text-center">
<p className="text-6xl font-bold text-blue-700">{code}</p>
<div>
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="mt-2 text-gray-600">{message}</p>
</div>
<a
href="/"
className="rounded-lg bg-blue-700 px-5 py-2.5 text-white transition-colors hover:bg-blue-800 focus:outline-none focus:ring-2 focus:ring-blue-800 focus:ring-offset-2"
>
Retour à l'accueil
</a>
</main>
)
}
export default ErrorPage
+9 -106
View File
@@ -1,82 +1,14 @@
import { useState } from 'react' import { useState } from 'react'
import type { NoteEditorProps, NoteFormProps } from '../interfaces/note-editor' import type { NoteEditorProps } from '../interfaces/note-editor'
import { isTitleValid, normalizeTitle } from '../lib/noteValidation' import { isTitleValid, normalizeTitle } from '../lib/noteValidation'
import DeleteModal from './DeleteModal' import NoteForm from './NoteForm'
import NoteReadView from './NoteReadView'
const TITLE_REQUIRED_MESSAGE = 'Le titre est obligatoire.'
function NoteForm({
title,
content,
titleMissing,
cancelLabel,
onTitleChange,
onContentChange,
onCancel,
onSubmit,
}: NoteFormProps) {
return (
<form
className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm"
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-500">Titre</span>
<input
value={title}
onChange={(event) => onTitleChange(event.target.value)}
autoFocus
aria-invalid={titleMissing}
className={`w-full rounded-lg border px-3 py-3 text-base focus:outline-none focus:ring-2 ${
titleMissing
? 'border-red-500 focus:ring-red-500/30'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/30'
}`}
placeholder="Sans titre"
/>
</label>
{titleMissing && (
<span role="alert" className="text-sm text-red-600">
{TITLE_REQUIRED_MESSAGE}
</span>
)}
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-500">Contenu</span>
<textarea
value={content}
onChange={(event) => onContentChange(event.target.value)}
className="w-full min-h-48 rounded-lg border border-gray-300 px-3 py-3 text-base focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30"
placeholder="Écrire ici…"
/>
</label>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-gray-300 px-5 py-3 text-gray-600 hover:bg-gray-50 active:bg-gray-100"
>
{cancelLabel}
</button>
<button
type="submit"
className="rounded-lg bg-blue-600 px-5 py-3 text-white hover:bg-blue-700 active:bg-blue-800"
>
Enregistrer
</button>
</div>
</form>
)
}
function NoteEditor(props: NoteEditorProps) { function NoteEditor(props: NoteEditorProps) {
const [editing, setEditing] = useState(props.mode === 'create') const [editing, setEditing] = useState(props.mode === 'create')
const [title, setTitle] = useState(props.mode === 'edit' ? props.note.title : '') const [title, setTitle] = useState(props.mode === 'edit' ? props.note.title : '')
const [content, setContent] = useState(props.mode === 'edit' ? props.note.content : '') const [content, setContent] = useState(props.mode === 'edit' ? props.note.content : '')
const [titleMissing, setTitleMissing] = useState(false) const [titleMissing, setTitleMissing] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false)
const handleTitleChange = (value: string) => { const handleTitleChange = (value: string) => {
setTitle(value) setTitle(value)
@@ -114,44 +46,15 @@ function NoteEditor(props: NoteEditorProps) {
const { note } = props const { note } = props
return ( return (
<article className="flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm"> <NoteReadView
<div className="flex items-start justify-between gap-4"> note={note}
<h2 className="text-xl font-semibold leading-snug">{note.title || 'Sans titre'}</h2> onEdit={() => {
<button setTitle(note.title)
type="button" setContent(note.content)
onClick={() => setConfirmDelete(true)}
aria-label="Supprimer la note"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-2xl leading-none text-red-600 hover:bg-red-50 hover:text-red-700 active:bg-red-100"
>
×
</button>
</div>
<p className="whitespace-pre-wrap text-base leading-relaxed">
{note.content || <span className="text-gray-400 italic">Note vide</span>}
</p>
<div className="flex justify-end">
<button
type="button"
onClick={() => {
setTitle(props.note.title)
setContent(props.note.content)
setEditing(true) setEditing(true)
}} }}
className="rounded-lg bg-blue-600 px-5 py-3 text-white hover:bg-blue-700 active:bg-blue-800" onDelete={props.onDelete}
>
Éditer
</button>
</div>
<DeleteModal
open={confirmDelete}
title={note.title}
onConfirm={() => {
setConfirmDelete(false)
props.onDelete(note.id)
}}
onCancel={() => setConfirmDelete(false)}
/> />
</article>
) )
} }
+71
View File
@@ -0,0 +1,71 @@
import type { NoteFormProps } from '../interfaces/note-editor'
const TITLE_REQUIRED_MESSAGE = 'Le titre est obligatoire.'
function NoteForm({
title,
content,
titleMissing,
cancelLabel,
onTitleChange,
onContentChange,
onCancel,
onSubmit,
}: NoteFormProps) {
return (
<form
className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm"
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-500">Titre</span>
<input
value={title}
onChange={(event) => onTitleChange(event.target.value)}
autoFocus
aria-invalid={titleMissing}
className={`w-full rounded-lg border px-3 py-3 text-base focus:outline-none focus:ring-2 ${
titleMissing
? 'border-red-500 focus:ring-red-500/30'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500/30'
}`}
placeholder="Sans titre"
/>
</label>
{titleMissing && (
<span role="alert" className="text-sm text-red-600">
{TITLE_REQUIRED_MESSAGE}
</span>
)}
<label className="flex flex-col gap-1">
<span className="text-sm font-medium text-gray-500">Contenu</span>
<textarea
value={content}
onChange={(event) => onContentChange(event.target.value)}
className="w-full min-h-48 rounded-lg border border-gray-300 px-3 py-3 text-base focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/30"
placeholder="Écrire ici…"
/>
</label>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-gray-300 px-5 py-3 text-gray-600 hover:bg-gray-50 active:bg-gray-100"
>
{cancelLabel}
</button>
<button
type="submit"
className="rounded-lg bg-blue-600 px-5 py-3 text-white hover:bg-blue-700 active:bg-blue-800"
>
Enregistrer
</button>
</div>
</form>
)
}
export default NoteForm
+57
View File
@@ -0,0 +1,57 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import NoteReadView from './NoteReadView'
import type { Note } from '../types/note'
const note: Note = { id: 42, title: 'Courses', content: 'Lait\nOeufs' }
const renderView = (overrides: Partial<Note> = {}) => {
const onEdit = vi.fn()
const onDelete = vi.fn()
render(<NoteReadView note={{ ...note, ...overrides }} onEdit={onEdit} onDelete={onDelete} />)
return { onEdit, onDelete }
}
describe('NoteReadView', () => {
it('displays the note title and content', () => {
renderView()
expect(screen.getByText('Courses')).toBeInTheDocument()
expect(screen.getByText('Lait Oeufs')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Éditer' })).toBeInTheDocument()
})
it('shows placeholders for a note without title or content', () => {
renderView({ title: '', content: '' })
expect(screen.getByText('Sans titre')).toBeInTheDocument()
expect(screen.getByText('Note vide')).toBeInTheDocument()
})
it('calls onEdit when Éditer is clicked', () => {
const { onEdit } = renderView()
fireEvent.click(screen.getByRole('button', { name: 'Éditer' }))
expect(onEdit).toHaveBeenCalled()
})
it('deletes the note after confirmation', () => {
const { onDelete } = renderView()
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' }))
fireEvent.click(screen.getByRole('button', { name: 'Supprimer' }))
expect(onDelete).toHaveBeenCalledWith(42)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('does not delete when the confirmation is cancelled', () => {
const { onDelete } = renderView()
fireEvent.click(screen.getByRole('button', { name: 'Supprimer la note' }))
fireEvent.click(screen.getByRole('button', { name: 'Annuler' }))
expect(onDelete).not.toHaveBeenCalled()
})
})
+46
View File
@@ -0,0 +1,46 @@
import { useState } from 'react'
import type { NoteReadViewProps } from '../interfaces/note-editor'
import DeleteModal from './DeleteModal'
function NoteReadView({ note, onEdit, onDelete }: NoteReadViewProps) {
const [confirmDelete, setConfirmDelete] = useState(false)
return (
<article className="flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
<div className="flex items-start justify-between gap-4">
<h2 className="text-xl font-semibold leading-snug">{note.title || 'Sans titre'}</h2>
<button
type="button"
onClick={() => setConfirmDelete(true)}
aria-label="Supprimer la note"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-2xl leading-none text-red-600 hover:bg-red-50 hover:text-red-700 active:bg-red-100"
>
×
</button>
</div>
<p className="whitespace-pre-wrap text-base leading-relaxed">
{note.content || <span className="text-gray-400 italic">Note vide</span>}
</p>
<div className="flex justify-end">
<button
type="button"
onClick={onEdit}
className="rounded-lg bg-blue-600 px-5 py-3 text-white hover:bg-blue-700 active:bg-blue-800"
>
Éditer
</button>
</div>
<DeleteModal
open={confirmDelete}
title={note.title}
onConfirm={() => {
setConfirmDelete(false)
onDelete(note.id)
}}
onCancel={() => setConfirmDelete(false)}
/>
</article>
)
}
export default NoteReadView
+31
View File
@@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import RgpdInfo from './RgpdInfo'
describe('RgpdInfo', () => {
it('renders nothing when closed', () => {
const { container } = render(<RgpdInfo open={false} onClose={vi.fn()} />)
expect(container).toBeEmptyDOMElement()
})
it('renders the GDPR information when open', () => {
render(<RgpdInfo open onClose={vi.fn()} />)
expect(screen.getByRole('dialog', { name: 'Protection des données (RGPD)' })).toBeInTheDocument()
expect(screen.getByText('localStorage', { exact: false })).toBeInTheDocument()
expect(
screen.getByText(/ne sont jamais partagées avec des tiers/i),
).toBeInTheDocument()
expect(screen.getByText(/aucune transmission vers un serveur externe/i)).toBeInTheDocument()
})
it('calls onClose when the close button is clicked', () => {
const onClose = vi.fn()
render(<RgpdInfo open onClose={onClose} />)
screen.getByRole('button', { name: 'Fermer' }).click()
expect(onClose).toHaveBeenCalled()
})
})
+39
View File
@@ -0,0 +1,39 @@
import type { RgpdInfoProps } from '../interfaces/rgpd-info'
function RgpdInfo({ open, onClose }: RgpdInfoProps) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div
role="dialog"
aria-modal="true"
aria-labelledby="rgpd-title"
className="flex w-full max-w-sm flex-col gap-4 rounded-xl bg-white p-5 shadow-xl"
>
<h2 id="rgpd-title" className="text-lg font-semibold">
Protection des données (RGPD)
</h2>
<ul className="flex list-disc flex-col gap-2 pl-5 text-sm text-gray-600">
<li>
Vos données sont stockées uniquement dans le
<span className="font-medium text-gray-800">&nbsp;stockage local</span> de votre navigateur.
</li>
<li>Elles ne sont jamais partagées avec des tiers.</li>
<li>Aucune transmission vers un serveur externe.</li>
</ul>
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-lg bg-blue-700 px-5 py-3 text-white hover:bg-blue-800 active:bg-blue-900"
>
Fermer
</button>
</div>
</div>
</div>
)
}
export default RgpdInfo
+5
View File
@@ -0,0 +1,5 @@
export interface ErrorPageProps {
code?: number
title?: string
message?: string
}
+6
View File
@@ -17,6 +17,12 @@ export interface CreateNoteEditorProps {
export type NoteEditorProps = EditNoteEditorProps | CreateNoteEditorProps export type NoteEditorProps = EditNoteEditorProps | CreateNoteEditorProps
export interface NoteReadViewProps {
note: Note
onEdit: () => void
onDelete: (id: number) => void
}
export interface NoteFormProps { export interface NoteFormProps {
title: string title: string
content: string content: string
+4
View File
@@ -0,0 +1,4 @@
export interface RgpdInfoProps {
open: boolean
onClose: () => void
}
+7 -7
View File
@@ -3,11 +3,11 @@ import type { Note } from '../types/note'
const STORAGE_KEY = 'notes' const STORAGE_KEY = 'notes'
export function loadNotes(): Note[] { export function loadNotes(): Note[] {
const raw = localStorage.getItem(STORAGE_KEY) const storage = localStorage.getItem(STORAGE_KEY)
if (raw === null) return [] if (storage === null) return []
try { try {
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(storage)
if (!Array.isArray(parsed)) return [] if (!Array.isArray(parsed)) return []
return parsed.filter(isNote) return parsed.filter(isNote)
} catch { } catch {
@@ -21,10 +21,10 @@ export function saveNotes(notes: Note[]): void {
function isNote(value: unknown): value is Note { function isNote(value: unknown): value is Note {
if (typeof value !== 'object' || value === null) return false if (typeof value !== 'object' || value === null) return false
const candidate = value as Record<string, unknown> const note = value as Record<string, unknown>
return ( return (
typeof candidate.id === 'number' && typeof note.id === 'number' &&
typeof candidate.title === 'string' && typeof note.title === 'string' &&
typeof candidate.content === 'string' typeof note.content === 'string'
) )
} }