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:
@@ -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) => (
|
||||
|
||||
@@ -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 P3–P97</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />
|
||||
OMS P3–P97
|
||||
<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. P3–P97 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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}%` }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user