feat: finish all UI/UX improvements from backlog
- nav: aria-label on baby switcher select - dashboard: active timers show baby name - growth: PDF export includes percentile band per weight row - settings: invite link note (permanent, reset if compromised) - timeline: select mode toggle shows "Annuler" when active - photos: delete photo from lightbox (PATCHes event metadata) - notes: invisible date input overlay for date-jump via calendar picker - IMPROVEMENTS.md: updated checkboxes, 8 remaining → 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,7 +84,7 @@ function formatElapsed(startedAt: string, now: Date): string {
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function ActiveTimers({ events, onStop }: { events: Event[]; onStop: () => void }) {
|
||||
function ActiveTimers({ events, onStop, babyName }: { events: Event[]; onStop: () => void; babyName?: string }) {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const active = events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer);
|
||||
|
||||
@@ -108,6 +108,7 @@ function ActiveTimers({ events, onStop }: { events: Event[]; onStop: () => void
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</p>
|
||||
{babyName && <span className="text-xs text-slate-400">{babyName}</span>}
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Depuis {format(new Date(ev.startedAt), "HH:mm")}
|
||||
</p>
|
||||
@@ -578,7 +579,7 @@ export default function DashboardPage() {
|
||||
<HandoffWidget events={events} userId={session?.user?.id ?? ""} />
|
||||
|
||||
{/* Active timers */}
|
||||
<ActiveTimers events={events} onStop={() => qc.invalidateQueries({ queryKey: ["events"] })} />
|
||||
<ActiveTimers events={events} onStop={() => qc.invalidateQueries({ queryKey: ["events"] })} babyName={selectedBaby?.name} />
|
||||
|
||||
{/* Pinned note + Quick note */}
|
||||
<div className="grid sm:grid-cols-2 gap-4 mb-6">
|
||||
|
||||
@@ -242,6 +242,15 @@ export default function GrowthPage() {
|
||||
}
|
||||
|
||||
function exportPDF() {
|
||||
function getPercentileBand(ageWeeks: number, weightKg: number): string {
|
||||
const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0);
|
||||
if (weightKg < values[0]) return "< P3";
|
||||
for (let i = 0; i < values.length - 1; i++) {
|
||||
if (weightKg >= values[i] && weightKg < values[i + 1]) return `${PERCENTILE_LABELS[i]}–${PERCENTILE_LABELS[i + 1]}`;
|
||||
}
|
||||
return "> P97";
|
||||
}
|
||||
|
||||
import("jspdf").then(({ default: jsPDF }) => {
|
||||
const doc = new jsPDF();
|
||||
const pageW = doc.internal.pageSize.getWidth();
|
||||
@@ -277,7 +286,11 @@ export default function GrowthPage() {
|
||||
}
|
||||
doc.setTextColor(30, 41, 59);
|
||||
doc.text(format(new Date(log.date), "d MMM yyyy", { locale: fr }), COL[0], y);
|
||||
doc.text(log.weight ? (log.weight / 1000).toFixed(3) : "—", COL[1], y);
|
||||
doc.text(log.weight ? (() => {
|
||||
const ageW = getAgeInWeeks(new Date(baby!.birthDate), new Date(log.date));
|
||||
const band = ageW >= 0 && ageW <= 104 ? ` (${getPercentileBand(ageW, log.weight! / 1000)})` : "";
|
||||
return `${(log.weight! / 1000).toFixed(3)}${band}`;
|
||||
})() : "—", COL[1], y);
|
||||
doc.text(log.height ? String(log.height) : "—", COL[2], y);
|
||||
doc.text(log.headCirc ? String(log.headCirc) : "—", COL[3], y);
|
||||
if (log.notes) doc.text(log.notes.slice(0, 30), COL[4], y);
|
||||
|
||||
@@ -213,8 +213,17 @@ export default function NotesPage() {
|
||||
</button>
|
||||
|
||||
<div className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-lg font-semibold text-slate-900 dark:text-slate-100 capitalize">
|
||||
<span className="text-lg font-semibold text-slate-900 dark:text-slate-100 capitalize relative">
|
||||
{formatDateLabel(currentDate)}
|
||||
<input
|
||||
type="date"
|
||||
value={format(currentDate, "yyyy-MM-dd")}
|
||||
max={format(new Date(), "yyyy-MM-dd")}
|
||||
onChange={(e) => {
|
||||
if (e.target.value) setCurrentDate(new Date(e.target.value + "T12:00:00"));
|
||||
}}
|
||||
className="absolute inset-0 opacity-0 cursor-pointer w-full"
|
||||
/>
|
||||
</span>
|
||||
{!isToday(currentDate) && (
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useBaby } from "@/contexts/baby-context";
|
||||
import { EVENT_CONFIG, EventType } from "@/lib/event-config";
|
||||
import { format, isToday, isYesterday } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { X, ChevronLeft, ChevronRight, Camera } from "lucide-react";
|
||||
import { X, ChevronLeft, ChevronRight, Camera, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface PhotoEvent {
|
||||
@@ -27,6 +27,7 @@ function dayLabel(key: string): string {
|
||||
|
||||
export default function PhotosPage() {
|
||||
const { selectedBaby } = useBaby();
|
||||
const qc = useQueryClient();
|
||||
const [lightbox, setLightbox] = useState<{ events: PhotoEvent[]; index: number } | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -67,6 +68,19 @@ export default function PhotosPage() {
|
||||
|
||||
const current = lightbox ? lightbox.events[lightbox.index] : null;
|
||||
|
||||
async function deletePhoto(eventId: string) {
|
||||
const res = await fetch(`/api/events/${eventId}`);
|
||||
const ev = await res.json();
|
||||
const existingMeta = ev.metadata ?? {};
|
||||
await fetch(`/api/events/${eventId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ metadata: { ...existingMeta, photo: null } }),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["photo-events", selectedBaby?.id] });
|
||||
setLightbox(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-6">
|
||||
@@ -134,9 +148,17 @@ export default function PhotosPage() {
|
||||
</p>
|
||||
{current.notes && <p className="text-white/80 text-xs mt-0.5 italic">{current.notes}</p>}
|
||||
</div>
|
||||
<button onClick={() => setLightbox(null)} className="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition">
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { if (confirm("Supprimer cette photo ?")) deletePhoto(current.id); }}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-black/50 hover:bg-red-600/80 transition"
|
||||
>
|
||||
<Trash2 className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<button onClick={() => setLightbox(null)} className="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition">
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
|
||||
@@ -546,6 +546,8 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 mt-1">Le lien n'expire pas — réinitialisez-le si compromis.</p>
|
||||
|
||||
<div className="pt-3 border-t border-slate-100 dark:border-slate-700 mt-3">
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-3">Inviter par email</p>
|
||||
<form onSubmit={sendInviteEmail} className="space-y-2">
|
||||
|
||||
@@ -156,7 +156,7 @@ export default function TimelinePage() {
|
||||
}`}
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5" />
|
||||
Sélectionner
|
||||
{selectMode ? "Annuler" : "Sélectionner"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Filter chips */}
|
||||
|
||||
Reference in New Issue
Block a user