initial webapp
This commit is contained in:
108
app/api/auth/route.ts
Normal file
108
app/api/auth/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, users } from '@/lib/db';
|
||||
import { generateMemorableToken, isValidToken } from '@/lib/auth/tokens';
|
||||
import { setTokenCookie, getTokenCookie } from '@/lib/auth/cookies';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Check if user already has a token
|
||||
const existingToken = await getTokenCookie();
|
||||
|
||||
if (existingToken) {
|
||||
// Verify token exists in database
|
||||
const [user] = await db.select().from(users).where(eq(users.token, existingToken));
|
||||
|
||||
if (user) {
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
token: existingToken,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ authenticated: false });
|
||||
} catch (error) {
|
||||
console.error('Auth check error:', error);
|
||||
return NextResponse.json({ authenticated: false }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action, token } = body;
|
||||
|
||||
if (action === 'create') {
|
||||
// Generate new token and create user
|
||||
const newToken = generateMemorableToken();
|
||||
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
token: newToken,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await setTokenCookie(newToken);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
token: newToken,
|
||||
userId: newUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'login' && token) {
|
||||
// Validate token format
|
||||
if (!isValidToken(token)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid token format',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if token exists
|
||||
const [user] = await db.select().from(users).where(eq(users.token, token));
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Token not found',
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
await setTokenCookie(token);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
token,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid action',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Auth error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
55
app/api/habits/[id]/log/route.ts
Normal file
55
app/api/habits/[id]/log/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, habits, users, habitLogs } from '@/lib/db';
|
||||
import { getTokenCookie } from '@/lib/auth/cookies';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
async function getUserFromToken() {
|
||||
const token = await getTokenCookie();
|
||||
if (!token) return null;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.token, token));
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const habitId = parseInt(id);
|
||||
|
||||
if (isNaN(habitId)) {
|
||||
return NextResponse.json({ error: 'Invalid habit ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await getUserFromToken();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify habit belongs to user
|
||||
const [habit] = await db
|
||||
.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||
|
||||
if (!habit) {
|
||||
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { note } = body;
|
||||
|
||||
// Create log entry
|
||||
const [log] = await db
|
||||
.insert(habitLogs)
|
||||
.values({
|
||||
habitId,
|
||||
note,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ log });
|
||||
} catch (error) {
|
||||
console.error('Log habit error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
105
app/api/habits/route.ts
Normal file
105
app/api/habits/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db, habits, users, habitLogs } from '@/lib/db';
|
||||
import { getTokenCookie } from '@/lib/auth/cookies';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
|
||||
async function getUserFromToken() {
|
||||
const token = await getTokenCookie();
|
||||
if (!token) return null;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.token, token));
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getUserFromToken();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get all non-archived habits with their latest log and statistics
|
||||
const userHabits = await db
|
||||
.select({
|
||||
id: habits.id,
|
||||
name: habits.name,
|
||||
type: habits.type,
|
||||
targetFrequency: habits.targetFrequency,
|
||||
color: habits.color,
|
||||
icon: habits.icon,
|
||||
createdAt: habits.createdAt,
|
||||
// Get the latest log time
|
||||
lastLoggedAt: sql<Date>`(
|
||||
SELECT MAX(logged_at)
|
||||
FROM ${habitLogs}
|
||||
WHERE habit_id = ${habits.id}
|
||||
)`.as('lastLoggedAt'),
|
||||
// Get total logs count
|
||||
totalLogs: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${habitLogs}
|
||||
WHERE habit_id = ${habits.id}
|
||||
)`.as('totalLogs'),
|
||||
// Get logs in last 7 days
|
||||
logsLastWeek: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${habitLogs}
|
||||
WHERE habit_id = ${habits.id}
|
||||
AND logged_at >= NOW() - INTERVAL '7 days'
|
||||
)`.as('logsLastWeek'),
|
||||
// Get logs in last 30 days
|
||||
logsLastMonth: sql<number>`(
|
||||
SELECT COUNT(*)
|
||||
FROM ${habitLogs}
|
||||
WHERE habit_id = ${habits.id}
|
||||
AND logged_at >= NOW() - INTERVAL '30 days'
|
||||
)`.as('logsLastMonth'),
|
||||
})
|
||||
.from(habits)
|
||||
.where(and(eq(habits.userId, user.id), eq(habits.isArchived, false)))
|
||||
.orderBy(desc(habits.createdAt));
|
||||
|
||||
return NextResponse.json({ habits: userHabits });
|
||||
} catch (error) {
|
||||
console.error('Get habits error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getUserFromToken();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name, type, targetFrequency, color, icon } = body;
|
||||
|
||||
if (!name || !type) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Name and type are required',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const [newHabit] = await db
|
||||
.insert(habits)
|
||||
.values({
|
||||
userId: user.id,
|
||||
name,
|
||||
type,
|
||||
targetFrequency,
|
||||
color,
|
||||
icon,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({ habit: newHabit });
|
||||
} catch (error) {
|
||||
console.error('Create habit error:', error);
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
398
app/dashboard/page.tsx
Normal file
398
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import {
|
||||
Plus,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Activity,
|
||||
Clock,
|
||||
Calendar,
|
||||
Target,
|
||||
Copy,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface Habit {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'positive' | 'neutral' | 'negative';
|
||||
lastLoggedAt: string | null;
|
||||
totalLogs: number;
|
||||
logsLastWeek: number;
|
||||
logsLastMonth: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [userToken, setUserToken] = useState<string | null>(null);
|
||||
const [showNewHabitDialog, setShowNewHabitDialog] = useState(false);
|
||||
const [newHabitName, setNewHabitName] = useState('');
|
||||
const [newHabitType, setNewHabitType] = useState<'positive' | 'neutral' | 'negative'>('neutral');
|
||||
const [copiedToken, setCopiedToken] = useState(false);
|
||||
|
||||
// Check authentication
|
||||
const { data: authData, isLoading: authLoading } = useQuery({
|
||||
queryKey: ['auth'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/auth');
|
||||
const data = await res.json();
|
||||
if (!data.authenticated) {
|
||||
router.push('/');
|
||||
}
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (authData?.token) {
|
||||
setUserToken(authData.token);
|
||||
}
|
||||
}, [authData]);
|
||||
|
||||
// Fetch habits
|
||||
const { data: habitsData, isLoading: habitsLoading } = useQuery({
|
||||
queryKey: ['habits'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/habits');
|
||||
if (!res.ok) throw new Error('Failed to fetch habits');
|
||||
return res.json();
|
||||
},
|
||||
enabled: !!authData?.authenticated,
|
||||
});
|
||||
|
||||
// Log habit mutation
|
||||
const logHabitMutation = useMutation({
|
||||
mutationFn: async (habitId: number) => {
|
||||
const res = await fetch(`/api/habits/${habitId}/log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to log habit');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Create habit mutation
|
||||
const createHabitMutation = useMutation({
|
||||
mutationFn: async (data: { name: string; type: string }) => {
|
||||
const res = await fetch('/api/habits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create habit');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||
setShowNewHabitDialog(false);
|
||||
setNewHabitName('');
|
||||
setNewHabitType('neutral');
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreateHabit = () => {
|
||||
if (newHabitName.trim()) {
|
||||
createHabitMutation.mutate({
|
||||
name: newHabitName.trim(),
|
||||
type: newHabitType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const copyToken = () => {
|
||||
if (userToken) {
|
||||
navigator.clipboard.writeText(userToken);
|
||||
setCopiedToken(true);
|
||||
setTimeout(() => setCopiedToken(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const getHabitCardClass = (type: string) => {
|
||||
switch (type) {
|
||||
case 'positive':
|
||||
return 'border-emerald-600 bg-emerald-950/50 hover:bg-emerald-900/50 hover:border-emerald-500';
|
||||
case 'negative':
|
||||
return 'border-red-600 bg-red-950/50 hover:bg-red-900/50 hover:border-red-500';
|
||||
default:
|
||||
return 'border-zinc-700 bg-zinc-950/50 hover:bg-zinc-900/50 hover:border-zinc-600';
|
||||
}
|
||||
};
|
||||
|
||||
const getHabitIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'positive':
|
||||
return <TrendingUp className="h-5 w-5 text-emerald-500" />;
|
||||
case 'negative':
|
||||
return <TrendingDown className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <Activity className="h-5 w-5 text-zinc-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getHabitBadgeVariant = (type: string): 'default' | 'secondary' | 'destructive' | 'outline' => {
|
||||
switch (type) {
|
||||
case 'positive':
|
||||
return 'default';
|
||||
case 'negative':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const getAverageFrequency = (habit: Habit) => {
|
||||
const daysSinceCreation = Math.max(
|
||||
1,
|
||||
Math.floor((Date.now() - new Date(habit.createdAt).getTime()) / (1000 * 60 * 60 * 24)),
|
||||
);
|
||||
|
||||
if (daysSinceCreation <= 7) {
|
||||
const avg = habit.totalLogs / daysSinceCreation;
|
||||
return `${avg.toFixed(1)}/day`;
|
||||
} else if (daysSinceCreation <= 30) {
|
||||
const weeks = daysSinceCreation / 7;
|
||||
const avg = habit.totalLogs / weeks;
|
||||
return `${avg.toFixed(1)}/week`;
|
||||
} else {
|
||||
const months = daysSinceCreation / 30;
|
||||
const avg = habit.totalLogs / months;
|
||||
return `${avg.toFixed(1)}/month`;
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || habitsLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black">
|
||||
<div className="text-zinc-400">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const habits = habitsData?.habits || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
<div className="mx-auto max-w-7xl p-4 md:p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="mb-2 text-4xl font-bold text-white">Track Every Day</h1>
|
||||
<p className="text-zinc-400">Build better habits, one day at a time.</p>
|
||||
</div>
|
||||
<Dialog open={showNewHabitDialog} onOpenChange={setShowNewHabitDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="lg" className="bg-emerald-600 hover:bg-emerald-700">
|
||||
<Plus className="mr-2 h-5 w-5" />
|
||||
New Habit
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="border-zinc-800 bg-zinc-950">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Habit</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new habit to track. Choose whether it's something you want to do more,
|
||||
less, or just monitor.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Habit Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Exercise, Read, Meditate..."
|
||||
value={newHabitName}
|
||||
onChange={(e) => setNewHabitName(e.target.value)}
|
||||
className="border-zinc-800 bg-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Habit Type</Label>
|
||||
<Select value={newHabitType} onValueChange={(value: any) => setNewHabitType(value)}>
|
||||
<SelectTrigger className="border-zinc-800 bg-zinc-900">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-zinc-800 bg-zinc-900">
|
||||
<SelectItem value="positive">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||
<span>Positive - Something to do more</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="neutral">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-zinc-500" />
|
||||
<span>Neutral - Just tracking</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="negative">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
<span>Negative - Something to reduce</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewHabitDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateHabit}
|
||||
disabled={!newHabitName.trim() || createHabitMutation.isPending}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
{createHabitMutation.isPending ? 'Creating...' : 'Create Habit'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Token Alert */}
|
||||
{userToken && (
|
||||
<Alert className="border-zinc-800 bg-zinc-950">
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-zinc-400">Your access token:</p>
|
||||
<code className="rounded bg-zinc-900 px-2 py-1 font-mono text-sm text-white">
|
||||
{userToken}
|
||||
</code>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={copyToken} className="ml-4">
|
||||
{copiedToken ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{copiedToken ? 'Copied!' : 'Copy token'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Habits Grid */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{habits.map((habit: Habit) => (
|
||||
<Card
|
||||
key={habit.id}
|
||||
className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
|
||||
habit.type,
|
||||
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
|
||||
onClick={() => logHabitMutation.mutate(habit.id)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">{habit.name}</CardTitle>
|
||||
{getHabitIcon(habit.type)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Last logged */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Clock className="h-4 w-4 text-zinc-500" />
|
||||
<span className="text-zinc-400">
|
||||
{habit.lastLoggedAt
|
||||
? formatDistanceToNow(new Date(habit.lastLoggedAt), { addSuffix: true })
|
||||
: 'Never logged'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-zinc-800" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{habit.totalLogs}</p>
|
||||
<p className="text-xs text-zinc-500">Total</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{habit.logsLastWeek}</p>
|
||||
<p className="text-xs text-zinc-500">This week</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Average frequency badge */}
|
||||
<div className="flex justify-center pt-2">
|
||||
<Badge variant={getHabitBadgeVariant(habit.type)} className="font-normal">
|
||||
<Target className="mr-1 h-3 w-3" />
|
||||
{getAverageFrequency(habit)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Motivational message */}
|
||||
{habit.type === 'positive' && habit.totalLogs > 0 && (
|
||||
<p className="pt-2 text-center text-xs text-emerald-400">
|
||||
Keep up the great work! 💪
|
||||
</p>
|
||||
)}
|
||||
|
||||
{habit.type === 'negative' && habit.lastLoggedAt && (
|
||||
<p className="pt-2 text-center text-xs text-red-400">
|
||||
Stay mindful, you've got this! 🎯
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{habits.length === 0 && (
|
||||
<Card className="border-dashed border-zinc-800 bg-zinc-950/50 md:col-span-2 lg:col-span-3 xl:col-span-4">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Calendar className="mb-4 h-12 w-12 text-zinc-600" />
|
||||
<h3 className="mb-2 text-lg font-semibold">No habits yet</h3>
|
||||
<p className="mb-4 text-sm text-zinc-500">Start building better habits today</p>
|
||||
<Button
|
||||
onClick={() => setShowNewHabitDialog(true)}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Your First Habit
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@@ -1,4 +1,4 @@
|
||||
@import 'tailwindcss';
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
--font-sans: var(--font-sans);
|
||||
|
||||
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
|
||||
--background-image-gradient-conic: conic-gradient(
|
||||
from 180deg at 50% 50%,
|
||||
var(--tw-gradient-stops)
|
||||
);
|
||||
--background-image-gradient-conic: conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops));
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
@@ -90,41 +87,14 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--primary: 142.1 76.2% 36.3%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
@@ -135,12 +105,13 @@
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--ring: 142.1 76.2% 36.3%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +119,9 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
@apply bg-black;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
@@ -1,22 +1,23 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import PlausibleProvider from "next-plausible";
|
||||
import "./globals.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import PlausibleProvider from 'next-plausible';
|
||||
import './globals.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Providers } from './providers';
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
|
||||
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
|
||||
export const viewport: Viewport = {
|
||||
colorScheme: "dark",
|
||||
colorScheme: 'dark',
|
||||
themeColor: [
|
||||
//{ media: "(prefers-color-scheme: light)", color: "#f5f5f5" },
|
||||
//{ media: "(prefers-color-scheme: dark)", color: "#171717" },
|
||||
{ color: "#052e16" },
|
||||
{ color: '#052e16' },
|
||||
],
|
||||
};
|
||||
export const metadata: Metadata = {
|
||||
title: "Track Every Day!",
|
||||
description: "A web app for tracking habits, activities and vices.",
|
||||
title: 'Track Every Day!',
|
||||
description: 'A web app for tracking habits, activities and vices.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -35,13 +36,8 @@ export default function RootLayout({
|
||||
trackOutboundLinks={true}
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={cn(
|
||||
"min-h-screen bg-background font-sans antialiased",
|
||||
inter.variable
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<body className={cn('bg-background min-h-screen font-sans antialiased', inter.variable)}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
15
app/page.tsx
15
app/page.tsx
@@ -1,5 +1,14 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Home() {
|
||||
redirect("/welcome");
|
||||
export default async function Home() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('habit-tracker-token');
|
||||
|
||||
// If user has a token, redirect to dashboard, otherwise to welcome
|
||||
if (token?.value) {
|
||||
redirect('/dashboard');
|
||||
} else {
|
||||
redirect('/welcome');
|
||||
}
|
||||
}
|
||||
|
20
app/providers.tsx
Normal file
20
app/providers.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactNode, useState } from 'react';
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
}
|
@@ -3,9 +3,5 @@ export default function Layout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen block bg-emerald-950 text-neutral-300">
|
||||
<div className="m-4 md:my-16 md:mx-auto max-w-96">{children}</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="flex min-h-screen items-center justify-center bg-black p-4">{children}</div>;
|
||||
}
|
||||
|
@@ -1,12 +1,200 @@
|
||||
export default function Home() {
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Card, CardContent, CardDescription, 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 { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { LogIn, Sparkles, Shield, Zap, ArrowLeft } from 'lucide-react';
|
||||
|
||||
export default function Welcome() {
|
||||
const router = useRouter();
|
||||
const [showTokenInput, setShowTokenInput] = useState(false);
|
||||
const [tokenInput, setTokenInput] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const createAccountMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'create' }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create account');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push('/dashboard');
|
||||
},
|
||||
onError: () => {
|
||||
setError('Failed to create account. Please try again.');
|
||||
},
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'login', token }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Failed to login');
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push('/dashboard');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setError(error.message || 'Failed to login. Please check your token.');
|
||||
},
|
||||
});
|
||||
|
||||
const handleTokenLogin = () => {
|
||||
if (tokenInput.trim()) {
|
||||
setError('');
|
||||
loginMutation.mutate(tokenInput.trim());
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="shadow-xl rounded-lg w-full border px-6 py-12 bg-emerald-900 border-emerald-700 ">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-4xl font-bold">📅 Track Every Day</span>
|
||||
<span className="mt-4 text-center">
|
||||
A web app for logging your habits, vices and activities.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Card className="w-full max-w-md border-zinc-800 bg-zinc-950">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-950">
|
||||
<span className="text-3xl">📅</span>
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold">Track Every Day</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Build better habits, one day at a time. No email or password required.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!showTokenInput ? (
|
||||
<div className="space-y-6">
|
||||
{/* Features */}
|
||||
<div className="grid gap-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
|
||||
<Shield className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-zinc-400">Privacy-first: No personal data required</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
|
||||
<Zap className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-zinc-400">Instant access with a unique token</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
|
||||
<Sparkles className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-zinc-400">Track positive, neutral, or negative habits</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-zinc-800" />
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => createAccountMutation.mutate()}
|
||||
disabled={createAccountMutation.isPending}
|
||||
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
size="lg"
|
||||
>
|
||||
{createAccountMutation.isPending ? (
|
||||
<>Creating your account...</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
Start Tracking Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full bg-zinc-800" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-zinc-950 px-2 text-zinc-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowTokenInput(true)}
|
||||
className="w-full border-zinc-800 hover:bg-zinc-900"
|
||||
size="lg"
|
||||
>
|
||||
<LogIn className="mr-2 h-4 w-4" />I Have a Token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowTokenInput(false);
|
||||
setTokenInput('');
|
||||
setError('');
|
||||
}}
|
||||
className="mb-2 -ml-2 text-zinc-500 hover:text-white"
|
||||
size="sm"
|
||||
>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token">Access Token</Label>
|
||||
<Input
|
||||
id="token"
|
||||
type="text"
|
||||
placeholder="e.g., happy-blue-cat-1234"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleTokenLogin()}
|
||||
className="border-zinc-800 bg-zinc-900 placeholder:text-zinc-600"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Enter the token you saved from your previous session
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleTokenLogin}
|
||||
disabled={loginMutation.isPending || !tokenInput.trim()}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-700"
|
||||
size="lg"
|
||||
>
|
||||
{loginMutation.isPending ? 'Logging in...' : 'Access My Habits'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert className="mt-4 border-red-900 bg-red-950">
|
||||
<AlertDescription className="text-sm text-red-400">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-zinc-800 pt-6">
|
||||
<p className="text-center text-xs text-zinc-500">
|
||||
Your habits are tied to a unique token. Save it to access your data across devices. No
|
||||
account creation or personal information required.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user