feat: daily digest, photo timeline, milk expiry cron, batch delete, duration chart
- /api/cron/daily-digest: last feed/diaper, 24h sleep total, next med due - /photos: photo grid grouped by day, full-screen lightbox with prev/next - /api/cron/milk-expiry: push alert for lots expiring within 24h - Timeline: select mode with checkboxes, confirm + bulk DELETE - Stats: avg NAP/NIGHT_SLEEP duration trend dual-line chart (30d) - Nav: Photos link added under "Vie du bébé" drawer group Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } 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 Image from "next/image";
|
||||
|
||||
interface PhotoEvent {
|
||||
id: string;
|
||||
type: EventType;
|
||||
startedAt: string;
|
||||
notes?: string;
|
||||
metadata: { photo: string };
|
||||
user: { name: string };
|
||||
}
|
||||
|
||||
function dayLabel(key: string): string {
|
||||
const d = new Date(key);
|
||||
if (isToday(d)) return "Aujourd'hui";
|
||||
if (isYesterday(d)) return "Hier";
|
||||
return format(d, "EEEE d MMMM yyyy", { locale: fr });
|
||||
}
|
||||
|
||||
export default function PhotosPage() {
|
||||
const { selectedBaby } = useBaby();
|
||||
const [lightbox, setLightbox] = useState<{ events: PhotoEvent[]; index: number } | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["photo-events", selectedBaby?.id],
|
||||
queryFn: () =>
|
||||
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=500`).then((r) => r.json()),
|
||||
enabled: !!selectedBaby?.id,
|
||||
});
|
||||
|
||||
const allEvents: PhotoEvent[] = ((data?.events ?? []) as { id: string; type: EventType; startedAt: string; notes?: string; metadata?: Record<string, unknown>; user: { name: string } }[])
|
||||
.filter((e) => typeof e.metadata?.photo === "string")
|
||||
.map((e) => ({ ...e, metadata: { photo: e.metadata!.photo as string } }));
|
||||
|
||||
// Group by day
|
||||
const grouped: Record<string, PhotoEvent[]> = {};
|
||||
for (const ev of allEvents) {
|
||||
const key = format(new Date(ev.startedAt), "yyyy-MM-dd");
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(ev);
|
||||
}
|
||||
const days = Object.keys(grouped).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
// Flat list for lightbox navigation
|
||||
const flat = days.flatMap((d) => grouped[d]);
|
||||
|
||||
function openLightbox(ev: PhotoEvent) {
|
||||
const index = flat.findIndex((e) => e.id === ev.id);
|
||||
setLightbox({ events: flat, index });
|
||||
}
|
||||
|
||||
function prev() {
|
||||
setLightbox((lb) => lb && lb.index > 0 ? { ...lb, index: lb.index - 1 } : lb);
|
||||
}
|
||||
|
||||
function next() {
|
||||
setLightbox((lb) => lb && lb.index < lb.events.length - 1 ? { ...lb, index: lb.index + 1 } : lb);
|
||||
}
|
||||
|
||||
const current = lightbox ? lightbox.events[lightbox.index] : 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">
|
||||
Photos{selectedBaby ? ` — ${selectedBaby.name}` : ""}
|
||||
</h1>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && allEvents.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500 gap-3">
|
||||
<Camera className="w-10 h-10 opacity-40" />
|
||||
<p className="text-sm">Aucune photo enregistrée</p>
|
||||
<p className="text-xs text-center max-w-xs">Ajoute une photo lors de l'enregistrement d'un événement.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{days.map((day) => (
|
||||
<div key={day}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide capitalize">{dayLabel(day)}</span>
|
||||
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">{grouped[day].length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
|
||||
{grouped[day].map((ev) => {
|
||||
const cfg = EVENT_CONFIG[ev.type];
|
||||
return (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => openLightbox(ev)}
|
||||
className="relative aspect-square rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-800 group"
|
||||
>
|
||||
<Image
|
||||
src={ev.metadata.photo}
|
||||
alt={cfg.label}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/50 to-transparent px-1.5 py-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white font-medium truncate">{cfg.label}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && current && (
|
||||
<div className="fixed inset-0 z-50 bg-black/90 flex flex-col" onClick={() => setLightbox(null)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{EVENT_CONFIG[current.type].label}</p>
|
||||
<p className="text-white/60 text-xs">
|
||||
{format(new Date(current.startedAt), "d MMMM yyyy · HH:mm", { locale: fr })} · {current.user.name}
|
||||
</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>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center px-4 relative min-h-0" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative w-full h-full max-w-lg">
|
||||
<Image src={current.metadata.photo} alt="" fill className="object-contain" unoptimized />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user