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
+14 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EVENT_CONFIG, EventType, formatDuration } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
@@ -73,7 +74,11 @@ export default function TimelinePage() {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null);
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [filterType, setFilterType] = useState<EventType | "">("");
const [savedNoteId, setSavedNoteId] = useState<string | null>(null);
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const filterType = (searchParams.get("type") as EventType | null) ?? "";
const [page, setPage] = useState(0);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -124,10 +129,14 @@ export default function TimelinePage() {
});
qc.invalidateQueries({ queryKey: ["events"] });
setEditingNotes(null);
setSavedNoteId(id);
setTimeout(() => setSavedNoteId(null), 2000);
}
function handleFilterChange(t: EventType | "") {
setFilterType(t);
const params = new URLSearchParams(searchParams.toString());
if (t === "" || t === filterType) { params.delete("type"); } else { params.set("type", t); }
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
setPage(0);
}
@@ -296,13 +305,14 @@ export default function TimelinePage() {
>
<StickyNote className="w-3.5 h-3.5" />
<span>{ev.notes ? ev.notes : "Ajouter une note..."}</span>
{savedNoteId === ev.id && <span className="text-xs text-emerald-500 ml-2"></span>}
</button>
)}
{typeof ev.metadata?.photo === "string" && (
<div className="relative w-full h-48 rounded-xl overflow-hidden border border-slate-200 dark:border-slate-600">
<a href={ev.metadata.photo} target="_blank" rel="noopener noreferrer" className="block relative w-full h-48 rounded-xl overflow-hidden border border-slate-200 dark:border-slate-600">
<Image src={ev.metadata.photo} alt="Photo" fill className="object-cover" unoptimized />
</div>
</a>
)}
<div className="flex items-center gap-3">