Compare commits
	
		
			169 Commits
		
	
	
		
			dev
			...
			64f568b574
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 64f568b574 | |||
| 29a995deb9 | |||
| 4db001a1fb | |||
| 020645a1f8 | |||
| 5d61249bfc | |||
| 41777d6215 | |||
| f6c6caa67d | |||
| 853e882198 | |||
| 74ef49821e | |||
| 36f0fbd87d | |||
| 1ae54a78eb | |||
| 088afc042e | |||
| d2e7715a33 | |||
| 44df200838 | |||
| 3919f4dc6c | |||
| 0ce02d2f00 | |||
| 0c7816c8fd | |||
| a9173b6589 | |||
| 0d9a063ad4 | |||
| 68fe96f838 | |||
| 9ef3e83307 | |||
| c9e14aeecd | |||
| 608f6ae1af | |||
| 3ec58d99a7 | |||
| 200d4bde02 | |||
| 24260b0f72 | |||
| 30ed468ef3 | |||
| 2982995ce9 | |||
| 11071f3150 | |||
| 41f1ee4d0c | |||
| a0bfab2404 | |||
| 593d845969 | |||
| b76730c927 | |||
| 0f8cc9dfe6 | |||
| 2e06f6cdc1 | |||
| b5dd538a36 | |||
| 5e25753311 | |||
| ad1bf1425b | |||
| 1fa7ad48e8 | |||
| 18213aa2ec | |||
| 87e9f7df79 | |||
| 3867fa5b3c | |||
| 2411f4d862 | |||
| 2dea8867f5 | |||
| 6139cb4b1d | |||
| f687242557 | |||
| 9259c74101 | |||
| 66768abc3b | |||
| abe7a4af7e | |||
| 1c78d18327 | |||
| e9dbbe51bd | |||
| 58afc26526 | |||
| 83dc1de53c | |||
| d176451787 | |||
| ba560199cc | |||
| a1bfc8cf21 | |||
| e77f7686cf | |||
| eaf30ad45d | |||
| ed6790ae0f | |||
| 6944a240b2 | |||
| 9787a58d58 | |||
| 5bc2b7fa28 | |||
| b792b75ea1 | |||
| 87fc331e9b | |||
| ca7164e52b | |||
| e277d53b52 | |||
| 87d4d5dde4 | |||
| 4f78e6cfcd | |||
| a8c78d6a42 | |||
| 114cba348a | |||
| 501a784da9 | |||
| 6552451f39 | |||
| 8deb7d13e9 | |||
| 2de8e45e92 | |||
| 18d6cf77e5 | |||
| 30b28a7cb6 | |||
| e6b4bae90d | |||
| 295c884804 | |||
| 270b21dbf0 | |||
| 91cd975e51 | |||
| bedf8f7eab | |||
| b24096ff80 | |||
| 7e9754bca4 | |||
| d1495d29e9 | |||
| c69b164c23 | |||
| 26443ef169 | |||
| dcfd830afe | |||
| 6145d4549a | |||
| 9c99914c36 | |||
| 988535d452 | |||
| 2ece96f3ca | |||
| a0bdda25bc | |||
| 778626c3a2 | |||
| af3e2ceb6b | |||
| 1cfcd31fff | |||
| 9276e0f037 | |||
| c4bf5f1a35 | |||
| d480175dd0 | |||
| e62cfaa0d4 | |||
| 095f2c65be | |||
| 60ce3892d6 | |||
| 9dff61cbbc | |||
| 3c7eeb108b | |||
| af7690e66d | |||
| 64ef558818 | |||
| 6bd6192dd0 | |||
| 43de660f1b | |||
| 4bb1943244 | |||
| 0888cca45b | |||
| 5314c98508 | |||
| 484047a7bd | |||
| 80d95065e8 | |||
| 5386dad5c9 | |||
| 4e63d0ac42 | |||
| 485e75dc93 | |||
| 49c8389d9d | |||
| 92a27f544b | |||
| b02f5e6364 | |||
| f5fff9c52b | |||
| 5d9eb9217e | |||
| d69d9fc129 | |||
| 253111f741 | |||
| 835b73552b | |||
| b1e8fb3353 | |||
| 20c0bf2770 | |||
| 07475f7cb1 | |||
| 1f77670b1e | |||
| 7108ce3e8f | |||
| f28341781e | |||
| 1d3bf08a2c | |||
| e155256b65 | |||
| 71cffc17d6 | |||
| 922009dffe | |||
| 6665a8b735 | |||
| 9fe6e0d243 | |||
| e888cef0c1 | |||
| 192f2503e4 | |||
| a534a04082 | |||
| f7cac220a8 | |||
| 087859edb8 | |||
| 84a4cd6293 | |||
| 01f2a30ca0 | |||
| c05a4b40b6 | |||
| 65f3833ad4 | |||
| 8d7780e46d | |||
| b0b69a4352 | |||
| 0346f0c792 | |||
| b6b40a0b50 | |||
| 17071995dd | |||
| 4c9e984ab1 | |||
| ec9b659b7b | |||
| 48e03d3775 | |||
| 51bf896d37 | |||
| 789e389bcd | |||
| 7ac4a5261b | |||
| 8540764a99 | |||
| 8ab4c37676 | |||
| 9d6214ca8a | |||
| a4151c5736 | |||
| 762dd0a445 | |||
| 82a6a1ba96 | |||
| 776c9e5547 | |||
| 90e809c272 | |||
| 6c02bd8dc6 | |||
| 745bb0d7f8 | |||
| e927ab6a15 | |||
| df0c893137 | |||
| 2697e763c2 | |||
| ef969d2b6c | 
@@ -1,3 +0,0 @@
 | 
				
			|||||||
{
 | 
					 | 
				
			||||||
  "extends": "next/core-web-vitals"
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -7,21 +7,25 @@ on:
 | 
				
			|||||||
      - "**" # matches every branch
 | 
					      - "**" # matches every branch
 | 
				
			||||||
 | 
					
 | 
				
			||||||
jobs:
 | 
					jobs:
 | 
				
			||||||
  eslint:
 | 
					  lint_and_check:
 | 
				
			||||||
    name: Lint
 | 
					    name: Lint and Check
 | 
				
			||||||
    runs-on: ubuntu-latest
 | 
					    runs-on: ubuntu-latest
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    steps:
 | 
					    steps:
 | 
				
			||||||
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
 | 
					      - name: Checkout code
 | 
				
			||||||
 | 
					        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
 | 
					      - name: Install pnpm
 | 
				
			||||||
 | 
					        uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      - name: Setup Node.js
 | 
				
			||||||
 | 
					        uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          node-version: 20
 | 
					          node-version: 22
 | 
				
			||||||
          cache: "npm"
 | 
					          cache: "pnpm"
 | 
				
			||||||
      - run: npm i
 | 
					
 | 
				
			||||||
      - uses: sibiraj-s/action-eslint@bcf41bb9abce43cdbad51ab9b3da2eddaa17eab3 # v3.0.1
 | 
					      - name: Install dependencies
 | 
				
			||||||
        with:
 | 
					        run: pnpm install
 | 
				
			||||||
          eslint-args: "--ignore-path=.gitignore --quiet"
 | 
					
 | 
				
			||||||
          extensions: "js,jsx,ts,tsx"
 | 
					      - name: Run check
 | 
				
			||||||
          annotations: true
 | 
					        run: pnpm run check
 | 
				
			||||||
          all-files: true
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										115
									
								
								app/api/auth/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								app/api/auth/route.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,115 @@
 | 
				
			|||||||
 | 
					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() {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    // Check if user already has a token
 | 
				
			||||||
 | 
					    const existingToken = await getTokenCookie();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (existingToken) {
 | 
				
			||||||
 | 
					      // Verify token exists in database
 | 
				
			||||||
 | 
					      const userRows = await db.select().from(users).where(eq(users.token, existingToken));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (userRows.length > 0) {
 | 
				
			||||||
 | 
					        const user = userRows[0];
 | 
				
			||||||
 | 
					        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()) as { action: string; token?: string };
 | 
				
			||||||
 | 
					    const { action, token } = body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (action === 'create') {
 | 
				
			||||||
 | 
					      // Generate new token and create user
 | 
				
			||||||
 | 
					      const newToken = generateMemorableToken();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const newUserRows = await db
 | 
				
			||||||
 | 
					        .insert(users)
 | 
				
			||||||
 | 
					        .values({
 | 
				
			||||||
 | 
					          token: newToken,
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					        .returning();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (newUserRows.length === 0) {
 | 
				
			||||||
 | 
					        throw new Error('Failed to create user');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const newUser = newUserRows[0];
 | 
				
			||||||
 | 
					      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 userRows = await db.select().from(users).where(eq(users.token, token));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (userRows.length === 0) {
 | 
				
			||||||
 | 
					        return NextResponse.json(
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            success: false,
 | 
				
			||||||
 | 
					            error: 'Token not found',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          { status: 404 },
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const user = userRows[0];
 | 
				
			||||||
 | 
					      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 },
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										60
									
								
								app/api/habits/[id]/log/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								app/api/habits/[id]/log/route.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
				
			|||||||
 | 
					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 userRows = await db.select().from(users).where(eq(users.token, token));
 | 
				
			||||||
 | 
					  return userRows.length > 0 ? userRows[0] : null;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 habitRows = await db
 | 
				
			||||||
 | 
					      .select()
 | 
				
			||||||
 | 
					      .from(habits)
 | 
				
			||||||
 | 
					      .where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (habitRows.length === 0) {
 | 
				
			||||||
 | 
					      return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const body = (await request.json()) as { note?: string };
 | 
				
			||||||
 | 
					    const { note } = body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Create log entry
 | 
				
			||||||
 | 
					    const logRows = await db
 | 
				
			||||||
 | 
					      .insert(habitLogs)
 | 
				
			||||||
 | 
					      .values({
 | 
				
			||||||
 | 
					        habitId,
 | 
				
			||||||
 | 
					        note,
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .returning();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (logRows.length === 0) {
 | 
				
			||||||
 | 
					      throw new Error('Failed to create log entry');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const log = logRows[0];
 | 
				
			||||||
 | 
					    return NextResponse.json({ log });
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Log habit error:', error);
 | 
				
			||||||
 | 
					    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										136
									
								
								app/api/habits/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								app/api/habits/route.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,136 @@
 | 
				
			|||||||
 | 
					import { NextRequest, NextResponse } from 'next/server';
 | 
				
			||||||
 | 
					import { db, habits, users, habitLogs } from '@/lib/db';
 | 
				
			||||||
 | 
					import { getTokenCookie } from '@/lib/auth/cookies';
 | 
				
			||||||
 | 
					import { eq, and, desc } from 'drizzle-orm';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					async function getUserFromToken() {
 | 
				
			||||||
 | 
					  const token = await getTokenCookie();
 | 
				
			||||||
 | 
					  if (!token) return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const userRows = await db.select().from(users).where(eq(users.token, token));
 | 
				
			||||||
 | 
					  return userRows.length > 0 ? userRows[0] : null;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function GET() {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const user = await getUserFromToken();
 | 
				
			||||||
 | 
					    if (!user) {
 | 
				
			||||||
 | 
					      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Get current timestamp for date calculations
 | 
				
			||||||
 | 
					    const now = new Date();
 | 
				
			||||||
 | 
					    const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
 | 
				
			||||||
 | 
					    const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // First get all habits
 | 
				
			||||||
 | 
					    const userHabitsBase = await db
 | 
				
			||||||
 | 
					      .select()
 | 
				
			||||||
 | 
					      .from(habits)
 | 
				
			||||||
 | 
					      .where(and(eq(habits.userId, user.id), eq(habits.isArchived, false)))
 | 
				
			||||||
 | 
					      .orderBy(desc(habits.createdAt));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Then get aggregated log data for each habit
 | 
				
			||||||
 | 
					    const habitsWithStats = await Promise.all(
 | 
				
			||||||
 | 
					      userHabitsBase.map(async (habit) => {
 | 
				
			||||||
 | 
					        // Get all logs for this habit
 | 
				
			||||||
 | 
					        const logs = await db
 | 
				
			||||||
 | 
					          .select({
 | 
				
			||||||
 | 
					            loggedAt: habitLogs.loggedAt,
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					          .from(habitLogs)
 | 
				
			||||||
 | 
					          .where(eq(habitLogs.habitId, habit.id));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        // Calculate statistics
 | 
				
			||||||
 | 
					        const totalLogs = logs.length;
 | 
				
			||||||
 | 
					        const logsLastWeek = logs.filter((log) => log.loggedAt >= sevenDaysAgo).length;
 | 
				
			||||||
 | 
					        const logsLastMonth = logs.filter((log) => log.loggedAt >= thirtyDaysAgo).length;
 | 
				
			||||||
 | 
					        const lastLoggedAt =
 | 
				
			||||||
 | 
					          logs.length > 0
 | 
				
			||||||
 | 
					            ? logs.reduce(
 | 
				
			||||||
 | 
					                (latest, log) => (log.loggedAt > latest ? log.loggedAt : latest),
 | 
				
			||||||
 | 
					                logs[0].loggedAt,
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					            : null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					          id: habit.id,
 | 
				
			||||||
 | 
					          name: habit.name,
 | 
				
			||||||
 | 
					          type: habit.type,
 | 
				
			||||||
 | 
					          targetFrequency: habit.targetFrequency,
 | 
				
			||||||
 | 
					          color: habit.color,
 | 
				
			||||||
 | 
					          icon: habit.icon,
 | 
				
			||||||
 | 
					          createdAt: habit.createdAt,
 | 
				
			||||||
 | 
					          lastLoggedAt,
 | 
				
			||||||
 | 
					          totalLogs,
 | 
				
			||||||
 | 
					          logsLastWeek,
 | 
				
			||||||
 | 
					          logsLastMonth,
 | 
				
			||||||
 | 
					        };
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return NextResponse.json({ habits: habitsWithStats });
 | 
				
			||||||
 | 
					  } 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()) as {
 | 
				
			||||||
 | 
					      name: string;
 | 
				
			||||||
 | 
					      type: string;
 | 
				
			||||||
 | 
					      targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
 | 
				
			||||||
 | 
					      color?: string;
 | 
				
			||||||
 | 
					      icon?: string;
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					    const { name, type, targetFrequency, color, icon } = body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!name || !type) {
 | 
				
			||||||
 | 
					      return NextResponse.json(
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          error: 'Name and type are required',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        { status: 400 },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Validate type is one of the allowed enum values
 | 
				
			||||||
 | 
					    if (!['positive', 'neutral', 'negative'].includes(type)) {
 | 
				
			||||||
 | 
					      return NextResponse.json(
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          error: 'Type must be one of: positive, neutral, negative',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        { status: 400 },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const newHabitRows = await db
 | 
				
			||||||
 | 
					      .insert(habits)
 | 
				
			||||||
 | 
					      .values({
 | 
				
			||||||
 | 
					        userId: user.id,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        type: type as 'positive' | 'neutral' | 'negative',
 | 
				
			||||||
 | 
					        targetFrequency,
 | 
				
			||||||
 | 
					        color,
 | 
				
			||||||
 | 
					        icon,
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					      .returning();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (newHabitRows.length === 0) {
 | 
				
			||||||
 | 
					      throw new Error('Failed to create habit');
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const newHabit = newHabitRows[0];
 | 
				
			||||||
 | 
					    return NextResponse.json({ habit: newHabit });
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error('Create habit error:', error);
 | 
				
			||||||
 | 
					    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										441
									
								
								app/dashboard/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										441
									
								
								app/dashboard/page.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,441 @@
 | 
				
			|||||||
 | 
					'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,
 | 
				
			||||||
 | 
					  Trophy,
 | 
				
			||||||
 | 
					  HeartCrack,
 | 
				
			||||||
 | 
					} from 'lucide-react';
 | 
				
			||||||
 | 
					import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
 | 
				
			||||||
 | 
					import { Button } from '@/components/ui/button';
 | 
				
			||||||
 | 
					import { Input } from '@/components/ui/input';
 | 
				
			||||||
 | 
					import { Label } from '@/components/ui/label';
 | 
				
			||||||
 | 
					import { 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 AuthData {
 | 
				
			||||||
 | 
					  authenticated: boolean;
 | 
				
			||||||
 | 
					  token?: string;
 | 
				
			||||||
 | 
					  userId?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Habit {
 | 
				
			||||||
 | 
					  id: number;
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  type: 'positive' | 'neutral' | 'negative';
 | 
				
			||||||
 | 
					  lastLoggedAt: string | null;
 | 
				
			||||||
 | 
					  totalLogs: number;
 | 
				
			||||||
 | 
					  logsLastWeek: number;
 | 
				
			||||||
 | 
					  logsLastMonth: number;
 | 
				
			||||||
 | 
					  createdAt: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface HabitsResponse {
 | 
				
			||||||
 | 
					  habits: Habit[];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface LogResponse {
 | 
				
			||||||
 | 
					  log: {
 | 
				
			||||||
 | 
					    id: number;
 | 
				
			||||||
 | 
					    habitId: number;
 | 
				
			||||||
 | 
					    loggedAt: string;
 | 
				
			||||||
 | 
					    note?: string;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface HabitResponse {
 | 
				
			||||||
 | 
					  habit: Habit;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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<AuthData>({
 | 
				
			||||||
 | 
					    queryKey: ['auth'],
 | 
				
			||||||
 | 
					    queryFn: async (): Promise<AuthData> => {
 | 
				
			||||||
 | 
					      const res = await fetch('/api/auth');
 | 
				
			||||||
 | 
					      const data = (await res.json()) as AuthData;
 | 
				
			||||||
 | 
					      if (!data.authenticated) {
 | 
				
			||||||
 | 
					        router.push('/');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return data;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    if (authData?.token) {
 | 
				
			||||||
 | 
					      setUserToken(authData.token);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }, [authData]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Fetch habits
 | 
				
			||||||
 | 
					  const { data: habitsData, isLoading: habitsLoading } = useQuery<HabitsResponse>({
 | 
				
			||||||
 | 
					    queryKey: ['habits'],
 | 
				
			||||||
 | 
					    queryFn: async (): Promise<HabitsResponse> => {
 | 
				
			||||||
 | 
					      const res = await fetch('/api/habits');
 | 
				
			||||||
 | 
					      if (!res.ok) throw new Error('Failed to fetch habits');
 | 
				
			||||||
 | 
					      return res.json() as Promise<HabitsResponse>;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    enabled: !!authData?.authenticated,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Log habit mutation
 | 
				
			||||||
 | 
					  const logHabitMutation = useMutation<LogResponse, Error, number>({
 | 
				
			||||||
 | 
					    mutationFn: async (habitId: number): Promise<LogResponse> => {
 | 
				
			||||||
 | 
					      const res = await fetch(`/api/habits/${String(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() as Promise<LogResponse>;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    onSuccess: () => {
 | 
				
			||||||
 | 
					      void queryClient.invalidateQueries({ queryKey: ['habits'] });
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Create habit mutation
 | 
				
			||||||
 | 
					  const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
 | 
				
			||||||
 | 
					    mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
 | 
				
			||||||
 | 
					      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() as Promise<HabitResponse>;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    onSuccess: () => {
 | 
				
			||||||
 | 
					      void 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) {
 | 
				
			||||||
 | 
					      void 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 <Trophy className="h-5 w-5 text-emerald-500" />;
 | 
				
			||||||
 | 
					      case 'negative':
 | 
				
			||||||
 | 
					        return <HeartCrack 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: 'positive' | 'neutral' | 'negative') => {
 | 
				
			||||||
 | 
					                        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,219 @@
 | 
				
			|||||||
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, Shield, Zap, ArrowLeft, Activity, CalendarCheck } from 'lucide-react';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface AuthResponse {
 | 
				
			||||||
 | 
					  success?: boolean;
 | 
				
			||||||
 | 
					  token?: string;
 | 
				
			||||||
 | 
					  userId?: string;
 | 
				
			||||||
 | 
					  error?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					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 (): Promise<AuthResponse> => {
 | 
				
			||||||
 | 
					      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() as Promise<AuthResponse>;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    onSuccess: () => {
 | 
				
			||||||
 | 
					      router.push('/dashboard');
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    onError: () => {
 | 
				
			||||||
 | 
					      setError('Failed to create account. Please try again.');
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const loginMutation = useMutation({
 | 
				
			||||||
 | 
					    mutationFn: async (token: string): Promise<AuthResponse> => {
 | 
				
			||||||
 | 
					      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()) as AuthResponse;
 | 
				
			||||||
 | 
					        throw new Error(data.error ?? 'Failed to login');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      return res.json() as Promise<AuthResponse>;
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    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());
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleKeyDown = (e: React.KeyboardEvent) => {
 | 
				
			||||||
 | 
					    if (e.key === 'Enter') {
 | 
				
			||||||
 | 
					      handleTokenLogin();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  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">
 | 
				
			||||||
 | 
					                  <Activity 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...</>
 | 
				
			||||||
 | 
					                ) : (
 | 
				
			||||||
 | 
					                  <>
 | 
				
			||||||
 | 
					                    <CalendarCheck 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);
 | 
				
			||||||
 | 
					                }}
 | 
				
			||||||
 | 
					                onKeyDown={handleKeyDown}
 | 
				
			||||||
 | 
					                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 };
 | 
				
			||||||
							
								
								
									
										17
									
								
								drizzle.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								drizzle.config.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,17 @@
 | 
				
			|||||||
 | 
					import { defineConfig } from 'drizzle-kit';
 | 
				
			||||||
 | 
					import dotenv from 'dotenv';
 | 
				
			||||||
 | 
					dotenv.config({ path: ['.env.local', '.env'] });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const DATABASE_URL = process.env.POSTGRES_URL;
 | 
				
			||||||
 | 
					if (!DATABASE_URL) {
 | 
				
			||||||
 | 
					  throw new Error('POSTGRES_URL environment variable is required');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default defineConfig({
 | 
				
			||||||
 | 
					  schema: './lib/db/schema.ts',
 | 
				
			||||||
 | 
					  dialect: 'postgresql',
 | 
				
			||||||
 | 
					  dbCredentials: {
 | 
				
			||||||
 | 
					    url: DATABASE_URL,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  out: './drizzle',
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
							
								
								
									
										32
									
								
								drizzle/0000_lazy_prism.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								drizzle/0000_lazy_prism.sql
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
				
			|||||||
 | 
					CREATE TABLE "habit_logs" (
 | 
				
			||||||
 | 
						"id" serial PRIMARY KEY NOT NULL,
 | 
				
			||||||
 | 
						"habit_id" integer NOT NULL,
 | 
				
			||||||
 | 
						"logged_at" timestamp DEFAULT now() NOT NULL,
 | 
				
			||||||
 | 
						"note" text
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					--> statement-breakpoint
 | 
				
			||||||
 | 
					CREATE TABLE "habits" (
 | 
				
			||||||
 | 
						"id" serial PRIMARY KEY NOT NULL,
 | 
				
			||||||
 | 
						"user_id" integer NOT NULL,
 | 
				
			||||||
 | 
						"name" text NOT NULL,
 | 
				
			||||||
 | 
						"type" text DEFAULT 'neutral' NOT NULL,
 | 
				
			||||||
 | 
						"target_frequency" jsonb,
 | 
				
			||||||
 | 
						"color" text,
 | 
				
			||||||
 | 
						"icon" text,
 | 
				
			||||||
 | 
						"is_archived" boolean DEFAULT false NOT NULL,
 | 
				
			||||||
 | 
						"created_at" timestamp DEFAULT now() NOT NULL,
 | 
				
			||||||
 | 
						"archived_at" timestamp
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					--> statement-breakpoint
 | 
				
			||||||
 | 
					CREATE TABLE "users" (
 | 
				
			||||||
 | 
						"id" serial PRIMARY KEY NOT NULL,
 | 
				
			||||||
 | 
						"token" text NOT NULL,
 | 
				
			||||||
 | 
						"created_at" timestamp DEFAULT now() NOT NULL,
 | 
				
			||||||
 | 
						CONSTRAINT "users_token_unique" UNIQUE("token")
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					--> statement-breakpoint
 | 
				
			||||||
 | 
					ALTER TABLE "habit_logs" ADD CONSTRAINT "habit_logs_habit_id_habits_id_fk" FOREIGN KEY ("habit_id") REFERENCES "public"."habits"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
 | 
				
			||||||
 | 
					ALTER TABLE "habits" ADD CONSTRAINT "habits_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
 | 
				
			||||||
 | 
					CREATE INDEX "habit_logs_habit_id_idx" ON "habit_logs" USING btree ("habit_id");--> statement-breakpoint
 | 
				
			||||||
 | 
					CREATE INDEX "habit_logs_logged_at_idx" ON "habit_logs" USING btree ("logged_at");--> statement-breakpoint
 | 
				
			||||||
 | 
					CREATE INDEX "habits_user_id_idx" ON "habits" USING btree ("user_id");
 | 
				
			||||||
							
								
								
									
										248
									
								
								drizzle/meta/0000_snapshot.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								drizzle/meta/0000_snapshot.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,248 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "id": "1b2a9dc5-79f6-4172-ba68-f1b9c0e67708",
 | 
				
			||||||
 | 
					  "prevId": "00000000-0000-0000-0000-000000000000",
 | 
				
			||||||
 | 
					  "version": "7",
 | 
				
			||||||
 | 
					  "dialect": "postgresql",
 | 
				
			||||||
 | 
					  "tables": {
 | 
				
			||||||
 | 
					    "public.habit_logs": {
 | 
				
			||||||
 | 
					      "name": "habit_logs",
 | 
				
			||||||
 | 
					      "schema": "",
 | 
				
			||||||
 | 
					      "columns": {
 | 
				
			||||||
 | 
					        "id": {
 | 
				
			||||||
 | 
					          "name": "id",
 | 
				
			||||||
 | 
					          "type": "serial",
 | 
				
			||||||
 | 
					          "primaryKey": true,
 | 
				
			||||||
 | 
					          "notNull": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "habit_id": {
 | 
				
			||||||
 | 
					          "name": "habit_id",
 | 
				
			||||||
 | 
					          "type": "integer",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "logged_at": {
 | 
				
			||||||
 | 
					          "name": "logged_at",
 | 
				
			||||||
 | 
					          "type": "timestamp",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true,
 | 
				
			||||||
 | 
					          "default": "now()"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "note": {
 | 
				
			||||||
 | 
					          "name": "note",
 | 
				
			||||||
 | 
					          "type": "text",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": false
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "indexes": {
 | 
				
			||||||
 | 
					        "habit_logs_habit_id_idx": {
 | 
				
			||||||
 | 
					          "name": "habit_logs_habit_id_idx",
 | 
				
			||||||
 | 
					          "columns": [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              "expression": "habit_id",
 | 
				
			||||||
 | 
					              "isExpression": false,
 | 
				
			||||||
 | 
					              "asc": true,
 | 
				
			||||||
 | 
					              "nulls": "last"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "isUnique": false,
 | 
				
			||||||
 | 
					          "concurrently": false,
 | 
				
			||||||
 | 
					          "method": "btree",
 | 
				
			||||||
 | 
					          "with": {}
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "habit_logs_logged_at_idx": {
 | 
				
			||||||
 | 
					          "name": "habit_logs_logged_at_idx",
 | 
				
			||||||
 | 
					          "columns": [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              "expression": "logged_at",
 | 
				
			||||||
 | 
					              "isExpression": false,
 | 
				
			||||||
 | 
					              "asc": true,
 | 
				
			||||||
 | 
					              "nulls": "last"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "isUnique": false,
 | 
				
			||||||
 | 
					          "concurrently": false,
 | 
				
			||||||
 | 
					          "method": "btree",
 | 
				
			||||||
 | 
					          "with": {}
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "foreignKeys": {
 | 
				
			||||||
 | 
					        "habit_logs_habit_id_habits_id_fk": {
 | 
				
			||||||
 | 
					          "name": "habit_logs_habit_id_habits_id_fk",
 | 
				
			||||||
 | 
					          "tableFrom": "habit_logs",
 | 
				
			||||||
 | 
					          "tableTo": "habits",
 | 
				
			||||||
 | 
					          "columnsFrom": [
 | 
				
			||||||
 | 
					            "habit_id"
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "columnsTo": [
 | 
				
			||||||
 | 
					            "id"
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "onDelete": "no action",
 | 
				
			||||||
 | 
					          "onUpdate": "no action"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "compositePrimaryKeys": {},
 | 
				
			||||||
 | 
					      "uniqueConstraints": {},
 | 
				
			||||||
 | 
					      "policies": {},
 | 
				
			||||||
 | 
					      "checkConstraints": {},
 | 
				
			||||||
 | 
					      "isRLSEnabled": false
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "public.habits": {
 | 
				
			||||||
 | 
					      "name": "habits",
 | 
				
			||||||
 | 
					      "schema": "",
 | 
				
			||||||
 | 
					      "columns": {
 | 
				
			||||||
 | 
					        "id": {
 | 
				
			||||||
 | 
					          "name": "id",
 | 
				
			||||||
 | 
					          "type": "serial",
 | 
				
			||||||
 | 
					          "primaryKey": true,
 | 
				
			||||||
 | 
					          "notNull": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "user_id": {
 | 
				
			||||||
 | 
					          "name": "user_id",
 | 
				
			||||||
 | 
					          "type": "integer",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "name": {
 | 
				
			||||||
 | 
					          "name": "name",
 | 
				
			||||||
 | 
					          "type": "text",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "type": {
 | 
				
			||||||
 | 
					          "name": "type",
 | 
				
			||||||
 | 
					          "type": "text",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true,
 | 
				
			||||||
 | 
					          "default": "'neutral'"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "target_frequency": {
 | 
				
			||||||
 | 
					          "name": "target_frequency",
 | 
				
			||||||
 | 
					          "type": "jsonb",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": false
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "color": {
 | 
				
			||||||
 | 
					          "name": "color",
 | 
				
			||||||
 | 
					          "type": "text",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": false
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "icon": {
 | 
				
			||||||
 | 
					          "name": "icon",
 | 
				
			||||||
 | 
					          "type": "text",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": false
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "is_archived": {
 | 
				
			||||||
 | 
					          "name": "is_archived",
 | 
				
			||||||
 | 
					          "type": "boolean",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true,
 | 
				
			||||||
 | 
					          "default": false
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "created_at": {
 | 
				
			||||||
 | 
					          "name": "created_at",
 | 
				
			||||||
 | 
					          "type": "timestamp",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true,
 | 
				
			||||||
 | 
					          "default": "now()"
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "archived_at": {
 | 
				
			||||||
 | 
					          "name": "archived_at",
 | 
				
			||||||
 | 
					          "type": "timestamp",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": false
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "indexes": {
 | 
				
			||||||
 | 
					        "habits_user_id_idx": {
 | 
				
			||||||
 | 
					          "name": "habits_user_id_idx",
 | 
				
			||||||
 | 
					          "columns": [
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              "expression": "user_id",
 | 
				
			||||||
 | 
					              "isExpression": false,
 | 
				
			||||||
 | 
					              "asc": true,
 | 
				
			||||||
 | 
					              "nulls": "last"
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "isUnique": false,
 | 
				
			||||||
 | 
					          "concurrently": false,
 | 
				
			||||||
 | 
					          "method": "btree",
 | 
				
			||||||
 | 
					          "with": {}
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "foreignKeys": {
 | 
				
			||||||
 | 
					        "habits_user_id_users_id_fk": {
 | 
				
			||||||
 | 
					          "name": "habits_user_id_users_id_fk",
 | 
				
			||||||
 | 
					          "tableFrom": "habits",
 | 
				
			||||||
 | 
					          "tableTo": "users",
 | 
				
			||||||
 | 
					          "columnsFrom": [
 | 
				
			||||||
 | 
					            "user_id"
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "columnsTo": [
 | 
				
			||||||
 | 
					            "id"
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					          "onDelete": "no action",
 | 
				
			||||||
 | 
					          "onUpdate": "no action"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "compositePrimaryKeys": {},
 | 
				
			||||||
 | 
					      "uniqueConstraints": {},
 | 
				
			||||||
 | 
					      "policies": {},
 | 
				
			||||||
 | 
					      "checkConstraints": {},
 | 
				
			||||||
 | 
					      "isRLSEnabled": false
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "public.users": {
 | 
				
			||||||
 | 
					      "name": "users",
 | 
				
			||||||
 | 
					      "schema": "",
 | 
				
			||||||
 | 
					      "columns": {
 | 
				
			||||||
 | 
					        "id": {
 | 
				
			||||||
 | 
					          "name": "id",
 | 
				
			||||||
 | 
					          "type": "serial",
 | 
				
			||||||
 | 
					          "primaryKey": true,
 | 
				
			||||||
 | 
					          "notNull": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "token": {
 | 
				
			||||||
 | 
					          "name": "token",
 | 
				
			||||||
 | 
					          "type": "text",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        "created_at": {
 | 
				
			||||||
 | 
					          "name": "created_at",
 | 
				
			||||||
 | 
					          "type": "timestamp",
 | 
				
			||||||
 | 
					          "primaryKey": false,
 | 
				
			||||||
 | 
					          "notNull": true,
 | 
				
			||||||
 | 
					          "default": "now()"
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "indexes": {},
 | 
				
			||||||
 | 
					      "foreignKeys": {},
 | 
				
			||||||
 | 
					      "compositePrimaryKeys": {},
 | 
				
			||||||
 | 
					      "uniqueConstraints": {
 | 
				
			||||||
 | 
					        "users_token_unique": {
 | 
				
			||||||
 | 
					          "name": "users_token_unique",
 | 
				
			||||||
 | 
					          "nullsNotDistinct": false,
 | 
				
			||||||
 | 
					          "columns": [
 | 
				
			||||||
 | 
					            "token"
 | 
				
			||||||
 | 
					          ]
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "policies": {},
 | 
				
			||||||
 | 
					      "checkConstraints": {},
 | 
				
			||||||
 | 
					      "isRLSEnabled": false
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "enums": {},
 | 
				
			||||||
 | 
					  "schemas": {},
 | 
				
			||||||
 | 
					  "sequences": {},
 | 
				
			||||||
 | 
					  "roles": {},
 | 
				
			||||||
 | 
					  "policies": {},
 | 
				
			||||||
 | 
					  "views": {},
 | 
				
			||||||
 | 
					  "_meta": {
 | 
				
			||||||
 | 
					    "columns": {},
 | 
				
			||||||
 | 
					    "schemas": {},
 | 
				
			||||||
 | 
					    "tables": {}
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										13
									
								
								drizzle/meta/_journal.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								drizzle/meta/_journal.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,13 @@
 | 
				
			|||||||
 | 
					{
 | 
				
			||||||
 | 
					  "version": "7",
 | 
				
			||||||
 | 
					  "dialect": "postgresql",
 | 
				
			||||||
 | 
					  "entries": [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      "idx": 0,
 | 
				
			||||||
 | 
					      "version": "7",
 | 
				
			||||||
 | 
					      "when": 1752596363693,
 | 
				
			||||||
 | 
					      "tag": "0000_lazy_prism",
 | 
				
			||||||
 | 
					      "breakpoints": true
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										60
									
								
								eslint.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								eslint.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,60 @@
 | 
				
			|||||||
 | 
					import js from '@eslint/js';
 | 
				
			||||||
 | 
					import tseslint from 'typescript-eslint';
 | 
				
			||||||
 | 
					import nextPlugin from '@next/eslint-plugin-next';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default tseslint.config(
 | 
				
			||||||
 | 
					  // Base recommended configs
 | 
				
			||||||
 | 
					  js.configs.recommended,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Next.js recommended configs
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    plugins: {
 | 
				
			||||||
 | 
					      '@next/next': nextPlugin,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    rules: {
 | 
				
			||||||
 | 
					      ...nextPlugin.configs.recommended.rules,
 | 
				
			||||||
 | 
					      ...nextPlugin.configs['core-web-vitals'].rules,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // TypeScript configs
 | 
				
			||||||
 | 
					  ...tseslint.configs.recommended,
 | 
				
			||||||
 | 
					  ...tseslint.configs.strictTypeChecked,
 | 
				
			||||||
 | 
					  ...tseslint.configs.stylisticTypeChecked,
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Project-specific configuration
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    languageOptions: {
 | 
				
			||||||
 | 
					      parserOptions: {
 | 
				
			||||||
 | 
					        projectService: true,
 | 
				
			||||||
 | 
					        tsconfigRootDir: import.meta.dirname,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Next.js specific overrides
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    files: ['**/*.{js,jsx,ts,tsx}'],
 | 
				
			||||||
 | 
					    rules: {
 | 
				
			||||||
 | 
					      // Next.js already handles React imports
 | 
				
			||||||
 | 
					      '@typescript-eslint/no-unused-vars': [
 | 
				
			||||||
 | 
					        'error',
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          argsIgnorePattern: '^_',
 | 
				
			||||||
 | 
					          varsIgnorePattern: '^_',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Config files don't need strict type checking
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    files: ['**/*.config.{js,ts,mjs}', 'tailwind.config.{js,ts}'],
 | 
				
			||||||
 | 
					    ...tseslint.configs.disableTypeChecked,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Ignore build outputs and dependencies
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    ignores: ['.next/**', 'node_modules/**', 'dist/**', 'build/**', 'drizzle/**/*.sql'],
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
							
								
								
									
										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);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										14
									
								
								lib/db/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/db/index.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					import { drizzle } from 'drizzle-orm/node-postgres';
 | 
				
			||||||
 | 
					import * as schema from './schema';
 | 
				
			||||||
 | 
					import dotenv from 'dotenv';
 | 
				
			||||||
 | 
					dotenv.config({ path: ['.env.local', '.env'] });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const DATABASE_URL = process.env.POSTGRES_URL;
 | 
				
			||||||
 | 
					if (!DATABASE_URL) {
 | 
				
			||||||
 | 
					  throw new Error('POSTGRES_URL environment variable is required');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const db = drizzle(DATABASE_URL, { schema });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// Re-export schema types for convenience
 | 
				
			||||||
 | 
					export * from './schema';
 | 
				
			||||||
							
								
								
									
										76
									
								
								lib/db/schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								lib/db/schema.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,76 @@
 | 
				
			|||||||
 | 
					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) => [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) => [
 | 
				
			||||||
 | 
					    index('habit_logs_habit_id_idx').on(table.habitId),
 | 
				
			||||||
 | 
					    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'],
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
							
								
								
									
										8596
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8596
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										72
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										72
									
								
								package.json
									
									
									
									
									
								
							@@ -2,38 +2,66 @@
 | 
				
			|||||||
  "name": "trackeveryday",
 | 
					  "name": "trackeveryday",
 | 
				
			||||||
  "version": "0.1.0",
 | 
					  "version": "0.1.0",
 | 
				
			||||||
  "private": true,
 | 
					  "private": true,
 | 
				
			||||||
 | 
					  "type": "module",
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "dev": "next dev --turbopack",
 | 
					    "dev": "next dev --turbopack",
 | 
				
			||||||
    "build": "next build",
 | 
					    "build": "next build",
 | 
				
			||||||
    "start": "next start",
 | 
					    "start": "next start",
 | 
				
			||||||
    "lint": "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": {
 | 
				
			||||||
    "class-variance-authority": "^0.7.0",
 | 
					    "@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",
 | 
				
			||||||
    "clsx": "^2.1.1",
 | 
					    "clsx": "^2.1.1",
 | 
				
			||||||
    "cssnano": "^7.0.1",
 | 
					    "cssnano": "^7.1.0",
 | 
				
			||||||
    "lucide-react": "^0.509.0",
 | 
					    "date-fns": "^4.1.0",
 | 
				
			||||||
    "next": "15.3.2",
 | 
					    "dotenv": "^17.2.0",
 | 
				
			||||||
    "next-plausible": "^3.12.0",
 | 
					    "drizzle-orm": "^0.44.3",
 | 
				
			||||||
 | 
					    "lucide-react": "^0.548.0",
 | 
				
			||||||
 | 
					    "nanoid": "^5.1.5",
 | 
				
			||||||
 | 
					    "next": "15.5.6",
 | 
				
			||||||
 | 
					    "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.0.0",
 | 
					    "postcss-preset-env": "^10.2.4",
 | 
				
			||||||
    "react": "19.1.0",
 | 
					    "react": "19.2.0",
 | 
				
			||||||
    "react-dom": "19.1.0",
 | 
					    "react-dom": "19.2.0",
 | 
				
			||||||
    "sharp": "^0.34.0",
 | 
					    "tailwind-merge": "^3.3.1",
 | 
				
			||||||
    "tailwind-merge": "^3.0.0",
 | 
					 | 
				
			||||||
    "tailwindcss-animate": "^1.0.7"
 | 
					    "tailwindcss-animate": "^1.0.7"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
    "@tailwindcss/postcss": "^4.1.6",
 | 
					    "@eslint/js": "9.38.0",
 | 
				
			||||||
    "@types/node": "22.15.17",
 | 
					    "@next/eslint-plugin-next": "15.5.6",
 | 
				
			||||||
    "@types/react": "19.1.3",
 | 
					    "@tailwindcss/postcss": "4.1.16",
 | 
				
			||||||
    "@types/react-dom": "19.1.3",
 | 
					    "@types/node": "22.18.13",
 | 
				
			||||||
    "eslint": "8.57.1",
 | 
					    "@types/pg": "8.15.6",
 | 
				
			||||||
    "eslint-config-next": "15.3.2",
 | 
					    "@types/react": "19.2.2",
 | 
				
			||||||
    "postcss": "8.5.3",
 | 
					    "@types/react-dom": "19.2.2",
 | 
				
			||||||
    "tailwindcss": "^4.1.6",
 | 
					    "@typescript-eslint/eslint-plugin": "8.46.2",
 | 
				
			||||||
    "turbo": "2.5.3",
 | 
					    "@typescript-eslint/parser": "8.46.2",
 | 
				
			||||||
    "typescript": "5.8.3"
 | 
					    "drizzle-kit": "0.31.6",
 | 
				
			||||||
 | 
					    "eslint": "9.38.0",
 | 
				
			||||||
 | 
					    "eslint-config-next": "15.5.6",
 | 
				
			||||||
 | 
					    "postcss": "8.5.6",
 | 
				
			||||||
 | 
					    "prettier": "3.6.2",
 | 
				
			||||||
 | 
					    "prettier-plugin-tailwindcss": "0.7.1",
 | 
				
			||||||
 | 
					    "tailwindcss": "4.1.16",
 | 
				
			||||||
 | 
					    "turbo": "2.5.8",
 | 
				
			||||||
 | 
					    "typescript": "5.9.3",
 | 
				
			||||||
 | 
					    "typescript-eslint": "8.46.2"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "packageManager": "npm@11.3.0"
 | 
					  "packageManager": "pnpm@10.20.0"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7418
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										7418
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										3
									
								
								pnpm-workspace.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								pnpm-workspace.yaml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					onlyBuiltDependencies:
 | 
				
			||||||
 | 
					  - '@tailwindcss/oxide'
 | 
				
			||||||
 | 
					  - sharp
 | 
				
			||||||
		Reference in New Issue
	
	Block a user