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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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