Compare commits
20
Commits
c5ca51a578
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81271544c6 | ||
|
|
f932ecb060 | ||
|
|
2a5954e8e9 | ||
|
|
fd6735ba93 | ||
|
|
fcd91f443f | ||
|
|
551f351ce1 | ||
|
|
249776b536 | ||
|
|
5452fb8680 | ||
|
|
ba1ba57fa5 | ||
|
|
a0263d6367 | ||
|
|
360ed62c14 | ||
|
|
f82a299877 | ||
|
|
7655cb5b69 | ||
|
|
e5220a4c4e | ||
|
|
8d6d923063 | ||
|
|
8523ace11f | ||
|
|
e13652a753 | ||
|
|
7325485ee3 | ||
|
|
32e8bc7837 | ||
|
|
0bfda59cb5 |
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
*.md
|
||||||
+22
@@ -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;"]
|
||||||
@@ -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,10 +50,64 @@ 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
|
||||||
|
|
||||||
|
Avec Docker Compose (recommandé) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Le port peut être changé via la variable d'environnement `PORT` (par défaut 8080) :
|
||||||
|
`PORT=9090 docker compose up -d`.
|
||||||
|
|
||||||
|
Ou avec les commandes Docker :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t notes .
|
||||||
|
docker run -d --rm --name notes -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
|
||||||
|
|
||||||
MIT — voir [LICENSE](LICENSE).
|
MIT — voir [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
services:
|
||||||
|
notes:
|
||||||
|
build: .
|
||||||
|
image: notes
|
||||||
|
container_name: notes
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${PORT:-8080}:80"
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Notes
|
||||||
|
|
||||||
|
Application de prise de notes personnelle sous forme de SPA PWA (React + TypeScript + Tailwind CSS).
|
||||||
|
Installable, utilisable hors connexion, et 100 % privée : les données restent dans le **localStorage** du navigateur, sans partage ni transmission à un serveur externe.
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
- Créer, lire, modifier et supprimer des notes
|
||||||
|
- Données stockées localement (localStorage) — aucune donnée partagée ni transmise
|
||||||
|
- PWA installable avec mode hors connexion
|
||||||
|
- Design responsive mobile-first
|
||||||
|
- Modale RGPD, confirmation avant suppression, page d'erreur 404
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
L'image est servie par nginx et exposée sur le port 80.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --rm --name notes -p 8080:80 devcodetools/notes:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Accédez ensuite à l'application sur http://localhost:8080.
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
notes:
|
||||||
|
image: devcodetools/notes:latest
|
||||||
|
container_name: notes
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Construction depuis les sources
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t devcodetools/notes:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Confidentialité
|
||||||
|
|
||||||
|
Vos notes ne quittent jamais votre appareil : stockage uniquement dans le localStorage de votre navigateur, aucune donnée partagée avec des tiers, aucune transmission vers un serveur.
|
||||||
+2
-2
@@ -5,12 +5,12 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Application web SPA PWA installable et utilisable hors connexion."
|
content="Notes - Application web SPA PWA installable et utilisable hors connexion."
|
||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#f8fafc" />
|
<meta name="theme-color" content="#f8fafc" />
|
||||||
<meta name="color-scheme" content="dark light" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<title>new</title>
|
<title>Notes</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+19
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-7
@@ -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)
|
||||||
@@ -37,10 +39,10 @@ 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-3xl 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">
|
||||||
{creating || selectedNote ? (
|
<div className="flex h-11 items-center gap-1">
|
||||||
<div className="flex items-center gap-1">
|
{creating || selectedNote ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goBack}
|
onClick={goBack}
|
||||||
@@ -49,11 +51,17 @@ function App() {
|
|||||||
>
|
>
|
||||||
←
|
←
|
||||||
</button>
|
</button>
|
||||||
<h1 className="text-lg font-semibold">Notes</h1>
|
) : null}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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>
|
||||||
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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-3xl 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
|
||||||
+12
-109
@@ -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)}
|
setEditing(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"
|
onDelete={props.onDelete}
|
||||||
>
|
/>
|
||||||
×
|
|
||||||
</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)
|
|
||||||
}}
|
|
||||||
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)
|
|
||||||
props.onDelete(note.id)
|
|
||||||
}}
|
|
||||||
onCancel={() => setConfirmDelete(false)}
|
|
||||||
/>
|
|
||||||
</article>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -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(/stockage local/i)).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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"> 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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface ErrorPageProps {
|
||||||
|
code?: number
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface RgpdInfoProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+2
-2
@@ -11,8 +11,8 @@ export default defineConfig({
|
|||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['icon.svg', 'apple-touch-icon-180x180.png'],
|
includeAssets: ['icon.svg', 'apple-touch-icon-180x180.png'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'new',
|
name: 'Notes',
|
||||||
short_name: 'new',
|
short_name: 'Notes',
|
||||||
description: 'Application web SPA PWA installable et utilisable hors connexion.',
|
description: 'Application web SPA PWA installable et utilisable hors connexion.',
|
||||||
lang: 'fr',
|
lang: 'fr',
|
||||||
start_url: '/',
|
start_url: '/',
|
||||||
|
|||||||
Reference in new issue
Block a user