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