feat: UI/UX improvements pass + fix photo 404 in Docker

Photos:
- Serve uploads via /api/photos/[filename] from UPLOAD_DIR env var
- Default UPLOAD_DIR=/data/uploads (mount as Docker volume for persistence)
- Upload route creates dir on demand, no root-permission issue
- Dockerfile creates /data/uploads owned by nextjs user

Growth:
- Show chart with single measurement (was hidden until 2+ points)
- PC label expanded to "PC — Périmètre crânien (cm)"
- WHO percentile ⓘ tooltip explaining P50/P3-P97
- Date field defaults to today in add-measurement form

Stats:
- Heatmap legend shows actual counts (0 1 2 3 4+) not just Moins/Plus
- Sleep timeline labeled "Fenêtre 19h – 11h"

Timeline:
- Filter type persisted in URL param (?type=...) — survives navigation
- Inline note save shows ✓ flash after blur
- Photo thumbnails open in new tab

Photos:
- Lightbox wraps: next on last → first, prev on first → last

Search:
- Result count header "X événements · Y notes"
- Event results link to /timeline?date=...&type=...
- Note results link to /notes?date=...

Notes:
- Unsaved indicator (amber dot) while textarea dirty, green ✓ after save

Milestones:
- Sort by date ascending within each month group

Settings:
- Pushover key field has eye toggle (show/hide)
- API key delete shows confirmation with key name
- All sections wrapped in collapsible SectionCard accordion

Dashboard:
- Empty state callout for new users with no events today

Event modal:
- Milk lot selection shows "Sélectionnez un lot — il sera marqué comme utilisé"
- Photo upload validates 5MB client-side before sending
- Timer resume shows "Reprise du chronomètre" when loaded from localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:44:45 +02:00
parent f7ab467385
commit 8f68eebeb0
14 changed files with 258 additions and 60 deletions
+11 -7
View File
@@ -159,7 +159,7 @@ export default function GrowthPage() {
function openAddForm() {
const last = logs.at(-1);
setForm({
date: "",
date: format(new Date(), "yyyy-MM-dd"),
weight: last?.weight ? (last.weight / 1000).toFixed(3) : "",
height: last?.height ? String(last.height) : "",
headCirc: last?.headCirc ? String(last.headCirc) : "",
@@ -343,7 +343,7 @@ export default function GrowthPage() {
step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">PC (cm)</label>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">PC Périmètre crânien (cm)</label>
<input type="number" value={form.headCirc} onChange={(e) => setForm((f) => ({ ...f, headCirc: e.target.value }))}
step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
</div>
@@ -377,7 +377,7 @@ export default function GrowthPage() {
)}
{latest.headCirc && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Périmètre crânien</p>
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Périmètre crânien (PC)</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{latest.headCirc} cm</p>
</div>
)}
@@ -405,13 +405,17 @@ export default function GrowthPage() {
<div className="space-y-4">
{/* Weight + WHO */}
{weightData.length > 1 && (
{weightData.length > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Poids (kg)</h3>
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-indigo-600 rounded" />Bébé</span>
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />OMS P3P97</span>
<span className="flex items-center gap-1">
<span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />
OMS P3P97
<span className="text-slate-300 dark:text-slate-600 text-[10px]" title="P50 = médiane : 50% des bébés ont un poids inférieur. P3P97 représente la plage normale OMS."></span>
</span>
</div>
</div>
<ResponsiveContainer width="100%" height={200}>
@@ -431,7 +435,7 @@ export default function GrowthPage() {
<div className="grid md:grid-cols-2 gap-4">
{/* Height */}
{heightData.length > 1 && (
{heightData.length > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Taille (cm)</h3>
<ResponsiveContainer width="100%" height={160}>
@@ -447,7 +451,7 @@ export default function GrowthPage() {
)}
{/* Head circumference */}
{headData.length > 1 && (
{headData.length > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Périmètre crânien (cm)</h3>
<ResponsiveContainer width="100%" height={160}>