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
+4
View File
@@ -43,6 +43,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./src/generated
COPY --from=builder /app/prisma ./prisma
# Upload directory — mount a volume here for persistence: -v /host/uploads:/data/uploads
RUN mkdir -p /data/uploads && chown nextjs:nodejs /data/uploads
ENV UPLOAD_DIR=/data/uploads
USER nextjs
EXPOSE 3000
+76
View File
@@ -0,0 +1,76 @@
# Improvements & Known Issues
## Critical Bugs
- [x] **Photos 404 in Docker** — Upload wrote to `public/uploads/` (root-owned in standalone container). Fix: serve via `/api/photos/[filename]` from configurable `UPLOAD_DIR=/data/uploads`. Mount `/data/uploads` as Docker volume.
- [x] **WHO curves invisible when baby has measurements**`connectNulls` missing on percentile Lines; baby data rows with no p-values broke line segments.
- [x] **Symptom icon missing**`Activity` not registered in `event-icon.tsx` icon map.
- [x] **OMS curves break when measurements exist** — same as above (connectNulls).
## UI/UX Improvements
### Dashboard
- [ ] **Empty state for new users** — show "Commencez par enregistrer un repas →" callout when no events today
- [ ] **Active timers show which baby** — add baby name if multiple babies in family
### Growth
- [ ] **Single-measurement chart** — currently blank; should show point + WHO curves + message
- [ ] **"PC" label** — expand to "Périmètre crânien (PC)" on first use
- [ ] **WHO percentile tooltip** — "P50 = médiane : 50% des bébés sont en dessous"
- [ ] **Date field defaults to today** in add-measurement form
- [ ] **PDF export percentile rank** — show baby's WHO band in PDF table
### Stats
- [ ] **Heatmap legend** — show actual count numbers (0, 1, 2, 3+) not just "Moins/Plus"
- [ ] **Sleep timeline** — label "19h → 11h" window, make configurable
- [ ] **Chart zero-data gaps** — distinguish "no data" vs "zero events" visually
### Timeline
- [ ] **Filter state sticky** — persist in URL param (`?type=BREASTFEED`)
- [ ] **Inline note save feedback** — subtle "Enregistré ✓" flash on blur
- [ ] **Photo in timeline** — make thumbnail clickable to open lightbox
- [ ] **Select mode indicator** — show "3 sélectionnés · Supprimer" counter badge
### Photos
- [ ] **Square thumbnails** — force aspect-ratio: 1/1 with object-cover
- [ ] **Lightbox wraps** — next on last → first photo; prev on first → last
- [ ] **Delete from lightbox** — trash icon in lightbox → removes photo from event metadata
### Search
- [ ] **Results clickable** — link events to `/timeline?date=YYYY-MM-DD`, notes to `/notes?date=...`
- [ ] **Result count header** — "X événements · Y notes"
- [ ] **Date/type filter chips** on search results
### Notes
- [ ] **Unsaved indicator** — show "Non enregistré" dot while textarea is dirty
- [ ] **Date jump input** — calendar picker to jump to any date (not just nav arrows)
### Milestones
- [ ] **Sort by date** within month, not creation order
### Settings
- [ ] **Pushover key eye toggle** — show/hide pasted key (copy pattern from API keys)
- [ ] **API key delete confirmation** — "This will break integrations using this key"
- [ ] **Accordion/tabs** — collapse long settings sections on mobile
- [ ] **Invite link TTL** — show expiry date next to copy-link button
### Event Modal
- [ ] **Timer resume indicator** — show "Reprise depuis 12:34" when re-opening active timer
- [ ] **Milk lot selection hint** — "Sélectionnez un lot qui sera marqué comme utilisé"
- [ ] **Photo size feedback** — client-side 5MB validation with error message
### Navigation
- [ ] **Baby switcher accessibility** — add `aria-label="Sélectionner bébé"` to select
- [ ] **Unsaved state warning** — prompt before navigating away from dirty notes
## Feature Backlog
- [ ] **Vaccination tracker** — dates, vaccine name, lot, next-due reminder + push alert
- [ ] **Doctor visit log** — notes, prescriptions, next appointment date
- [ ] **Teeth tracker** — visual mouth diagram, mark appearance date
- [ ] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns
- [ ] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
- [ ] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
- [ ] **Wellness check-in** — parent self-reports sleep/mood for postpartum monitoring
- [ ] **Weight gain on-track indicator** — vs last weigh-in projected line on growth chart
- [ ] **Contraction timer** — repurpose timer infra for pre-birth use
+11
View File
@@ -600,6 +600,17 @@ export default function DashboardPage() {
})}
</div>
{/* Empty state for new users */}
{todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
<div className="bg-indigo-50 dark:bg-indigo-950 border border-indigo-100 dark:border-indigo-900 rounded-xl p-5 mb-4 flex items-start gap-3">
<span className="text-2xl">👶</span>
<div>
<p className="text-sm font-semibold text-indigo-800 dark:text-indigo-200">Bienvenue sur Grow !</p>
<p className="text-xs text-indigo-600 dark:text-indigo-400 mt-0.5">Commencez par enregistrer un repas, une couche ou un sommeil via les boutons ci-dessous.</p>
</div>
</div>
)}
{/* Quick log */}
<div className="space-y-4">
{QUICK_LOG_GROUPS.map((group) => (
+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}>
+3
View File
@@ -82,6 +82,9 @@ function groupByMonth(milestones: Milestone[]) {
}
groups[seen[key]].items.push(m);
}
for (const g of groups) {
g.items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}
return groups;
}
+9 -1
View File
@@ -47,6 +47,7 @@ export default function NotesPage() {
const [currentDate, setCurrentDate] = useState<Date>(startOfDay(new Date()));
const [draftContent, setDraftContent] = useState<string>("");
const [dirty, setDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
@@ -99,6 +100,7 @@ export default function NotesPage() {
if (isDayLoading) return;
setDraftContent(currentNote?.content ?? "");
setSaveError(null);
setDirty(false);
}, [currentNote?.id, dateStr, isDayLoading]);
function goToPrevDay() {
@@ -141,6 +143,7 @@ export default function NotesPage() {
}
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
setDirty(false);
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Erreur inconnue");
} finally {
@@ -266,7 +269,7 @@ export default function NotesPage() {
<textarea
ref={textareaRef}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
onChange={(e) => { setDraftContent(e.target.value); setDirty(true); }}
onBlur={handleBlur}
placeholder="Notes du jour..."
rows={6}
@@ -289,6 +292,11 @@ export default function NotesPage() {
{saveError && (
<span className="text-xs text-red-500">{saveError}</span>
)}
{dirty ? (
<span className="text-xs text-amber-500"> Non enregistré</span>
) : !isSaving && draftContent.trim() ? (
<span className="text-xs text-emerald-500"> Enregistré</span>
) : null}
<button
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges || (!draftContent.trim() && !currentNote)}
+4 -6
View File
@@ -58,11 +58,11 @@ export default function PhotosPage() {
}
function prev() {
setLightbox((lb) => lb && lb.index > 0 ? { ...lb, index: lb.index - 1 } : lb);
setLightbox((lb) => lb ? { ...lb, index: (lb.index - 1 + lb.events.length) % lb.events.length } : lb);
}
function next() {
setLightbox((lb) => lb && lb.index < lb.events.length - 1 ? { ...lb, index: lb.index + 1 } : lb);
setLightbox((lb) => lb ? { ...lb, index: (lb.index + 1) % lb.events.length } : lb);
}
const current = lightbox ? lightbox.events[lightbox.index] : null;
@@ -150,16 +150,14 @@ export default function PhotosPage() {
<div className="flex items-center justify-between px-4 py-4 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<button
onClick={prev}
disabled={lightbox.index === 0}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition disabled:opacity-30"
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition"
>
<ChevronLeft className="w-5 h-5 text-white" />
</button>
<span className="text-white/60 text-xs">{lightbox.index + 1} / {lightbox.events.length}</span>
<button
onClick={next}
disabled={lightbox.index === lightbox.events.length - 1}
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition disabled:opacity-30"
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition"
>
<ChevronRight className="w-5 h-5 text-white" />
</button>
+11 -4
View File
@@ -8,6 +8,7 @@ import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Search, FileText, StickyNote } from "lucide-react";
import Link from "next/link";
interface SearchEvent {
id: string;
@@ -108,6 +109,12 @@ export default function SearchPage() {
<p className="text-sm text-slate-400 dark:text-slate-500 text-center py-12">Aucun résultat pour « {debouncedQ} »</p>
)}
{debouncedQ.length >= 2 && !isFetching && hasResults && (
<p className="text-xs text-slate-400 mb-3">
{events.length} événement{events.length !== 1 ? "s" : ""} · {notes.length} note{notes.length !== 1 ? "s" : ""}
</p>
)}
{events.length > 0 && (
<div className="mb-6">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-1.5">
@@ -119,7 +126,7 @@ export default function SearchPage() {
const cfg = EVENT_CONFIG[ev.type];
const summary = eventSummary(ev);
return (
<div key={ev.id} className="flex items-start gap-3 px-4 py-3">
<Link key={ev.id} href={`/timeline?date=${ev.startedAt.slice(0, 10)}&type=${ev.type}`} className="flex items-start gap-3 px-4 py-3 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/50 transition rounded-lg">
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0 mt-0.5`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
@@ -137,7 +144,7 @@ export default function SearchPage() {
{format(new Date(ev.startedAt), "d MMM yyyy HH:mm", { locale: fr })} · {ev.user.name}
</p>
</div>
</div>
</Link>
);
})}
</div>
@@ -152,14 +159,14 @@ export default function SearchPage() {
</h2>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{notes.map((note) => (
<div key={note.id} className="px-4 py-3">
<Link key={note.id} href={`/notes?date=${note.date.slice(0, 10)}`} className="block px-4 py-3 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/50 transition rounded-lg">
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">
{format(new Date(note.date), "d MMMM yyyy", { locale: fr })} · {note.user.name}
</p>
<p className="text-sm text-slate-700 dark:text-slate-200 line-clamp-3">
{highlight(note.content, debouncedQ)}
</p>
</div>
</Link>
))}
</div>
</div>
+44 -21
View File
@@ -5,7 +5,7 @@ import { signOut } from "next-auth/react";
import React, { useState, useEffect } from "react";
import { useBaby } from "@/contexts/baby-context";
import { getThresholds, saveThresholds, requestPermission } from "@/lib/notifications";
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, FileText, Plus, Mail, Send, Key, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
import { Copy, Check, Download, LogOut, Bell, Baby, Users, ChevronRight, ChevronDown, FileText, Plus, Mail, Send, Key, Trash2, Eye, EyeOff, ExternalLink } from "lucide-react";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
@@ -18,6 +18,19 @@ function Section({ title, children }: { title: string; children: React.ReactNode
);
}
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
const [open, setOpen] = useState(true);
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<button onClick={() => setOpen((o) => !o)} className="w-full flex items-center justify-between px-5 py-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-200">{title}</h2>
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && <div className="px-5 pb-5">{children}</div>}
</div>
);
}
interface Family {
id: string;
name: string;
@@ -74,8 +87,8 @@ function ApiKeysSection() {
setSaving(false);
}
async function deleteKey(id: string) {
if (!confirm("Révoquer cette clé ?")) return;
async function deleteKey(id: string, name: string) {
if (!confirm(`Supprimer la clé "${name}" ? Toute intégration utilisant cette clé sera interrompue.`)) return;
await fetch(`/api/api-keys/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["api-keys"] });
}
@@ -88,7 +101,7 @@ function ApiKeysSection() {
}
return (
<Section title="Clés API">
<SectionCard title="Clés API">
<div className="px-4 py-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-500 dark:text-slate-400">
@@ -130,7 +143,7 @@ function ApiKeysSection() {
{k.lastUsedAt ? ` · utilisée ${new Date(k.lastUsedAt).toLocaleDateString("fr")}` : " · jamais utilisée"}
</p>
</div>
<button onClick={() => deleteKey(k.id)}
<button onClick={() => deleteKey(k.id, k.name)}
className="p-1.5 rounded text-slate-300 dark:text-slate-600 hover:text-red-500 transition">
<Trash2 className="w-3.5 h-3.5" />
</button>
@@ -175,7 +188,7 @@ function ApiKeysSection() {
</button>
)}
</div>
</Section>
</SectionCard>
);
}
@@ -199,6 +212,7 @@ export default function SettingsPage() {
);
const [pushoverKey, setPushoverKey] = useState("");
const [showPushover, setShowPushover] = useState(false);
const [savingPushover, setSavingPushover] = useState(false);
const [pushoverMsg, setPushoverMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
@@ -408,7 +422,7 @@ export default function SettingsPage() {
<div className="space-y-4">
{/* Babies */}
<Section title="Bébés">
<SectionCard title="Bébés">
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{family?.babies.map((b) => (
<div key={b.id}>
@@ -467,11 +481,11 @@ export default function SettingsPage() {
</a>
</div>
</div>
</Section>
</SectionCard>
{/* Family */}
{family && (
<Section title={`Famille · ${family.users.length} membre${family.users.length > 1 ? "s" : ""}`}>
<SectionCard title={`Famille · ${family.users.length} membre${family.users.length > 1 ? "s" : ""}`}>
{family.users.map((u) => (
<div key={u.id} className="flex items-center gap-3 px-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0">
<div className="w-8 h-8 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center text-xs font-bold text-slate-600 dark:text-slate-300">
@@ -570,11 +584,11 @@ export default function SettingsPage() {
</form>
</div>
</div>
</Section>
</SectionCard>
)}
{/* Notifications */}
<Section title="Notifications">
<SectionCard title="Notifications">
<div className="p-4 space-y-4">
{!notifPermission ? (
<div className="flex items-start gap-3">
@@ -624,13 +638,22 @@ export default function SettingsPage() {
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Votre clé utilisateur Pushover</label>
<div className="flex gap-2">
<input
type="password"
value={pushoverKey}
onChange={(e) => setPushoverKey(e.target.value)}
placeholder="u••••••••••••••••••••••••••••••"
className={inputCls}
/>
<div className="relative flex-1">
<input
type={showPushover ? "text" : "password"}
value={pushoverKey}
onChange={(e) => setPushoverKey(e.target.value)}
placeholder="u••••••••••••••••••••••••••••••"
className={inputCls}
/>
<button
type="button"
onClick={() => setShowPushover((v) => !v)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{showPushover ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<button
type="button"
onClick={savePushoverKey}
@@ -649,11 +672,11 @@ export default function SettingsPage() {
</div>
</div>
</div>
</Section>
</SectionCard>
{/* Export */}
{selectedBaby && (
<Section title="Exporter les données">
<SectionCard title="Exporter les données">
<div className="p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
@@ -684,7 +707,7 @@ export default function SettingsPage() {
</button>
</div>
</div>
</Section>
</SectionCard>
)}
{/* API Keys */}
+14 -12
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import React, { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
@@ -172,10 +172,10 @@ function FeedHeatmap({ events, isDark }: { events: Event[]; isDark: boolean }) {
}
const maxVal = Math.max(...grid.flat(), 1);
const cellBg = (count: number): string => {
if (count === 0) return isDark ? "rgba(51,65,85,0.4)" : "rgb(241,245,249)";
const cellBg = (count: number): React.CSSProperties => {
if (count === 0) return { backgroundColor: isDark ? "rgba(51,65,85,0.4)" : "rgb(241,245,249)" };
const intensity = 0.15 + (count / maxVal) * 0.85;
return isDark ? `rgba(99,102,241,${intensity})` : `rgba(79,70,229,${intensity})`;
return { backgroundColor: isDark ? `rgba(99,102,241,${intensity})` : `rgba(79,70,229,${intensity})` };
};
return (
@@ -202,7 +202,7 @@ function FeedHeatmap({ events, isDark }: { events: Event[]; isDark: boolean }) {
key={hour}
title={`${DOW_LABELS[dowIdx]} ${hour.toString().padStart(2, "0")}h — ${count} repas`}
className="flex-1 h-5 rounded-[3px]"
style={{ backgroundColor: cellBg(count) }}
style={cellBg(count)}
/>
))}
</div>
@@ -210,13 +210,14 @@ function FeedHeatmap({ events, isDark }: { events: Event[]; isDark: boolean }) {
))}
</div>
</div>
<div className="flex items-center gap-1.5 mt-3 justify-end">
<span className="text-[10px] text-slate-400 dark:text-slate-500 mr-1">Moins</span>
{[0.15, 0.4, 0.65, 0.85, 1.0].map((v) => (
<div key={v} className="w-3 h-3 rounded-[3px]"
style={{ backgroundColor: isDark ? `rgba(99,102,241,${v})` : `rgba(79,70,229,${v})` }} />
<div className="flex items-center gap-1 mt-3">
<span className="text-xs text-slate-400 mr-1">Tétées :</span>
{[0, 1, 2, 3, 4].map((n) => (
<div key={n} className="flex flex-col items-center gap-0.5">
<div className="w-4 h-4 rounded-sm" style={cellBg(n)} />
<span className="text-[9px] text-slate-400">{n === 4 ? "4+" : n}</span>
</div>
))}
<span className="text-[10px] text-slate-400 dark:text-slate-500 ml-1">Plus</span>
</div>
</div>
);
@@ -256,7 +257,8 @@ function SleepTimeline({ events }: { events: Event[] }) {
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-4">Nuits 7 derniers jours</p>
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-0.5">Nuits 7 derniers jours</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Fenêtre 19h 11h</p>
<div className="relative h-4 ml-10 mb-0.5">
{markers.map((m) => (
<span key={m.label} className="absolute text-[9px] text-slate-400 dark:text-slate-500 -translate-x-1/2" style={{ left: `${m.pos}%` }}>
+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">
+37
View File
@@ -0,0 +1,37 @@
import { readFile } from "fs/promises";
import { join } from "path";
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? join(process.cwd(), "public", "uploads");
const MIME: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
gif: "image/gif",
};
export async function GET(_req: Request, { params }: { params: Promise<{ filename: string }> }) {
const { filename } = await params;
// Prevent path traversal
if (filename.includes("/") || filename.includes("..")) {
return new Response("Not found", { status: 404 });
}
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const contentType = MIME[ext];
if (!contentType) return new Response("Not found", { status: 404 });
try {
const data = await readFile(join(UPLOAD_DIR, filename));
return new Response(data, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
} catch {
return new Response("Not found", { status: 404 });
}
}
+7 -4
View File
@@ -1,12 +1,15 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { writeFile } from "fs/promises";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
// Configurable via env so Docker volume can be mounted at /data/uploads
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? join(process.cwd(), "public", "uploads");
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
@@ -22,8 +25,8 @@ export async function POST(req: Request) {
const filename = `${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const uploadDir = join(process.cwd(), "public", "uploads");
await writeFile(join(uploadDir, filename), buffer);
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(join(UPLOAD_DIR, filename), buffer);
return NextResponse.json({ url: `/uploads/${filename}` });
return NextResponse.json({ url: `/api/photos/${filename}` });
}
+13 -1
View File
@@ -122,6 +122,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
const [timerRunning, setTimerRunning] = useState(false);
const [timerSeconds, setTimerSeconds] = useState(0);
const [timerResumed, setTimerResumed] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<Date | null>(null);
const [loading, setLoading] = useState(false);
@@ -207,6 +208,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
startTimeRef.current = savedStart;
setTimerSeconds(elapsed);
setTimerRunning(true);
setTimerResumed(true);
timerRef.current = setInterval(() => setTimerSeconds((s) => s + 1), 1000);
}
return () => { if (timerRef.current) clearInterval(timerRef.current); };
@@ -218,6 +220,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
startTimeRef.current = start;
localStorage.setItem(TIMER_KEY, start.toISOString());
setTimerRunning(true);
setTimerResumed(false);
timerRef.current = setInterval(() => setTimerSeconds((s) => s + 1), 1000);
}
@@ -243,6 +246,10 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert("Image trop volumineuse (max 5 Mo)");
return;
}
setPhotoUploading(true);
const fd = new FormData();
fd.append("file", file);
@@ -422,9 +429,13 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
{/* Timer */}
{config.hasTimer && (
<div className="bg-slate-50 dark:bg-slate-700/50 rounded-xl p-4 text-center">
<div className="text-3xl sm:text-4xl font-mono font-bold text-slate-800 dark:text-slate-100 mb-3 tabular-nums">
<div className="text-3xl sm:text-4xl font-mono font-bold text-slate-800 dark:text-slate-100 mb-1 tabular-nums">
{formatTimer(timerSeconds)}
</div>
{timerRunning && timerResumed && (
<span className="text-xs text-amber-500">Reprise du chronomètre</span>
)}
<div className="mb-3" />
<button
type="button"
onClick={timerRunning ? stopTimer : startTimer}
@@ -473,6 +484,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
</div>
{form.bottleType === "maternal" && milkStocks.length > 0 && (
<div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">Sélectionnez un lot il sera marqué comme utilisé</p>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Lot de stock ({milkStocks.length} disponible{milkStocks.length > 1 ? "s" : ""})
</label>