Initial version of the app
This commit is contained in:
parent
0299acb4e6
commit
b8aa1a442a
5
app/globals.css
Normal file
5
app/globals.css
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html, body, #__next { height: 100%; }
|
||||||
15
app/layout.tsx
Normal file
15
app/layout.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import "./globals.css";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Study Tracker",
|
||||||
|
description: "Split your pages, track your progress, and finish on time.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
462
app/page.tsx
Normal file
462
app/page.tsx
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
"use client";
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { CalendarDays, CheckCircle2, Flame, RefreshCcw, Target, TrendingUp } from "lucide-react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip as RTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
interface DayPlan {
|
||||||
|
date: string;
|
||||||
|
planned: number;
|
||||||
|
actual: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanState {
|
||||||
|
totalPages: number;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
includeWeekends: boolean;
|
||||||
|
days: DayPlan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toYMD(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromYMD(s: string): Date {
|
||||||
|
const [y, m, d] = s.split("-").map(Number);
|
||||||
|
return new Date(y, (m || 1) - 1, d || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeekend(d: Date): boolean {
|
||||||
|
const day = d.getDay();
|
||||||
|
return day === 0 || day === 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eachDay(start: Date, end: Date, includeWeekends = true): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
const cur = new Date(start);
|
||||||
|
cur.setHours(0, 0, 0, 0);
|
||||||
|
const last = new Date(end);
|
||||||
|
last.setHours(0, 0, 0, 0);
|
||||||
|
while (cur <= last) {
|
||||||
|
if (includeWeekends || !isWeekend(cur)) {
|
||||||
|
out.push(toYMD(cur));
|
||||||
|
}
|
||||||
|
cur.setDate(cur.getDate() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function distribute(total: number, buckets: number): number[] {
|
||||||
|
if (buckets <= 0) return [];
|
||||||
|
const base = Math.floor(total / buckets);
|
||||||
|
const remainder = total - base * buckets;
|
||||||
|
const arr = Array.from({ length: buckets }, (_, i) => base + (i < remainder ? 1 : 0));
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPretty(s: string): string {
|
||||||
|
try {
|
||||||
|
const d = fromYMD(s);
|
||||||
|
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LS_KEY = "study-tracker-plan-v1";
|
||||||
|
|
||||||
|
function savePlan(p: PlanState) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPlan(): PlanState | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const raw = localStorage.getItem(LS_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as PlanState;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudyTrackerApp() {
|
||||||
|
const todayYMD = toYMD(new Date());
|
||||||
|
const [totalPages, setTotalPages] = useState<number>(200);
|
||||||
|
const [endDate, setEndDate] = useState<string>(toYMD(new Date(Date.now() + 7 * 86400000)));
|
||||||
|
const [includeWeekends, setIncludeWeekends] = useState<boolean>(true);
|
||||||
|
const [days, setDays] = useState<DayPlan[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = loadPlan();
|
||||||
|
if (stored) {
|
||||||
|
setTotalPages(stored.totalPages);
|
||||||
|
setEndDate(stored.endDate);
|
||||||
|
setIncludeWeekends(stored.includeWeekends);
|
||||||
|
setDays(stored.days);
|
||||||
|
} else {
|
||||||
|
buildPlan();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function buildPlan() {
|
||||||
|
const start = new Date();
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const end = fromYMD(endDate);
|
||||||
|
end.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (isNaN(totalPages) || totalPages <= 0 || end < start) {
|
||||||
|
setDays([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slots = eachDay(start, end, includeWeekends);
|
||||||
|
const allocation = distribute(totalPages, slots.length);
|
||||||
|
|
||||||
|
const newDays: DayPlan[] = slots.map((ymd, i) => {
|
||||||
|
const existing = days.find((d) => d.date === ymd);
|
||||||
|
return {
|
||||||
|
date: ymd,
|
||||||
|
planned: allocation[i] || 0,
|
||||||
|
actual: existing?.actual || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setDays(newDays);
|
||||||
|
|
||||||
|
const state: PlanState = {
|
||||||
|
totalPages,
|
||||||
|
startDate: todayYMD,
|
||||||
|
endDate,
|
||||||
|
includeWeekends,
|
||||||
|
days: newDays,
|
||||||
|
};
|
||||||
|
savePlan(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const state: PlanState = {
|
||||||
|
totalPages,
|
||||||
|
startDate: todayYMD,
|
||||||
|
endDate,
|
||||||
|
includeWeekends,
|
||||||
|
days,
|
||||||
|
};
|
||||||
|
savePlan(state);
|
||||||
|
}, [days, totalPages, endDate, includeWeekends, todayYMD]);
|
||||||
|
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const studied = days.reduce((sum, d) => sum + (d.actual || 0), 0);
|
||||||
|
const planned = days.reduce((sum, d) => sum + (d.planned || 0), 0);
|
||||||
|
const pct = planned > 0 ? Math.min(100, Math.round((studied / planned) * 100)) : 0;
|
||||||
|
const remaining = Math.max(0, planned - studied);
|
||||||
|
const avgPerDay = days.length ? Math.round(planned / days.length) : 0;
|
||||||
|
return { studied, planned, pct, remaining, avgPerDay };
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
let cumPlanned = 0;
|
||||||
|
let cumActual = 0;
|
||||||
|
return days.map((d) => {
|
||||||
|
cumPlanned += d.planned;
|
||||||
|
cumActual += d.actual || 0;
|
||||||
|
return {
|
||||||
|
date: formatPretty(d.date),
|
||||||
|
Planned: cumPlanned,
|
||||||
|
Actual: cumActual,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
function updateActual(ymd: string, value: number) {
|
||||||
|
setDays((prev) => prev.map((d) => (d.date === ymd ? { ...d, actual: Math.max(0, Math.floor(value || 0)) } : d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpActual(ymd: string, inc: number) {
|
||||||
|
setDays((prev) => prev.map((d) => (d.date === ymd ? { ...d, actual: Math.max(0, (d.actual || 0) + inc) } : d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetProgress() {
|
||||||
|
setDays((prev) => prev.map((d) => ({ ...d, actual: 0 })));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAll() {
|
||||||
|
setTotalPages(200);
|
||||||
|
setEndDate(toYMD(new Date(Date.now() + 7 * 86400000)));
|
||||||
|
setIncludeWeekends(true);
|
||||||
|
setDays([]);
|
||||||
|
if (typeof window !== "undefined") localStorage.removeItem(LS_KEY);
|
||||||
|
buildPlan();
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadlinePassed = new Date(endDate).setHours(0, 0, 0, 0) < new Date().setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen w-full bg-gradient-to-b from-slate-50 to-white p-4 md:p-8">
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6">
|
||||||
|
<header className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Study Tracker</h1>
|
||||||
|
<p className="text-slate-600">Split your pages, track your progress, and finish on time.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" onClick={resetProgress} className="gap-2">
|
||||||
|
<RefreshCcw className="h-4 w-4" /> Reset progress
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={resetAll} className="gap-2">
|
||||||
|
<RefreshCcw className="h-4 w-4" /> Reset all
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Card className="shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Target className="h-5 w-5" /> Plan your study
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4 md:grid-cols-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="totalPages">Total pages</Label>
|
||||||
|
<Input
|
||||||
|
id="totalPages"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={totalPages}
|
||||||
|
onChange={(e) => setTotalPages(Math.max(0, Math.floor(Number(e.target.value) || 0)))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="endDate" className="flex items-center gap-2">
|
||||||
|
<CalendarDays className="h-4 w-4" /> Deadline
|
||||||
|
</Label>
|
||||||
|
<Input id="endDate" type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border p-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">Include weekends</div>
|
||||||
|
<div className="text-sm text-slate-500">Count Saturdays & Sundays in the plan</div>
|
||||||
|
</div>
|
||||||
|
<Switch checked={includeWeekends} onCheckedChange={setIncludeWeekends} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button className="w-full" onClick={buildPlan}>
|
||||||
|
Build / Update plan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{deadlinePassed && (
|
||||||
|
<div className="md:col-span-4 text-sm text-red-600">The deadline date is in the past. Please choose a future date.</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<TrendingUp className="h-4 w-4" /> Overall progress
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm text-slate-600">
|
||||||
|
<span>
|
||||||
|
{stats.studied} / {stats.planned} pages
|
||||||
|
</span>
|
||||||
|
<span>{stats.pct}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={stats.pct} />
|
||||||
|
<div className="text-xs text-slate-500">{stats.remaining} pages remaining</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Flame className="h-4 w-4" /> Pace
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<div>
|
||||||
|
Average planned per day: <span className="font-semibold">{stats.avgPerDay}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Days in plan: <span className="font-semibold">{days.length}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Finish by: <span className="font-semibold">{formatPretty(endDate)}</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> Streaks
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<StreakBadge days={days} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="calendar" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="calendar">Daily</TabsTrigger>
|
||||||
|
<TabsTrigger value="chart">Progress chart</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="chart">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Planned vs. Actual (cumulative)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="h-72">
|
||||||
|
{chartData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="date" interval={Math.floor(chartData.length / 7)} />
|
||||||
|
<YAxis allowDecimals={false} />
|
||||||
|
<RTooltip />
|
||||||
|
<Legend />
|
||||||
|
<Line type="monotone" dataKey="Planned" dot={false} strokeWidth={2} />
|
||||||
|
<Line type="monotone" dataKey="Actual" dot={false} strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-slate-500">Create a plan to see the chart.</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="calendar">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{days.map((d) => {
|
||||||
|
const met = d.actual >= d.planned && d.planned > 0;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={d.date}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className={`rounded-2xl border p-4 shadow-sm ${met ? "ring-2 ring-green-500/40" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="font-semibold">{formatPretty(d.date)}</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="text-xs text-slate-500">Planned</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Pages planned for this day</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 text-sm text-slate-600">
|
||||||
|
Target: <span className="font-semibold">{d.planned}</span> pages
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Label htmlFor={`a-${d.date}`} className="text-xs">
|
||||||
|
Pages studied
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`a-${d.date}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={d.actual}
|
||||||
|
onChange={(e) => updateActual(d.date, Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => bumpActual(d.date, d.planned)}>
|
||||||
|
+{d.planned}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => bumpActual(d.date, 5)}>
|
||||||
|
+5
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{met && (
|
||||||
|
<div className="mt-3 flex items-center gap-2 text-green-700">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
<span className="text-sm">Great job! You hit today’s target 🎉</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<footer className="pt-4 text-center text-xs text-slate-500">
|
||||||
|
Data is stored locally in your browser. Coming next: user accounts and cloud sync.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StreakBadge({ days }: { days: DayPlan[] }) {
|
||||||
|
const today = toYMD(new Date());
|
||||||
|
const upToToday = days.filter((d) => d.date <= today);
|
||||||
|
let streak = 0;
|
||||||
|
for (let i = upToToday.length - 1; i >= 0; i--) {
|
||||||
|
const d = upToToday[i];
|
||||||
|
if (d.actual >= d.planned && d.planned > 0) streak++;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
const best = (() => {
|
||||||
|
let bestStreak = 0;
|
||||||
|
let cur = 0;
|
||||||
|
for (const d of upToToday) {
|
||||||
|
if (d.actual >= d.planned && d.planned > 0) cur++;
|
||||||
|
else {
|
||||||
|
bestStreak = Math.max(bestStreak, cur);
|
||||||
|
cur = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(bestStreak, cur);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between rounded-2xl border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm text-slate-600">Current streak</div>
|
||||||
|
<div className="text-2xl font-bold">{streak}🔥</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-px bg-slate-200" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm text-slate-600">Best streak</div>
|
||||||
|
<div className="text-2xl font-bold">{best}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
components/ui/button.tsx
Normal file
30
components/ui/button.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: "default" | "secondary" | "ghost" | "outline";
|
||||||
|
size?: "default" | "sm";
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants: Record<string, string> = {
|
||||||
|
default: "bg-black text-white hover:bg-black/90",
|
||||||
|
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200",
|
||||||
|
ghost: "bg-transparent hover:bg-slate-100",
|
||||||
|
outline: "border border-slate-300 hover:bg-slate-50",
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes: Record<string, string> = {
|
||||||
|
default: "h-10 px-4 py-2 rounded-2xl",
|
||||||
|
sm: "h-8 px-3 rounded-xl text-sm",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant = "default", size = "default", ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={twMerge("inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none shadow-sm", variants[variant], sizes[size], className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
15
components/ui/card.tsx
Normal file
15
components/ui/card.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={twMerge("rounded-2xl border bg-white", className)} {...props} />;
|
||||||
|
}
|
||||||
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={twMerge("p-4 pb-0", className)} {...props} />;
|
||||||
|
}
|
||||||
|
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <h3 className={twMerge("text-lg font-semibold", className)} {...props} />;
|
||||||
|
}
|
||||||
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={twMerge("p-4", className)} {...props} />;
|
||||||
|
}
|
||||||
15
components/ui/input.tsx
Normal file
15
components/ui/input.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={twMerge("flex h-10 w-full rounded-2xl border border-slate-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 disabled:opacity-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Input.displayName = "Input";
|
||||||
6
components/ui/label.tsx
Normal file
6
components/ui/label.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||||
|
return <label className={twMerge("text-sm font-medium text-slate-700", className)} {...props} />;
|
||||||
|
}
|
||||||
11
components/ui/progress.tsx
Normal file
11
components/ui/progress.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function Progress({ value = 0, className }: { value?: number; className?: string }) {
|
||||||
|
const pct = Math.min(100, Math.max(0, value));
|
||||||
|
return (
|
||||||
|
<div className={twMerge("h-3 w-full overflow-hidden rounded-full bg-slate-200", className)}>
|
||||||
|
<div className="h-full bg-black transition-all" style={{ width: pct + "%" }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/ui/switch.tsx
Normal file
14
components/ui/switch.tsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export function Switch({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: (v: boolean) => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onCheckedChange(!checked)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${checked ? "bg-black" : "bg-slate-300"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${checked ? "translate-x-5" : "translate-x-1"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
components/ui/tabs.tsx
Normal file
45
components/ui/tabs.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
import * as React from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
type TabsContextValue = {
|
||||||
|
value: string;
|
||||||
|
setValue: (v: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TabsCtx = React.createContext<TabsContextValue | null>(null);
|
||||||
|
|
||||||
|
export function Tabs({ defaultValue, className, children }: { defaultValue: string; className?: string; children: React.ReactNode }) {
|
||||||
|
const [value, setValue] = React.useState(defaultValue);
|
||||||
|
return (
|
||||||
|
<div className={twMerge("w-full", className)}>
|
||||||
|
<TabsCtx.Provider value={{ value, setValue }}>{children}</TabsCtx.Provider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsList({ className, children }: { className?: string; children: React.ReactNode }) {
|
||||||
|
return <div className={twMerge("mb-3 inline-flex items-center gap-2 rounded-2xl border bg-slate-100 p-1", className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsTrigger({ value, children }: { value: string; children: React.ReactNode }) {
|
||||||
|
const ctx = React.useContext(TabsCtx)!;
|
||||||
|
const active = ctx.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => ctx.setValue(value)}
|
||||||
|
className={twMerge(
|
||||||
|
"px-4 py-2 rounded-xl text-sm",
|
||||||
|
active ? "bg-white shadow border" : "text-slate-600 hover:text-slate-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabsContent({ value, children }: { value: string; children: React.ReactNode }) {
|
||||||
|
const ctx = React.useContext(TabsCtx)!;
|
||||||
|
if (ctx.value !== value) return null;
|
||||||
|
return <div className="mt-2">{children}</div>;
|
||||||
|
}
|
||||||
15
components/ui/tooltip.tsx
Normal file
15
components/ui/tooltip.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
export function TooltipProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
export function Tooltip({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
export function TooltipTrigger({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <span className={className} title="">{children}</span>;
|
||||||
|
}
|
||||||
|
export function TooltipContent({ children }: { children: React.ReactNode }) {
|
||||||
|
// Minimal: native title attribute on trigger is simpler; content hidden.
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
1
next.config.js
Normal file
1
next.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = { reactStrictMode: true };
|
||||||
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "study-tracker",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.2.3",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"framer-motion": "^11.0.0",
|
||||||
|
"recharts": "^2.8.0",
|
||||||
|
"lucide-react": "^0.441.0",
|
||||||
|
"tailwind-merge": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.7",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/node": "^20.10.6",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-next": "^14.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
12
tailwind.config.js
Normal file
12
tailwind.config.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./components/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"./pages/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user