initial webapp
This commit is contained in:
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 105,
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
139
README.md
139
README.md
@@ -1,4 +1,137 @@
|
|||||||
# trackevery-day
|
# 📅 Track Every Day
|
||||||
|
|
||||||
https://trackevery.day/
|
https://trackevery.day/
|
||||||
Track anything, every day.
|
|
||||||
|
A simple, privacy-focused habit tracking web app. Track anything, every day.
|
||||||
|
|
||||||
|
## ✨ Features
|
||||||
|
|
||||||
|
- **Token-based authentication** - No email or password required
|
||||||
|
- **Privacy-first** - Your data is tied to a unique token
|
||||||
|
- **Simple interface** - Click to log, see stats instantly
|
||||||
|
- **Habit types** - Track positive, neutral, or negative habits
|
||||||
|
- **Real-time statistics** - See averages, streaks, and time since last log
|
||||||
|
- **Cross-device sync** - Use your token to access data anywhere
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- PostgreSQL database (local or hosted)
|
||||||
|
- pnpm (or npm/yarn)
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.schulze.network/schulze/trackevery-day.git
|
||||||
|
cd trackevery-day
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set up environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a .env.local file with:
|
||||||
|
POSTGRES_URL="your-postgres-connection-string"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Set up the database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate migrations
|
||||||
|
pnpm db:generate
|
||||||
|
|
||||||
|
# Push schema to database
|
||||||
|
pnpm db:push
|
||||||
|
|
||||||
|
# Or run migrations
|
||||||
|
pnpm db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to start tracking!
|
||||||
|
|
||||||
|
## 🏗️ Tech Stack
|
||||||
|
|
||||||
|
- **Next.js 15** - React framework with App Router
|
||||||
|
- **Drizzle ORM** - Type-safe database queries
|
||||||
|
- **PostgreSQL** - Database (works with Vercel Postgres, Neon, Supabase, etc.)
|
||||||
|
- **React Query** - Data fetching and caching
|
||||||
|
- **Tailwind CSS** - Styling
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
|
||||||
|
## 📱 How It Works
|
||||||
|
|
||||||
|
1. **First Visit**: A unique token is generated (e.g., `happy-blue-cat-1234`)
|
||||||
|
2. **Save Your Token**: This is your key to access your data
|
||||||
|
3. **Track Habits**: Click habit cards to log executions
|
||||||
|
4. **View Stats**: See real-time statistics and progress
|
||||||
|
5. **Access Anywhere**: Use your token to login from any device
|
||||||
|
|
||||||
|
## 🔒 Privacy
|
||||||
|
|
||||||
|
- No personal information required
|
||||||
|
- No email or password needed
|
||||||
|
- Your token is your only identifier
|
||||||
|
- Data is only accessible with your token
|
||||||
|
|
||||||
|
## 📝 Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users table
|
||||||
|
users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Habits table
|
||||||
|
habits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT CHECK (type IN ('positive', 'neutral', 'negative')),
|
||||||
|
is_archived BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Habit logs table
|
||||||
|
habit_logs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
habit_id INTEGER REFERENCES habits(id),
|
||||||
|
logged_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
note TEXT
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run development server
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
pnpm check
|
||||||
|
|
||||||
|
# Database management
|
||||||
|
pnpm db:studio # Open Drizzle Studio
|
||||||
|
pnpm db:generate # Generate migrations
|
||||||
|
pnpm db:push # Push schema changes
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📄 License
|
||||||
|
|
||||||
|
GPL-3.0 License - see LICENSE file for details
|
||||||
|
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 *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -6,10 +6,7 @@
|
|||||||
--font-sans: var(--font-sans);
|
--font-sans: var(--font-sans);
|
||||||
|
|
||||||
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
|
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
|
||||||
--background-image-gradient-conic: conic-gradient(
|
--background-image-gradient-conic: conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops));
|
||||||
from 180deg at 50% 50%,
|
|
||||||
var(--tw-gradient-stops)
|
|
||||||
);
|
|
||||||
|
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
@@ -90,41 +87,14 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 0%;
|
||||||
--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%;
|
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 240 10% 3.9%;
|
--card: 240 10% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 240 10% 3.9%;
|
--popover: 240 10% 3.9%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 142.1 76.2% 36.3%;
|
||||||
--primary-foreground: 240 5.9% 10%;
|
--primary-foreground: 355.7 100% 97.3%;
|
||||||
--secondary: 240 3.7% 15.9%;
|
--secondary: 240 3.7% 15.9%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 240 3.7% 15.9%;
|
--muted: 240 3.7% 15.9%;
|
||||||
@@ -135,12 +105,13 @@
|
|||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 3.7% 15.9%;
|
--border: 240 3.7% 15.9%;
|
||||||
--input: 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-1: 220 70% 50%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 55%;
|
||||||
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +119,9 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
html {
|
||||||
|
@apply bg-black;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
@@ -1,22 +1,23 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from 'next/font/google';
|
||||||
import PlausibleProvider from "next-plausible";
|
import PlausibleProvider from 'next-plausible';
|
||||||
import "./globals.css";
|
import './globals.css';
|
||||||
import { cn } from "@/lib/utils";
|
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 = {
|
export const viewport: Viewport = {
|
||||||
colorScheme: "dark",
|
colorScheme: 'dark',
|
||||||
themeColor: [
|
themeColor: [
|
||||||
//{ media: "(prefers-color-scheme: light)", color: "#f5f5f5" },
|
//{ media: "(prefers-color-scheme: light)", color: "#f5f5f5" },
|
||||||
//{ media: "(prefers-color-scheme: dark)", color: "#171717" },
|
//{ media: "(prefers-color-scheme: dark)", color: "#171717" },
|
||||||
{ color: "#052e16" },
|
{ color: '#052e16' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Track Every Day!",
|
title: 'Track Every Day!',
|
||||||
description: "A web app for tracking habits, activities and vices.",
|
description: 'A web app for tracking habits, activities and vices.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -35,13 +36,8 @@ export default function RootLayout({
|
|||||||
trackOutboundLinks={true}
|
trackOutboundLinks={true}
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body className={cn('bg-background min-h-screen font-sans antialiased', inter.variable)}>
|
||||||
className={cn(
|
<Providers>{children}</Providers>
|
||||||
"min-h-screen bg-background font-sans antialiased",
|
|
||||||
inter.variable
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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() {
|
export default async function Home() {
|
||||||
redirect("/welcome");
|
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<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return <div className="flex min-h-screen items-center justify-center bg-black p-4">{children}</div>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@@ -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 (
|
return (
|
||||||
<div className="shadow-xl rounded-lg w-full border px-6 py-12 bg-emerald-900 border-emerald-700 ">
|
<Card className="w-full max-w-md border-zinc-800 bg-zinc-950">
|
||||||
<div className="flex flex-col">
|
<CardHeader className="space-y-2 text-center">
|
||||||
<span className="text-4xl font-bold">📅 Track Every Day</span>
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-950">
|
||||||
<span className="mt-4 text-center">
|
<span className="text-3xl">📅</span>
|
||||||
A web app for logging your habits, vices and activities.
|
</div>
|
||||||
</span>
|
<CardTitle className="text-3xl font-bold">Track Every Day</CardTitle>
|
||||||
</div>
|
<CardDescription className="text-base">
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
60
components/ui/alert.tsx
Normal file
60
components/ui/alert.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-card text-card-foreground',
|
||||||
|
destructive:
|
||||||
|
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
37
components/ui/badge.tsx
Normal file
37
components/ui/badge.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||||
|
secondary:
|
||||||
|
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
|
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot : 'span';
|
||||||
|
|
||||||
|
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
52
components/ui/button.tsx
Normal file
52
components/ui/button.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||||
|
outline:
|
||||||
|
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||||
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
|
icon: 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'button'> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
71
components/ui/card.tsx
Normal file
71
components/ui/card.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
126
components/ui/dialog.tsx
Normal file
126
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { XIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn('text-lg leading-none font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn('text-muted-foreground text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||||
|
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
21
components/ui/label.tsx
Normal file
21
components/ui/label.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
170
components/ui/select.tsx
Normal file
170
components/ui/select.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = 'default',
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: 'sm' | 'default';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = 'popper',
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
53
components/ui/tooltip.tsx
Normal file
53
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
11
drizzle.config.ts
Normal file
11
drizzle.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
import { env } from 'node:process';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './lib/db/schema.ts',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: env.POSTGRES_URL!,
|
||||||
|
},
|
||||||
|
out: './drizzle',
|
||||||
|
});
|
26
lib/auth/cookies.ts
Normal file
26
lib/auth/cookies.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const TOKEN_COOKIE_NAME = 'habit-tracker-token';
|
||||||
|
const TOKEN_COOKIE_OPTIONS = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||||
|
path: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function setTokenCookie(token: string) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(TOKEN_COOKIE_NAME, token, TOKEN_COOKIE_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTokenCookie(): Promise<string | undefined> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const cookie = cookieStore.get(TOKEN_COOKIE_NAME);
|
||||||
|
return cookie?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTokenCookie() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete(TOKEN_COOKIE_NAME);
|
||||||
|
}
|
167
lib/auth/tokens.ts
Normal file
167
lib/auth/tokens.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { customAlphabet } from 'nanoid';
|
||||||
|
|
||||||
|
// Word lists for generating memorable tokens
|
||||||
|
const adjectives = [
|
||||||
|
'quick',
|
||||||
|
'lazy',
|
||||||
|
'happy',
|
||||||
|
'brave',
|
||||||
|
'bright',
|
||||||
|
'calm',
|
||||||
|
'clever',
|
||||||
|
'eager',
|
||||||
|
'gentle',
|
||||||
|
'kind',
|
||||||
|
'lively',
|
||||||
|
'proud',
|
||||||
|
'silly',
|
||||||
|
'witty',
|
||||||
|
'bold',
|
||||||
|
'cool',
|
||||||
|
'fair',
|
||||||
|
'fine',
|
||||||
|
'glad',
|
||||||
|
'good',
|
||||||
|
'neat',
|
||||||
|
'nice',
|
||||||
|
'rare',
|
||||||
|
'safe',
|
||||||
|
'warm',
|
||||||
|
'wise',
|
||||||
|
'fresh',
|
||||||
|
'clean',
|
||||||
|
'clear',
|
||||||
|
'crisp',
|
||||||
|
'sweet',
|
||||||
|
'smooth',
|
||||||
|
];
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'red',
|
||||||
|
'blue',
|
||||||
|
'green',
|
||||||
|
'yellow',
|
||||||
|
'purple',
|
||||||
|
'orange',
|
||||||
|
'pink',
|
||||||
|
'black',
|
||||||
|
'white',
|
||||||
|
'gray',
|
||||||
|
'brown',
|
||||||
|
'cyan',
|
||||||
|
'lime',
|
||||||
|
'navy',
|
||||||
|
'teal',
|
||||||
|
'gold',
|
||||||
|
'silver',
|
||||||
|
'coral',
|
||||||
|
'salmon',
|
||||||
|
'indigo',
|
||||||
|
'violet',
|
||||||
|
'crimson',
|
||||||
|
'azure',
|
||||||
|
'jade',
|
||||||
|
];
|
||||||
|
|
||||||
|
const animals = [
|
||||||
|
'cat',
|
||||||
|
'dog',
|
||||||
|
'bird',
|
||||||
|
'fish',
|
||||||
|
'bear',
|
||||||
|
'lion',
|
||||||
|
'wolf',
|
||||||
|
'fox',
|
||||||
|
'deer',
|
||||||
|
'owl',
|
||||||
|
'hawk',
|
||||||
|
'duck',
|
||||||
|
'goat',
|
||||||
|
'seal',
|
||||||
|
'crab',
|
||||||
|
'moth',
|
||||||
|
'bee',
|
||||||
|
'ant',
|
||||||
|
'bat',
|
||||||
|
'cow',
|
||||||
|
'pig',
|
||||||
|
'hen',
|
||||||
|
'ram',
|
||||||
|
'rat',
|
||||||
|
'eel',
|
||||||
|
'cod',
|
||||||
|
'jay',
|
||||||
|
'yak',
|
||||||
|
'ox',
|
||||||
|
'pug',
|
||||||
|
'doe',
|
||||||
|
'hog',
|
||||||
|
];
|
||||||
|
|
||||||
|
const nouns = [
|
||||||
|
'moon',
|
||||||
|
'star',
|
||||||
|
'cloud',
|
||||||
|
'river',
|
||||||
|
'mountain',
|
||||||
|
'ocean',
|
||||||
|
'forest',
|
||||||
|
'desert',
|
||||||
|
'island',
|
||||||
|
'valley',
|
||||||
|
'meadow',
|
||||||
|
'garden',
|
||||||
|
'bridge',
|
||||||
|
'castle',
|
||||||
|
'tower',
|
||||||
|
'light',
|
||||||
|
'shadow',
|
||||||
|
'dream',
|
||||||
|
'hope',
|
||||||
|
'wish',
|
||||||
|
'song',
|
||||||
|
'dance',
|
||||||
|
'smile',
|
||||||
|
'laugh',
|
||||||
|
'gift',
|
||||||
|
'pearl',
|
||||||
|
'jewel',
|
||||||
|
'crown',
|
||||||
|
'shield',
|
||||||
|
'sword',
|
||||||
|
'arrow',
|
||||||
|
'bow',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate a 4-digit number suffix for uniqueness
|
||||||
|
const generateNumber = customAlphabet('0123456789', 4);
|
||||||
|
|
||||||
|
function getRandomElement<T>(array: T[]): T {
|
||||||
|
return array[Math.floor(Math.random() * array.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateMemorableToken(): string {
|
||||||
|
const parts = [
|
||||||
|
getRandomElement(adjectives),
|
||||||
|
getRandomElement(colors),
|
||||||
|
getRandomElement(animals),
|
||||||
|
generateNumber(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return parts.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateShortToken(): string {
|
||||||
|
const parts = [getRandomElement(colors), getRandomElement(nouns), generateNumber()];
|
||||||
|
|
||||||
|
return parts.join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token format
|
||||||
|
export function isValidToken(token: string): boolean {
|
||||||
|
// Check if token matches our format (word-word-word-4digits or word-word-4digits)
|
||||||
|
const longFormat = /^[a-z]+-[a-z]+-[a-z]+-\d{4}$/;
|
||||||
|
const shortFormat = /^[a-z]+-[a-z]+-\d{4}$/;
|
||||||
|
|
||||||
|
return longFormat.test(token) || shortFormat.test(token);
|
||||||
|
}
|
8
lib/db/index.ts
Normal file
8
lib/db/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
export const db = drizzle(process.env.POSTGRES_URL!, { schema });
|
||||||
|
|
||||||
|
// Re-export schema types for convenience
|
||||||
|
export * from './schema';
|
78
lib/db/schema.ts
Normal file
78
lib/db/schema.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { pgTable, serial, text, timestamp, integer, jsonb, boolean, index } from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
token: text('token').notNull().unique(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const habits = pgTable(
|
||||||
|
'habits',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.references(() => users.id)
|
||||||
|
.notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
type: text('type', { enum: ['positive', 'neutral', 'negative'] })
|
||||||
|
.notNull()
|
||||||
|
.default('neutral'),
|
||||||
|
targetFrequency: jsonb('target_frequency').$type<{
|
||||||
|
value: number;
|
||||||
|
period: 'day' | 'week' | 'month';
|
||||||
|
}>(),
|
||||||
|
color: text('color'),
|
||||||
|
icon: text('icon'),
|
||||||
|
isArchived: boolean('is_archived').default(false).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
archivedAt: timestamp('archived_at'),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
userIdIdx: index('habits_user_id_idx').on(table.userId),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const habitLogs = pgTable(
|
||||||
|
'habit_logs',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
habitId: integer('habit_id')
|
||||||
|
.references(() => habits.id)
|
||||||
|
.notNull(),
|
||||||
|
loggedAt: timestamp('logged_at').defaultNow().notNull(),
|
||||||
|
note: text('note'),
|
||||||
|
},
|
||||||
|
(table) => ({
|
||||||
|
habitIdIdx: index('habit_logs_habit_id_idx').on(table.habitId),
|
||||||
|
loggedAtIdx: index('habit_logs_logged_at_idx').on(table.loggedAt),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
|
habits: many(habits),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const habitsRelations = relations(habits, ({ one, many }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [habits.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
logs: many(habitLogs),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const habitLogsRelations = relations(habitLogs, ({ one }) => ({
|
||||||
|
habit: one(habits, {
|
||||||
|
fields: [habitLogs.habitId],
|
||||||
|
references: [habits.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type NewUser = typeof users.$inferInsert;
|
||||||
|
export type Habit = typeof habits.$inferSelect;
|
||||||
|
export type NewHabit = typeof habits.$inferInsert;
|
||||||
|
export type HabitLog = typeof habitLogs.$inferSelect;
|
||||||
|
export type NewHabitLog = typeof habitLogs.$inferInsert;
|
@@ -1,6 +1,6 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
24
middleware.ts
Normal file
24
middleware.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const token = request.cookies.get('habit-tracker-token');
|
||||||
|
const isAuthPage = request.nextUrl.pathname === '/welcome';
|
||||||
|
const isDashboard = request.nextUrl.pathname === '/dashboard';
|
||||||
|
|
||||||
|
// If trying to access dashboard without token, redirect to welcome
|
||||||
|
if (isDashboard && !token) {
|
||||||
|
return NextResponse.redirect(new URL('/welcome', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If trying to access welcome page with token, redirect to dashboard
|
||||||
|
if (isAuthPage && token) {
|
||||||
|
return NextResponse.redirect(new URL('/dashboard', request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/dashboard', '/welcome'],
|
||||||
|
};
|
25
package.json
25
package.json
@@ -6,15 +6,34 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"check": "next lint && npx tsc --noEmit"
|
"check": "next lint && npx tsc --noEmit",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||||
|
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cssnano": "^7.1.0",
|
"cssnano": "^7.1.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^17.2.0",
|
||||||
|
"drizzle-orm": "^0.44.3",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
|
"nanoid": "^5.1.5",
|
||||||
"next": "15.4.1",
|
"next": "15.4.1",
|
||||||
"next-plausible": "^3.12.4",
|
"next-plausible": "^3.12.4",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"pg-native": "^3.5.2",
|
||||||
"postcss-flexbugs-fixes": "^5.0.2",
|
"postcss-flexbugs-fixes": "^5.0.2",
|
||||||
"postcss-preset-env": "^10.2.4",
|
"postcss-preset-env": "^10.2.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -25,11 +44,15 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "4.1.11",
|
"@tailwindcss/postcss": "4.1.11",
|
||||||
"@types/node": "22.16.3",
|
"@types/node": "22.16.3",
|
||||||
|
"@types/pg": "^8.15.4",
|
||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
|
"drizzle-kit": "^0.31.4",
|
||||||
"eslint": "8.57.1",
|
"eslint": "8.57.1",
|
||||||
"eslint-config-next": "15.4.1",
|
"eslint-config-next": "15.4.1",
|
||||||
"postcss": "8.5.6",
|
"postcss": "8.5.6",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"turbo": "2.5.4",
|
"turbo": "2.5.4",
|
||||||
"typescript": "5.8.3"
|
"typescript": "5.8.3"
|
||||||
|
1843
pnpm-lock.yaml
generated
1843
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@tailwindcss/oxide'
|
- '@tailwindcss/oxide'
|
||||||
|
- libpq
|
||||||
- sharp
|
- sharp
|
||||||
|
Reference in New Issue
Block a user