feat: teeth tracker, feeding analysis, sidebar groups, timeline polish
Features: - Teeth tracker: Tooth model + migration, API routes (GET/POST upsert/DELETE), page at /teeth with 20-tooth grid, progress bar, inline mark/unmark flow - Dashboard feeding analysis: avg interval + last feed + next expected time widget - Fix dashboard baby?.name crash (selectedBaby?.name) UI/UX: - Sidebar: grouped into Suivi / Santé / Vie du bébé / Outils sections - Nav: active highlight works on child routes (startsWith) - Timeline: filter chips sticky on mobile; skeleton cards replace spinner - Stats: 500-event limit warning banner - Medications: 3 states — "Dans Xh" (orange), "Disponible" (green), "En retard de Xh" (amber) - Notes: skip auto-save on empty/whitespace blur Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+11
-10
@@ -12,6 +12,8 @@
|
|||||||
### Dashboard
|
### Dashboard
|
||||||
- [x] **Empty state for new users** — onboarding callout when no events today
|
- [x] **Empty state for new users** — onboarding callout when no events today
|
||||||
- [x] **Active timers show which baby** — baby name badge below event label
|
- [x] **Active timers show which baby** — baby name badge below event label
|
||||||
|
- [x] **Medication overdue** — 3 states: "Dans Xh" (orange), "Disponible" (green), "En retard de Xh" (amber)
|
||||||
|
- [x] **Feeding pattern analysis widget** — avg interval + last feed + next expected (in progress)
|
||||||
|
|
||||||
### Growth
|
### Growth
|
||||||
- [x] **Single-measurement chart** — shows point + WHO curves (was blank until 2+ points)
|
- [x] **Single-measurement chart** — shows point + WHO curves (was blank until 2+ points)
|
||||||
@@ -23,10 +25,13 @@
|
|||||||
### Stats
|
### Stats
|
||||||
- [x] **Heatmap legend** — shows actual counts (0 1 2 3 4+)
|
- [x] **Heatmap legend** — shows actual counts (0 1 2 3 4+)
|
||||||
- [x] **Sleep timeline label** — "Fenêtre 19h – 11h"
|
- [x] **Sleep timeline label** — "Fenêtre 19h – 11h"
|
||||||
|
- [x] **Event limit warning** — banner when 500-event limit hit
|
||||||
- [ ] **Chart zero-data gaps** — distinguish "no data" vs "zero events" visually
|
- [ ] **Chart zero-data gaps** — distinguish "no data" vs "zero events" visually
|
||||||
|
|
||||||
### Timeline
|
### Timeline
|
||||||
- [x] **Filter state sticky** — persisted in URL param `?type=...`
|
- [x] **Filter state sticky** — persisted in URL param `?type=...`
|
||||||
|
- [x] **Filter chips sticky on mobile** — `sticky top-0 z-10` container
|
||||||
|
- [x] **Loading skeleton** — skeleton cards replace spinner on first render
|
||||||
- [x] **Inline note save feedback** — ✓ flash after blur
|
- [x] **Inline note save feedback** — ✓ flash after blur
|
||||||
- [x] **Photo thumbnail clickable** — opens in new tab
|
- [x] **Photo thumbnail clickable** — opens in new tab
|
||||||
- [x] **Select mode indicator** — toggle shows "Annuler" when active
|
- [x] **Select mode indicator** — toggle shows "Annuler" when active
|
||||||
@@ -38,11 +43,12 @@
|
|||||||
### Search
|
### Search
|
||||||
- [x] **Results clickable** — events link to `/timeline?date=...`, notes to `/notes?date=...`
|
- [x] **Results clickable** — events link to `/timeline?date=...`, notes to `/notes?date=...`
|
||||||
- [x] **Result count header** — "X événements · Y notes"
|
- [x] **Result count header** — "X événements · Y notes"
|
||||||
- [ ] **Date/type filter chips** on search results
|
- [x] **Date/type filter chips** on search results
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
- [x] **Unsaved indicator** — amber dot while dirty, green ✓ after save
|
- [x] **Unsaved indicator** — amber dot while dirty, green ✓ after save
|
||||||
- [x] **Date jump input** — invisible date input overlaid on label, opens native calendar
|
- [x] **Date jump input** — invisible date input overlaid on label, opens native calendar
|
||||||
|
- [x] **Empty blur fix** — skip save on empty/whitespace content
|
||||||
|
|
||||||
### Milestones
|
### Milestones
|
||||||
- [x] **Sort by date** within month group
|
- [x] **Sort by date** within month group
|
||||||
@@ -60,19 +66,14 @@
|
|||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
- [x] **Baby switcher `aria-label`** — added to nav select
|
- [x] **Baby switcher `aria-label`** — added to nav select
|
||||||
- [ ] **Unsaved state warning** — prompt before leaving dirty notes page
|
- [x] **Active highlight on child routes** — uses `startsWith` instead of exact match
|
||||||
|
- [x] **Sidebar grouped** — sections: Suivi / Santé / Vie du bébé / Outils (replaces flat 14-item list)
|
||||||
## Remaining
|
|
||||||
|
|
||||||
- [ ] **Search date/type filter chips** — filter search results by date range or event type
|
|
||||||
- [ ] **Stats chart zero-data gaps** — distinguish "no data" vs "zero events" visually
|
|
||||||
- [ ] **Notes unsaved state nav warning** — prompt before leaving dirty notes page
|
|
||||||
|
|
||||||
## Feature Backlog
|
## Feature Backlog
|
||||||
|
|
||||||
- [ ] **Vaccination tracker** — dates, vaccine name, lot, next-due reminder + push alert
|
- [x] **Vaccination tracker** — full UI + API, overdue badges, done/upcoming sections
|
||||||
|
- [x] **Teeth tracker** — visual grid of 20 baby teeth, tap to mark appeared (in progress)
|
||||||
- [ ] **Doctor visit log** — notes, prescriptions, next appointment date
|
- [ ] **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
|
- [ ] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns
|
||||||
- [ ] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
|
- [ ] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
|
||||||
- [ ] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
|
- [ ] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tooth" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"babyId" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"appearedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Tooth_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Tooth_babyId_code_key" ON "Tooth"("babyId", "code");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Tooth" ADD CONSTRAINT "Tooth_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -136,6 +136,7 @@ model Baby {
|
|||||||
vaccinations Vaccination[]
|
vaccinations Vaccination[]
|
||||||
medicationReminders MedicationReminder[]
|
medicationReminders MedicationReminder[]
|
||||||
milkStocks MilkStock[]
|
milkStocks MilkStock[]
|
||||||
|
teeth Tooth[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,3 +280,16 @@ model MedicationReminder {
|
|||||||
lastSentAt DateTime?
|
lastSentAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Tooth {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
babyId String
|
||||||
|
baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade)
|
||||||
|
code String
|
||||||
|
appearedAt DateTime
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([babyId, code])
|
||||||
|
}
|
||||||
|
|||||||
@@ -511,9 +511,9 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
||||||
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), baby?.name);
|
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), selectedBaby?.name);
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), baby?.name);
|
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), selectedBaby?.name);
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [events]);
|
}, [events]);
|
||||||
@@ -540,6 +540,21 @@ export default function DashboardPage() {
|
|||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: feedData } = useQuery({
|
||||||
|
queryKey: ["events-feeds", selectedBaby?.id],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BREASTFEED`).then((r) => r.json()),
|
||||||
|
enabled: !!selectedBaby?.id,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const { data: bottleData } = useQuery({
|
||||||
|
queryKey: ["events-bottles", selectedBaby?.id],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BOTTLE`).then((r) => r.json()),
|
||||||
|
enabled: !!selectedBaby?.id,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
if (familyLoading) {
|
if (familyLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
@@ -559,6 +574,26 @@ export default function DashboardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feed pattern analysis
|
||||||
|
const allFeeds: Event[] = [
|
||||||
|
...(feedData?.events ?? []),
|
||||||
|
...(bottleData?.events ?? []),
|
||||||
|
].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
|
||||||
|
|
||||||
|
const feedAnalysis = (() => {
|
||||||
|
const recent = allFeeds.slice(0, 20);
|
||||||
|
if (recent.length < 2) return null;
|
||||||
|
const times = recent.map((e) => new Date(e.startedAt).getTime()).sort((a, b) => b - a);
|
||||||
|
const intervals = times.slice(0, -1).map((t, i) => t - times[i + 1]);
|
||||||
|
const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||||
|
const avgH = Math.floor(avgMs / 3600000);
|
||||||
|
const avgM = Math.round((avgMs % 3600000) / 60000);
|
||||||
|
const lastFeedTime = new Date(times[0]);
|
||||||
|
const nextExpected = new Date(times[0] + avgMs);
|
||||||
|
const untilNextMs = nextExpected.getTime() - Date.now();
|
||||||
|
return { avgH, avgM, lastFeedTime, nextExpected, untilNextMs };
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
@@ -601,6 +636,40 @@ export default function DashboardPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{feedAnalysis && (
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
|
||||||
|
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Repas</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">Intervalle moy.</p>
|
||||||
|
<p className="text-lg font-bold text-rose-600 dark:text-rose-400 font-mono">
|
||||||
|
{feedAnalysis.avgH}h{feedAnalysis.avgM.toString().padStart(2, "0")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">Dernier repas</p>
|
||||||
|
<p className="text-lg font-bold text-slate-700 dark:text-slate-200 font-mono">
|
||||||
|
{format(feedAnalysis.lastFeedTime, "HH:mm")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">Prochain estimé</p>
|
||||||
|
<p className={`text-lg font-bold font-mono ${feedAnalysis.untilNextMs < 0 ? "text-amber-500" : "text-indigo-600 dark:text-indigo-400"}`}>
|
||||||
|
{format(feedAnalysis.nextExpected, "HH:mm")}
|
||||||
|
</p>
|
||||||
|
{feedAnalysis.untilNextMs > 0 && (
|
||||||
|
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-0.5">
|
||||||
|
dans {Math.floor(feedAnalysis.untilNextMs / 3600000)}h{Math.floor((feedAnalysis.untilNextMs % 3600000) / 60000).toString().padStart(2, "0")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{feedAnalysis.untilNextMs < 0 && (
|
||||||
|
<p className="text-[10px] text-amber-500 dark:text-amber-400 mt-0.5">dépassé</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty state for new users */}
|
{/* Empty state for new users */}
|
||||||
{todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
|
{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">
|
<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">
|
||||||
@@ -658,14 +727,17 @@ export default function DashboardPage() {
|
|||||||
const nextAt = new Date(new Date(intake.startedAt).getTime() + p.intervalHours * 3600000);
|
const nextAt = new Date(new Date(intake.startedAt).getTime() + p.intervalHours * 3600000);
|
||||||
const waitMs = nextAt.getTime() - Date.now();
|
const waitMs = nextAt.getTime() - Date.now();
|
||||||
const tooSoon = waitMs > 0;
|
const tooSoon = waitMs > 0;
|
||||||
|
const overdue = !tooSoon && waitMs < -3600000;
|
||||||
const h = Math.floor(Math.abs(waitMs) / 3600000);
|
const h = Math.floor(Math.abs(waitMs) / 3600000);
|
||||||
const m = Math.floor((Math.abs(waitMs) % 3600000) / 60000);
|
const m = Math.floor((Math.abs(waitMs) % 3600000) / 60000);
|
||||||
const timeLabel = h > 0 ? `${h}h${m.toString().padStart(2, "0")}` : `${m}min`;
|
const timeLabel = h > 0 ? `${h}h${m.toString().padStart(2, "0")}` : `${m}min`;
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="flex items-center gap-3 px-4 py-3">
|
<div key={p.id} className="flex items-center gap-3 px-4 py-3">
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${tooSoon ? "bg-red-50 dark:bg-red-950" : "bg-green-50 dark:bg-green-950"}`}>
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${tooSoon ? "bg-orange-50 dark:bg-orange-950" : overdue ? "bg-amber-50 dark:bg-amber-950" : "bg-green-50 dark:bg-green-950"}`}>
|
||||||
{tooSoon
|
{tooSoon
|
||||||
? <AlertTriangle className="w-4 h-4 text-red-500" />
|
? <AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||||
|
: overdue
|
||||||
|
? <AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||||
: <CheckCircle2 className="w-4 h-4 text-green-500" />
|
: <CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -675,7 +747,9 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right flex-shrink-0">
|
<div className="text-right flex-shrink-0">
|
||||||
{tooSoon ? (
|
{tooSoon ? (
|
||||||
<p className="text-xs font-semibold text-red-600 dark:text-red-400">Dans {timeLabel}</p>
|
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400">Dans {timeLabel}</p>
|
||||||
|
) : overdue ? (
|
||||||
|
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400">En retard de {timeLabel}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs font-semibold text-green-600 dark:text-green-400">Disponible</p>
|
<p className="text-xs font-semibold text-green-600 dark:text-green-400">Disponible</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -162,11 +162,9 @@ export default function NotesPage() {
|
|||||||
async function handleBlur() {
|
async function handleBlur() {
|
||||||
if (isReadonly) return;
|
if (isReadonly) return;
|
||||||
const trimmed = draftContent.trim();
|
const trimmed = draftContent.trim();
|
||||||
// Only auto-save if content changed from what's stored
|
if (!trimmed) return;
|
||||||
const stored = currentNote?.content ?? "";
|
const stored = currentNote?.content ?? "";
|
||||||
if (trimmed === stored) return;
|
if (trimmed === stored) return;
|
||||||
// Don't auto-save empty content if there's no existing note
|
|
||||||
if (!trimmed && !currentNote) return;
|
|
||||||
await handleSave();
|
await handleSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ export default function StatsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const events: Event[] = eventsData?.events ?? [];
|
const events: Event[] = eventsData?.events ?? [];
|
||||||
|
const hitLimit = events.length >= 500;
|
||||||
const weeklyData = buildWeeklyData(events, days);
|
const weeklyData = buildWeeklyData(events, days);
|
||||||
const durationTrend = buildDurationTrend(events, Math.min(days, 30));
|
const durationTrend = buildDurationTrend(events, Math.min(days, 30));
|
||||||
const feedInterval = computeFeedInterval(events);
|
const feedInterval = computeFeedInterval(events);
|
||||||
@@ -377,6 +378,12 @@ export default function StatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hitLimit && (
|
||||||
|
<div className="mb-4 px-4 py-2.5 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-xl text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
Données limitées aux 500 derniers événements de la période — les graphiques peuvent être incomplets.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Today summary */}
|
{/* Today summary */}
|
||||||
<div className="grid grid-cols-3 gap-2 md:gap-3 mb-6">
|
<div className="grid grid-cols-3 gap-2 md:gap-3 mb-6">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useBaby } from "@/contexts/baby-context";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Check, Trash2, X } from "lucide-react";
|
||||||
|
|
||||||
|
interface Tooth {
|
||||||
|
id: string;
|
||||||
|
babyId: string;
|
||||||
|
code: string;
|
||||||
|
appearedAt: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPPER_CODES = ["u5l", "u4l", "u3l", "u2l", "u1l", "u1r", "u2r", "u3r", "u4r", "u5r"];
|
||||||
|
const LOWER_CODES = ["l5l", "l4l", "l3l", "l2l", "l1l", "l1r", "l2r", "l3r", "l4r", "l5r"];
|
||||||
|
|
||||||
|
const TOOTH_NAMES: Record<string, string> = {
|
||||||
|
"1": "Incisive centrale",
|
||||||
|
"2": "Incisive latérale",
|
||||||
|
"3": "Canine",
|
||||||
|
"4": "Première molaire",
|
||||||
|
"5": "Deuxième molaire",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERUPTION_AGES: Record<string, string> = {
|
||||||
|
"1": "6–12 mois",
|
||||||
|
"2": "9–16 mois",
|
||||||
|
"3": "16–23 mois",
|
||||||
|
"4": "14–18 mois",
|
||||||
|
"5": "23–31 mois",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getToothNumber(code: string): string {
|
||||||
|
// code is like "u1l", "l3r" — digit is at index 1
|
||||||
|
return code[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToothLabel(code: string): string {
|
||||||
|
const num = getToothNumber(code);
|
||||||
|
const jaw = code[0] === "u" ? "sup." : "inf.";
|
||||||
|
const side = code[2] === "l" ? "gauche" : "droite";
|
||||||
|
return `${TOOTH_NAMES[num]} ${jaw} ${side}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
"w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
|
||||||
|
|
||||||
|
type ActiveState =
|
||||||
|
| { type: "form"; code: string; date: string; notes: string }
|
||||||
|
| { type: "detail"; code: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export default function TeethPage() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { selectedBaby: baby } = useBaby();
|
||||||
|
const [active, setActive] = useState<ActiveState>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: teeth = [] } = useQuery<Tooth[]>({
|
||||||
|
queryKey: ["teeth", baby?.id],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(`/api/teeth?babyId=${baby!.id}`).then((r) => r.json()),
|
||||||
|
enabled: !!baby?.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const appearedCodes = new Set(teeth.map((t) => t.code));
|
||||||
|
const count = appearedCodes.size;
|
||||||
|
|
||||||
|
function getToothData(code: string): Tooth | undefined {
|
||||||
|
return teeth.find((t) => t.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToothClick(code: string) {
|
||||||
|
if (appearedCodes.has(code)) {
|
||||||
|
setActive(active?.type === "detail" && active.code === code ? null : { type: "detail", code });
|
||||||
|
} else {
|
||||||
|
const today = format(new Date(), "yyyy-MM-dd");
|
||||||
|
setActive(
|
||||||
|
active?.type === "form" && active.code === code
|
||||||
|
? null
|
||||||
|
: { type: "form", code, date: today, notes: "" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!baby || active?.type !== "form") return;
|
||||||
|
setSaving(true);
|
||||||
|
await fetch("/api/teeth", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
babyId: baby.id,
|
||||||
|
code: active.code,
|
||||||
|
appearedAt: new Date(active.date).toISOString(),
|
||||||
|
notes: active.notes || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ["teeth", baby.id] });
|
||||||
|
setSaving(false);
|
||||||
|
setActive(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(code: string) {
|
||||||
|
if (!baby) return;
|
||||||
|
setDeleting(code);
|
||||||
|
await fetch(`/api/teeth/${code}?babyId=${baby.id}`, { method: "DELETE" });
|
||||||
|
qc.invalidateQueries({ queryKey: ["teeth", baby.id] });
|
||||||
|
setDeleting(null);
|
||||||
|
setActive(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToothSquare({ code }: { code: string }) {
|
||||||
|
const appeared = appearedCodes.has(code);
|
||||||
|
const num = getToothNumber(code);
|
||||||
|
const isActive = active !== null && active.code === code;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToothClick(code)}
|
||||||
|
title={`${getToothLabel(code)} — ${ERUPTION_AGES[num]}`}
|
||||||
|
className={`
|
||||||
|
relative aspect-square w-full rounded-xl border-2 transition-all duration-150 flex flex-col items-center justify-center gap-0.5 text-xs font-semibold
|
||||||
|
${appeared
|
||||||
|
? "bg-emerald-50 dark:bg-emerald-950 border-emerald-400 dark:border-emerald-600 text-emerald-700 dark:text-emerald-300"
|
||||||
|
: "bg-slate-100 dark:bg-slate-800 border-dashed border-slate-300 dark:border-slate-600 text-slate-400 dark:text-slate-500"
|
||||||
|
}
|
||||||
|
${isActive ? "ring-2 ring-offset-1 ring-emerald-500 dark:ring-emerald-400" : "hover:border-emerald-400 dark:hover:border-emerald-500"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{appeared ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
<span className="text-[10px] leading-tight">{num}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">{num}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeToothLabel = active ? getToothLabel(active.code) : "";
|
||||||
|
const activeToothNum = active ? getToothNumber(active.code) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-8 pb-28 md:pb-8 max-w-lg mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Dents</h1>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||||
|
{count} / 20 dents apparues
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{Math.round((count / 20) * 100)} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2.5 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${(count / 20) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Diagram */}
|
||||||
|
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 p-4 space-y-3">
|
||||||
|
{/* Upper jaw label */}
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-600 text-center">
|
||||||
|
Mâchoire supérieure
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-10 gap-1.5">
|
||||||
|
{UPPER_CODES.map((code) => (
|
||||||
|
<ToothSquare key={code} code={code} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-dashed border-slate-200 dark:border-slate-700" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-10 gap-1.5">
|
||||||
|
{LOWER_CODES.map((code) => (
|
||||||
|
<ToothSquare key={code} code={code} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Lower jaw label */}
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-600 text-center">
|
||||||
|
Mâchoire inférieure
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="mt-3 flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-3.5 h-3.5 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 inline-block" />
|
||||||
|
Non apparue
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="w-3.5 h-3.5 rounded border-2 border-emerald-400 bg-emerald-50 dark:bg-emerald-950 inline-block" />
|
||||||
|
Apparue
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline panel for form or detail */}
|
||||||
|
{active && (
|
||||||
|
<div className="mt-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{activeToothLabel}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">
|
||||||
|
Éruption typique : {ERUPTION_AGES[activeToothNum]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setActive(null)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active.type === "form" ? (
|
||||||
|
<form onSubmit={handleSave} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1">
|
||||||
|
Date d'apparition <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={active.date}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActive((prev) =>
|
||||||
|
prev?.type === "form" ? { ...prev, date: e.target.value } : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1">
|
||||||
|
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={active.notes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setActive((prev) =>
|
||||||
|
prev?.type === "form" ? { ...prev, notes: e.target.value } : prev
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Observations..."
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(null)}
|
||||||
|
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex-1 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{saving ? "..." : "Enregistrer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
(() => {
|
||||||
|
const tooth = getToothData(active.code);
|
||||||
|
if (!tooth) return null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-200">
|
||||||
|
Apparue le{" "}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{format(new Date(tooth.appearedAt), "d MMMM yyyy", { locale: fr })}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{tooth.notes && (
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400">{tooth.notes}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(active.code)}
|
||||||
|
disabled={deleting === active.code}
|
||||||
|
className="flex items-center gap-2 mt-2 px-3 py-1.5 rounded-lg text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{deleting === active.code ? "Suppression..." : "Supprimer"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -160,7 +160,8 @@ export default function TimelinePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/* Filter chips */}
|
{/* Filter chips */}
|
||||||
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
<div className="sticky top-0 z-10 bg-white dark:bg-slate-900 -mx-4 px-4 py-2 border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<div className="flex gap-2 overflow-x-auto no-scrollbar">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleFilterChange("")}
|
onClick={() => handleFilterChange("")}
|
||||||
className={`flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
|
className={`flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
|
||||||
@@ -182,6 +183,7 @@ export default function TimelinePage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
@@ -210,8 +212,24 @@ export default function TimelinePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-center py-16">
|
<div className="space-y-6">
|
||||||
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
|
{[1, 2, 3].map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<div className="h-3 w-24 bg-slate-200 dark:bg-slate-700 rounded animate-pulse mb-3" />
|
||||||
|
<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">
|
||||||
|
{[1, 2, 3, 4].map((r) => (
|
||||||
|
<div key={r} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-slate-200 dark:bg-slate-700 animate-pulse flex-shrink-0" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<div className="h-3 w-28 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
||||||
|
<div className="h-2.5 w-16 bg-slate-100 dark:bg-slate-800 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-10 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function DELETE(req: Request, { params }: { params: Promise<{ code: string }> }) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const { code } = await params;
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const babyId = searchParams.get("babyId");
|
||||||
|
|
||||||
|
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
|
||||||
|
|
||||||
|
await prisma.tooth.delete({
|
||||||
|
where: { babyId_code: { babyId, code } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const babyId = searchParams.get("babyId");
|
||||||
|
|
||||||
|
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
|
||||||
|
|
||||||
|
const teeth = await prisma.tooth.findMany({
|
||||||
|
where: { babyId },
|
||||||
|
orderBy: { appearedAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(teeth);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { babyId, code, appearedAt, notes } = body;
|
||||||
|
|
||||||
|
if (!babyId || !code || !appearedAt) {
|
||||||
|
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooth = await prisma.tooth.upsert({
|
||||||
|
where: { babyId_code: { babyId, code } },
|
||||||
|
update: {
|
||||||
|
appearedAt: new Date(appearedAt),
|
||||||
|
notes: notes ?? null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
babyId,
|
||||||
|
code,
|
||||||
|
appearedAt: new Date(appearedAt),
|
||||||
|
notes: notes ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(tooth, { status: 201 });
|
||||||
|
}
|
||||||
+60
-4
@@ -29,6 +29,7 @@ import {
|
|||||||
Pill,
|
Pill,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Search,
|
Search,
|
||||||
|
Smile,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
@@ -43,6 +44,7 @@ const LINKS = [
|
|||||||
{ href: "/notes", label: "Notes", Icon: NotebookPen },
|
{ href: "/notes", label: "Notes", Icon: NotebookPen },
|
||||||
{ href: "/doctor-notes", label: "Médecin", Icon: Stethoscope },
|
{ href: "/doctor-notes", label: "Médecin", Icon: Stethoscope },
|
||||||
{ href: "/vaccinations", label: "Vaccins", Icon: Syringe },
|
{ href: "/vaccinations", label: "Vaccins", Icon: Syringe },
|
||||||
|
{ href: "/teeth", label: "Dents", Icon: Smile },
|
||||||
{ href: "/medications", label: "Médicaments", Icon: Pill },
|
{ href: "/medications", label: "Médicaments", Icon: Pill },
|
||||||
{ href: "/reminders", label: "Rappels", Icon: Bell },
|
{ href: "/reminders", label: "Rappels", Icon: Bell },
|
||||||
{ href: "/search", label: "Recherche", Icon: Search },
|
{ href: "/search", label: "Recherche", Icon: Search },
|
||||||
@@ -52,7 +54,7 @@ const LINKS = [
|
|||||||
// Groups shown in the mobile "More" drawer
|
// Groups shown in the mobile "More" drawer
|
||||||
const DRAWER_GROUPS: { label: string; hrefs: string[] }[] = [
|
const DRAWER_GROUPS: { label: string; hrefs: string[] }[] = [
|
||||||
{ label: "Suivi", hrefs: ["/calendar", "/stats", "/milk"] },
|
{ label: "Suivi", hrefs: ["/calendar", "/stats", "/milk"] },
|
||||||
{ label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/doctor-notes"] },
|
{ label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/teeth", "/doctor-notes"] },
|
||||||
{ label: "Vie du bébé", hrefs: ["/photos", "/milestones", "/notes", "/growth"] },
|
{ label: "Vie du bébé", hrefs: ["/photos", "/milestones", "/notes", "/growth"] },
|
||||||
{ label: "Outils", hrefs: ["/search"] },
|
{ label: "Outils", hrefs: ["/search"] },
|
||||||
];
|
];
|
||||||
@@ -158,9 +160,11 @@ export function SidebarNav() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<nav className="flex-1 px-3 py-4 space-y-0.5">
|
<nav className="flex-1 px-3 py-4 overflow-y-auto">
|
||||||
{LINKS.map(({ href, label, Icon }) => {
|
{/* Primary links — no header */}
|
||||||
const active = pathname === href;
|
{["/dashboard", "/timeline"].map((href) => {
|
||||||
|
const { label, Icon } = LINKS.find((l) => l.href === href)!;
|
||||||
|
const active = pathname === href || (href !== "/" && pathname.startsWith(href + "/"));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
@@ -176,6 +180,58 @@ export function SidebarNav() {
|
|||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<div className="my-2 border-t border-slate-100 dark:border-slate-800" />
|
||||||
|
|
||||||
|
{/* Grouped links */}
|
||||||
|
{DRAWER_GROUPS.map((group) => {
|
||||||
|
const groupLinks = group.hrefs
|
||||||
|
.map((href) => LINKS.find((l) => l.href === href))
|
||||||
|
.filter(Boolean) as typeof LINKS;
|
||||||
|
if (groupLinks.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={group.label}>
|
||||||
|
<p className="px-3 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-600">{group.label}</p>
|
||||||
|
{groupLinks.map(({ href, label, Icon }) => {
|
||||||
|
const active = pathname === href || (href !== "/" && pathname.startsWith(href + "/"));
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={href}
|
||||||
|
href={href}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
|
||||||
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? "text-indigo-600" : "text-slate-400"}`} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
{(() => {
|
||||||
|
const link = LINKS.find((l) => l.href === "/settings")!;
|
||||||
|
const active = pathname === "/settings";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? "bg-indigo-50 text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
|
||||||
|
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<link.Icon className={`w-4 h-4 flex-shrink-0 ${active ? "text-indigo-600" : "text-slate-400"}`} />
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<Link
|
<Link
|
||||||
href="/admin"
|
href="/admin"
|
||||||
|
|||||||
Reference in New Issue
Block a user