diff --git a/.env.example b/.env.example index d67873f..f561b45 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,15 @@ -NEXT_PUBLIC_BASE_URL= -AUTH_SECRET= - -# Supabase -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= - - +NEXT_PUBLIC_BASE_URL=http://localhost:3000 + +# next-auth +AUTH_SECRET= +AUTH_RESEND_KEY= + +# Database +POSTGRES_URL= +POSTGRES_PRISMA_URL= +POSTGRES_URL_NO_SSL= +POSTGRES_URL_NON_POOLING= +POSTGRES_USER= +POSTGRES_HOST= +POSTGRES_PASSWORD= +POSTGRES_DATABASE= diff --git a/.gitignore b/.gitignore index fd3dbb5..4f750ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,36 +1,43 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# nextjs public +public/uploads + +# drizzle +/drizzle diff --git a/README.md b/README.md index feb9797..1d4aa59 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,70 @@ -# cardia - -**cardia** is a blood pressure documentation application built with Next.js, using Drizzle as an ORM, and Supabase for authentication and database management. It allows users to easily add and track their blood pressure records. - -## Getting Started - -To set up cardia locally, follow these steps: - -1. Clone this repository to your local machine: - - ```bash - git clone https://github.com/visualcookie/cardia.git - - OR - - gh repo clone visualcookie/cardia - ``` - -2. Navigate to the project directory: - - ```bash - cd cardia - ``` - -3. Install dependencies: - - ```bash - bun install - ``` - -4. Set up your Supabase project: - - - Sign up or log in to [Supabase](https://supabase.io/). - - Create a new project and database. - - Set up authentication and obtain your Supabase URL and public key. - -5. Configure environment variables: - - Create a `.env.local` file in the root directory and add the following: - - ```plaintext - NEXT_PUBLIC_BASE_URL=http://localhost:3000 - NEXT_PUBLIC_SUPABASE_URL=your-supabase-url - NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-public-key - ``` - -6. Run the development server: - - ```bash - bun run dev - ``` - -7. Open [http://localhost:3000](http://localhost:3000) in your browser to access cardia. - -## TODO - -- [ ] Add data visualization for blood pressure records. -- [ ] Implement reminders or notifications for regular blood pressure checks. -- [ ] Improve the export. - -## License - -This project is licensed under the "The Unlicense" license. See the [LICENSE.md](LICENSE.md) file for details. +# cardia + +**cardia** is a blood pressure documentation application built with Next.js, using Drizzle as an ORM, and Supabase for authentication and database management. It allows users to easily add and track their blood pressure records. + +## Getting Started + +To set up cardia locally, follow these steps: + +1. Clone this repository to your local machine: + + ```bash + git clone https://github.com/visualcookie/cardia.git + + OR + + gh repo clone visualcookie/cardia + ``` + +2. Navigate to the project directory: + + ```bash + cd cardia + ``` + +3. Install dependencies: + + ```bash + bun install + ``` + +4. Set up your Supabase project: + + - Sign up or log in to [Supabase](https://supabase.io/). + - Create a new project and database. + - Set up authentication and obtain your Supabase URL and public key. + +5. Configure environment variables: + + Create a `.env.local` file in the root directory and add the following: + + ```plaintext + NEXT_PUBLIC_BASE_URL=http://localhost:3000 + NEXT_PUBLIC_SUPABASE_URL=your-supabase-url + NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-public-key + ``` + +6. Run the development server: + + ```bash + bun run dev + ``` + +7. Open [http://localhost:3000](http://localhost:3000) in your browser to access cardia. + +## TODO + +- [ ] Add data visualization for blood pressure records +- [ ] Implement reminders for regular blood pressure checks +- [ ] Improve the export +- [ ] Add import from CSV +- [ ] Add filter to stages +- [ ] Group data by day/date +- [ ] Add filter by date range +- [ ] Display last 10 records +- [ ] Add "Load more" functionality +- [ ] Settings page + +## License + +This project is licensed under the "The Unlicense" license. See the [LICENSE.md](LICENSE.md) file for details. diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index 8754aa3..b1af14f Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle.config.ts b/drizzle.config.ts index 66aa921..c9a495d 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,11 +1,15 @@ -import 'dotenv/config' -import type { Config } from 'drizzle-kit' - -export default { - schema: './src/db/schema.ts', - out: './drizzle', - driver: 'pg', - dbCredentials: { - connectionString: process.env.DB_URL!, - }, -} satisfies Config +import 'dotenv/config' +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'postgresql', + dbCredentials: { + url: process.env.POSTGRES_URL!, + }, + migrations: { + table: 'migrations', + schema: 'cardia', + }, + schema: './src/db/schema.ts', + out: './drizzle', +}) diff --git a/drizzle/0000_careful_shotgun.sql b/drizzle/0000_careful_shotgun.sql deleted file mode 100644 index 311d32a..0000000 --- a/drizzle/0000_careful_shotgun.sql +++ /dev/null @@ -1,30 +0,0 @@ -CREATE SCHEMA IF NOT EXISTS "auth"; ---> statement-breakpoint -CREATE SCHEMA IF NOT EXISTS "cardia"; ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "cardia"."users" ( - "id" uuid PRIMARY KEY NOT NULL, - "username" text, - "profile_picture" text -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "cardia"."record" ( - "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "user_id" uuid NOT NULL, - "systolic" integer NOT NULL, - "diastolic" integer NOT NULL, - "pulse" integer NOT NULL, - "recorded_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "cardia"."users" ADD CONSTRAINT "users_id_users_id_fk" FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "cardia"."record" ADD CONSTRAINT "record_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "cardia"."users"("id") ON DELETE cascade ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json deleted file mode 100644 index b67b638..0000000 --- a/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "id": "6723e6e2-f2b2-4adb-96b0-27336337c267", - "prevId": "00000000-0000-0000-0000-000000000000", - "version": "5", - "dialect": "pg", - "tables": { - "users": { - "name": "users", - "schema": "cardia", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "username": { - "name": "username", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "profile_picture": { - "name": "profile_picture", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "users_id_users_id_fk": { - "name": "users_id_users_id_fk", - "tableFrom": "users", - "tableTo": "users", - "schemaTo": "auth", - "columnsFrom": [ - "id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "record": { - "name": "record", - "schema": "cardia", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "systolic": { - "name": "systolic", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "diastolic": { - "name": "diastolic", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "pulse": { - "name": "pulse", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "recorded_at": { - "name": "recorded_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": { - "record_user_id_users_id_fk": { - "name": "record_user_id_users_id_fk", - "tableFrom": "record", - "tableTo": "users", - "schemaTo": "cardia", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - } - }, - "enums": {}, - "schemas": { - "auth": "auth", - "cardia": "cardia" - }, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json deleted file mode 100644 index 420ecb4..0000000 --- a/drizzle/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "5", - "dialect": "pg", - "entries": [ - { - "idx": 0, - "version": "5", - "when": 1711815144834, - "tag": "0000_careful_shotgun", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index 485ebc3..c0eb2c2 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,22 +1,15 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - redirects: async () => { - return [ - { - source: '/', - destination: '/app', - permanent: true, - }, - ] - }, - images: { - remotePatterns: [ - { - protocol: 'https', - hostname: 'static.vecteezy.com', - }, - ], - }, -} - -export default nextConfig +/** @type {import('next').NextConfig} */ +const nextConfig = { + redirects: async () => { + return [ + { + source: '/', + destination: '/app', + permanent: true, + }, + ] + }, +} + +export default nextConfig + diff --git a/package.json b/package.json index 8962328..15f5241 100644 --- a/package.json +++ b/package.json @@ -1,65 +1,69 @@ { "name": "cardia", - "version": "0.1.0", + "version": "0.2.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", - "migrate": "drizzle-kit generate:pg && node -r esbuild-register src/db/migrate.ts" + "migrate": "drizzle-kit generate && node -r esbuild-register src/db/migrate.ts" }, "dependencies": { - "@auth/core": "^0.28.1", - "@auth/drizzle-adapter": "^0.8.1", - "@hookform/resolvers": "^3.3.4", - "@radix-ui/react-alert-dialog": "^1.0.5", - "@radix-ui/react-avatar": "^1.0.4", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-dropdown-menu": "^2.0.6", + "@auth/drizzle-adapter": "^1.4.2", + "@hookform/resolvers": "^3.9.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", - "@radix-ui/react-label": "^2.0.2", - "@radix-ui/react-popover": "^1.0.7", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-toast": "^1.1.5", - "@supabase/ssr": "^0.1.0", - "@supabase/supabase-js": "^2.41.1", - "@tanstack/react-table": "^8.15.3", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-tooltip": "^1.1.4", + "@rescale/nemo": "^1.2.2", + "@tanstack/react-table": "^8.20.5", + "@vercel/postgres": "^0.10.0", "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", + "clsx": "^2.1.1", "date-fns": "^3.6.0", - "dayjs": "^1.11.10", + "dayjs": "^1.11.13", "dotenv": "^16.4.5", - "drizzle-orm": "^0.30.6", + "drizzle-orm": "^0.31.4", "exceljs": "^4.4.0", - "lucide-react": "^0.363.0", - "next": "14.1.4", - "next-auth": "beta", - "pg": "^8.11.3", + "lucide-react": "^0.395.0", + "multer": "^1.4.5-lts.1", + "next": "14.2.4", + "next-auth": "^5.0.0-beta.20", + "pg": "^8.12.0", "postgres": "^3.4.4", - "react": "^18", - "react-aria": "^3.32.1", - "react-day-picker": "^8.10.0", - "react-dom": "^18", - "react-hook-form": "^7.51.2", - "react-stately": "^3.30.1", - "tailwind-merge": "^2.2.2", + "react": "^18.3.1", + "react-aria": "^3.34.3", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-dropzone": "^14.2.3", + "react-easy-crop": "^5.0.8", + "react-hook-form": "^7.53.0", + "react-stately": "^3.32.2", + "tailwind-merge": "^2.5.2", "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4" + "zod": "^3.23.8" }, "devDependencies": { - "@types/node": "^20", - "@types/pg": "^8.11.4", - "@types/react": "^18", - "@types/react-dom": "^18", - "autoprefixer": "^10.0.1", - "drizzle-kit": "^0.20.14", - "eslint": "^8", - "eslint-config-next": "14.1.4", + "@types/multer": "^1.4.12", + "@types/node": "^20.16.5", + "@types/pg": "^8.11.9", + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.20", + "drizzle-kit": "^0.22.8", + "eslint": "^8.57.0", + "eslint-config-next": "14.2.4", "eslint-config-prettier": "^9.1.0", - "postcss": "^8", - "tailwindcss": "^3.3.0", - "typescript": "^5" + "postcss": "^8.4.45", + "tailwindcss": "^3.4.11", + "typescript": "^5.6.2" } } diff --git a/src/actions/auth.ts b/src/actions/auth.ts new file mode 100644 index 0000000..dfe0445 --- /dev/null +++ b/src/actions/auth.ts @@ -0,0 +1,23 @@ +'use server' + +import { AuthError } from 'next-auth' +import { signIn } from '@/auth' + +export async function signinAction(formData: { email: string }) { + try { + await signIn('resend', formData) + return { + status: 'success', + message: `A magic link has been sent to ${formData.email}`, + } + } catch (error) { + if (error instanceof AuthError) { + return { + status: 'error', + message: error.message, + } + } + + throw error + } +} diff --git a/src/actions/records.ts b/src/actions/records.ts index f418b18..8b703d4 100644 --- a/src/actions/records.ts +++ b/src/actions/records.ts @@ -1,46 +1,46 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { eq } from 'drizzle-orm' -import { z } from 'zod' -import { newRecordSchema } from '@/components/NewRecordForm' -import { db } from '@/db' -import { records } from '@/db/schema' - -export async function createRecord( - userId: string, - data: z.infer -) { - const transformData = { - ...data, - systolic: parseInt(data.systolic, 10), - diastolic: parseInt(data.diastolic, 10), - pulse: parseInt(data.pulse, 10), - } - const createRecord = await db - .insert(records) - .values({ - ...transformData, - userId, - }) - .returning() - - if (!createRecord) { - throw new Error('Could not create record') - } - - revalidatePath('/app') -} - -export async function deleteRecord(recordId: string) { - const deleteRecord = await db - .delete(records) - .where(eq(records.id, recordId)) - .returning() - - if (!deleteRecord) { - throw new Error('Could not delete record') - } - - revalidatePath('/app') -} +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { readingFormSchema } from '@/lib/form-validations' +import { + addReading, + deleteReadingById, + updateReadingById, +} from '@/lib/db/queries' + +export async function addUserReading( + userId: string, + data: z.infer +) { + const reading = await addReading(userId, data) + + if (!reading) { + throw new Error('Could not create record') + } + + revalidatePath('/app') +} + +export async function updateUserReading( + id: string, + data: z.infer +) { + const reading = await updateReadingById(id, data) + + if (!reading) { + throw new Error(`Could not update record ${id}`) + } + + revalidatePath('/app') +} + +export async function deleteUserReading(recordId: string) { + const reading = await deleteReadingById(recordId) + + if (!reading) { + throw new Error(`Could not delete record ${recordId}`) + } + + revalidatePath('/app') +} diff --git a/src/actions/settings.ts b/src/actions/settings.ts new file mode 100644 index 0000000..0f85e9b --- /dev/null +++ b/src/actions/settings.ts @@ -0,0 +1,66 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { eq } from 'drizzle-orm' +import { db } from '@/lib/db' +import { users } from '@/lib/db/schema' + +export async function updateUserSettings( + userId: string, + data: { + username?: string + email?: string + avatar?: string + } +) { + try { + await db + .update(users) + .set({ + name: data.username, + email: data.email, + image: data.avatar, + }) + .where(eq(users.id, userId)) + + revalidatePath('/settings') + return { success: true } + } catch (error) { + console.error('Failed to update user settings:', error) + return { success: false, error: 'Failed to update user settings' } + } +} + +export async function updateUserAvatar(userId: string, avatarUrl: string) { + try { + await db + .update(users) + .set({ + image: avatarUrl, + }) + .where(eq(users.id, userId)) + + revalidatePath('/settings') + return { success: true, avatarUrl } + } catch (error) { + console.error('Failed to update user avatar:', error) + return { success: false, error: 'Failed to update user avatar' } + } +} + +export async function deleteUserAvatar(userId: string) { + try { + await db + .update(users) + .set({ + image: null, + }) + .where(eq(users.id, userId)) + + revalidatePath('/settings') + return { success: true } + } catch (error) { + console.error('Failed to delete user avatar:', error) + return { success: false, error: 'Failed to delete user avatar' } + } +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..5e24c51 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from '@/auth' +export const { GET, POST } = handlers diff --git a/src/app/api/records/export/route.ts b/src/app/api/records/export/route.ts deleted file mode 100644 index 6b4ccfe..0000000 --- a/src/app/api/records/export/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { eq } from 'drizzle-orm' -import { Workbook } from 'exceljs' -import { db } from '@/db' -import { records } from '@/db/schema' -import { createClient } from '@/lib/supabase/server' - -enum StatusCodes { - UNAUTHORIZED = 'UNAUTHORIZED', - BAD_REQUEST = 'BAD_REQUEST', - EXPORT_FAILED = 'EXPORT_FAILED', - EXPORT_SUCCESS = 'EXPORT_SUCCESS', -} - -export async function POST(req: Request) { - const supabase = createClient() - const { - data: { user }, - error, - } = await supabase.auth.getUser() - - if (!user || error) { - return Response.json(StatusCodes.UNAUTHORIZED, { - status: 401, - }) - } - - const dbRecords = await db.query.records.findMany({ - where: eq(records.userId, user.id), - }) - - if (dbRecords.length === 0) { - return Response.json(StatusCodes.EXPORT_FAILED, { - status: 400, - }) - } - - const workbook = new Workbook() - const worksheet = workbook.addWorksheet(`Records`) - worksheet.columns = [ - { header: 'Recorded at', key: 'recordedAt', width: 20 }, - { header: 'Systolic', key: 'systolic', width: 10 }, - { header: 'Diastolic', key: 'diastolic', width: 10 }, - { header: 'Pulse', key: 'pulse', width: 10 }, - ] - - dbRecords.forEach((record) => { - worksheet.addRow(record) - }) - - worksheet.getRow(1).eachCell((cell) => { - cell.font = { bold: true } - }) - - const buffer = await workbook.xlsx.writeBuffer() - - if (buffer.byteLength === 0) { - return Response.json(StatusCodes.EXPORT_FAILED, { - status: 500, - }) - } - - return new Response(buffer, { - status: 200, - headers: { - 'Content-Type': 'application/vnd.ms-excel', - 'Content-Disposition': `attachment; filename=records.xlsx`, - }, - }) -} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..872adad --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import multer from 'multer' +import path from 'path' +import { writeFile } from 'fs/promises' + +const upload = multer({ + limits: { + fileSize: 5 * 1024 * 1024, // 5 MB + }, +}) + +function runMiddleware(req: NextRequest, middleware: any) { + return new Promise((resolve, reject) => { + middleware( + req, + { + end: (data: any) => { + resolve(data) + }, + setHeader: () => {}, + status: (code: number) => ({ end: (data: any) => reject(data) }), + }, + (result: any) => { + resolve(result) + } + ) + }) +} + +export async function POST(request: NextRequest) { + try { + await runMiddleware(request, upload.single('avatar')) + + const formData = await request.formData() + const file = formData.get('avatar') as File | null + + if (!file) { + return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }) + } + + const buffer = await file.arrayBuffer() + const filename = Date.now() + '-' + file.name.replaceAll(' ', '_') + const filePath = path.join(process.cwd(), 'public', 'uploads', filename) + + await writeFile(filePath, Buffer.from(buffer)) + + const fileUrl = `/uploads/${filename}` + return NextResponse.json({ fileUrl }) + } catch (error) { + console.error('Upload error:', error) + if (error instanceof multer.MulterError) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }) + } +} + +export const config = { + api: { + bodyParser: false, + }, +} diff --git a/src/app/app/columns.tsx b/src/app/app/columns.tsx deleted file mode 100644 index 0653b73..0000000 --- a/src/app/app/columns.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client' - -import DeleteRecordDialog from '@/components/DeleteRecordDialog' -import { ColumnDef } from '@tanstack/react-table' - -export type Records = { - id: string - recordedAt: Date - systolic: number - diastolic: number - pulse: number - userId: string -} - -export const columns: ColumnDef[] = [ - { - header: 'Date of record', - accessorKey: 'recordedAt', - enableSorting: true, - cell: ({ row }) => { - const recordedAt = row.getValue('recordedAt') as string - const formatted = new Intl.DateTimeFormat('de-DE', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - }).format(new Date(recordedAt)) - - return formatted - }, - }, - { - header: 'SYS', - accessorKey: 'systolic', - size: 40, - }, - { - header: 'DIA', - accessorKey: 'diastolic', - size: 40, - }, - { - header: 'Pulse', - accessorKey: 'pulse', - size: 40, - }, - { - id: 'actions', - size: 80, - cell: ({ row }) => { - const record = row.original - return ( -
- -
- ) - }, - }, -] diff --git a/src/app/app/data-table.tsx b/src/app/app/data-table.tsx deleted file mode 100644 index 99cc6c8..0000000 --- a/src/app/app/data-table.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'use client' - -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, -} from '@tanstack/react-table' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' - -interface DataTableProps { - columns: ColumnDef[] - data: TData[] -} - -export function DataTable({ - columns, - data, -}: Readonly>) { - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - }) - - return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ) - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No results. - - - )} - -
-
- ) -} diff --git a/src/app/app/layout.tsx b/src/app/app/layout.tsx index a4a5e66..123abe1 100644 --- a/src/app/app/layout.tsx +++ b/src/app/app/layout.tsx @@ -1,39 +1,21 @@ -import { eq } from 'drizzle-orm' -import { redirect } from 'next/navigation' -import { db } from '@/db' -import { users } from '@/db/schema' -import { createClient } from '@/lib/supabase/server' -import { AuthProvider } from '@/providers/SupabaseAuthProvider' -import Navbar from '@/components/Navbar' - -const AppLayout: React.FC<{ children: React.ReactNode }> = async ({ - children, -}) => { - const supabase = createClient() - const { data, error } = await supabase.auth.getUser() - - if (error || !data?.user) { - redirect('/auth/signin') - } - - const dbUser = await db.query.users.findFirst({ - where: eq(users.id, data.user?.id), - }) - - if (!dbUser) { - redirect('/app/welcome/onboarding') - } - - return ( - -
- -
-
{children}
-
-
-
- ) -} - -export default AppLayout +import { auth } from '@/auth' +import Navbar from '@/components/navbar' +import { redirect } from 'next/navigation' + +const AppLayout: React.FC<{ children: React.ReactNode }> = async ({ + children, +}) => { + const session = await auth() + if (!session?.user) return redirect('/auth/signin') + + return ( +
+ +
+
{children}
+
+
+ ) +} + +export default AppLayout diff --git a/src/app/app/new-user/page.tsx b/src/app/app/new-user/page.tsx new file mode 100644 index 0000000..92c613e --- /dev/null +++ b/src/app/app/new-user/page.tsx @@ -0,0 +1,33 @@ +import { auth } from '@/auth' +import SettingsForm from '@/components/settings-form' +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@/components/ui/card' + +const NewUserPage = async () => { + const session = await auth() + + return ( +
+ + + 👋 Welcome to Cardia + + To probably set you up, we need further informations about you. + Don't worry, we won't sell your data. + + + + {/* NOTE: can i reuse the settings form here? */} + + + +
+ ) +} + +export default NewUserPage diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 478cae1..2e514e6 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -1,59 +1,33 @@ -import { desc, eq } from 'drizzle-orm' -import { redirect } from 'next/navigation' -import ExportRecords from '@/components/ExportRecords' -import NewRecordDialog from '@/components/NewRecordDialog' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' -import { db } from '@/db' -import { records, users } from '@/db/schema' -import { createClient } from '@/lib/supabase/server' -import { columns } from './columns' -import { DataTable } from './data-table' - -const AppMainPage = async () => { - const supabase = createClient() - const { data, error } = await supabase.auth.getUser() - - if (error || !data?.user) { - redirect('/auth/signin') - } - - // TODO: Move this to a middleware - const shouldOnboard = await db.query.users.findFirst({ - where: eq(users.id, data.user.id), - }) - - if (!shouldOnboard?.username || !shouldOnboard?.profilePicture) { - redirect('/app/welcome') - } - - const dbRecords = await db.query.records.findMany({ - where: eq(records.userId, data.user.id), - orderBy: desc(records.recordedAt), - }) - - return ( - - -
- Records - Your blood pressure records. -
-
- - -
-
- - - -
- ) -} - -export default AppMainPage +import { auth } from '@/auth' +import { getAllReadingsByUser } from '@/lib/db/queries' +import { EmptyRecord } from '@/components/empty-record' +import { RecordCard } from '@/components/record-card' +import { Toolbar } from '@/components/toolbar' + +const AppMainPage = async () => { + const session = await auth() + const readings = await getAllReadingsByUser(session?.user?.id!) + + return ( +
+
+

Blood Pressure Records

+

+ Track and manage your blood pressure readings over time. Add new + records, view your history, and monitor your cardiovascular health. +

+
+ + {readings.length <= 0 && } + {readings.length > 0 && } + {readings.map(({ id, systolic, diastolic, pulse, createdAt }) => ( + + ))} +
+ ) +} + +export default AppMainPage diff --git a/src/app/app/settings/page.tsx b/src/app/app/settings/page.tsx index ea16887..74fb9c6 100644 --- a/src/app/app/settings/page.tsx +++ b/src/app/app/settings/page.tsx @@ -1,18 +1,32 @@ -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' - -const SettingsPage = async () => { - return ( -
- - - Account Settings - - - {/* */} - - -
- ) -} - -export default SettingsPage +import { auth } from '@/auth' +import SettingsForm from '@/components/settings-form' +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from '@/components/ui/card' + +const SettingsPage = async () => { + const session = await auth() + + return ( +
+ + + Your settings + + Manage your account settings, customize your profile, and set + communication preferences. + + + + + + +
+ ) +} + +export default SettingsPage diff --git a/src/app/app/welcome/actions.ts b/src/app/app/welcome/actions.ts deleted file mode 100644 index 8b58db8..0000000 --- a/src/app/app/welcome/actions.ts +++ /dev/null @@ -1,29 +0,0 @@ -// TODO: Implement validation -'use server' - -import { redirect } from 'next/navigation' -import { z } from 'zod' -import { eq } from 'drizzle-orm' -import { db } from '@/db' -import { users } from '@/db/schema' -import { onboardingSchema } from './onboarding-form' - -export async function setupUser( - formData: z.infer, - userId: string -) { - const setupUser = await db - .update(users) - .set({ - username: formData.username, - profilePicture: formData.profilePicture, - }) - .where(eq(users.id, userId)) - .returning() - - if (!setupUser) { - throw new Error('Could not set up user') - } - - redirect('/app') -} diff --git a/src/app/app/welcome/onboarding-form.tsx b/src/app/app/welcome/onboarding-form.tsx deleted file mode 100644 index fa4ae7c..0000000 --- a/src/app/app/welcome/onboarding-form.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client' - -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' -import { Button } from '@/components/ui/button' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { toast } from '@/components/ui/use-toast' -import { setupUser } from './actions' -import { useAuth } from '@/providers/SupabaseAuthProvider' - -export const onboardingSchema = z.object({ - username: z.string().min(3, 'Username must be at least 3 characters'), - profilePicture: z.string().url('Profile picture must be a valid URL'), -}) - -const OnboardingForm: React.FC = () => { - const auth = useAuth() - const form = useForm>({ - resolver: zodResolver(onboardingSchema), - }) - - const onSubmit = async (data: z.infer) => { - try { - await setupUser(data, auth.user?.id ?? '') - toast({ - title: 'Ready to go!', - description: 'You can now start using the app.', - }) - } catch (error) { - toast({ - title: 'Oops!', - description: 'Could not set up your profile. Please try again.', - }) - } - } - - return ( -
- - ( - - Username - - - - - - )} - /> - ( - - Profile picture (URL) - - - - - - )} - /> - - - - ) -} - -export default OnboardingForm diff --git a/src/app/app/welcome/page.tsx b/src/app/app/welcome/page.tsx deleted file mode 100644 index 96f9017..0000000 --- a/src/app/app/welcome/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { redirect } from 'next/navigation' -import { createClient } from '@/lib/supabase/server' -import OnboardingForm from './onboarding-form' -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card' - -const WelcomePage = async () => { - return ( -
- - - Set up your profile - - {`Welcome! Let's set up your profile so you can start using the app.`} - - - - - - -
- ) -} - -export default WelcomePage diff --git a/src/app/auth/confirm/route.tsx b/src/app/auth/confirm/route.tsx deleted file mode 100644 index ae0c1e4..0000000 --- a/src/app/auth/confirm/route.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { type EmailOtpType } from '@supabase/supabase-js' -import { type NextRequest, NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url) - const token_hash = searchParams.get('token_hash') - const type = searchParams.get('type') as EmailOtpType | null - const next = searchParams.get('next') ?? '/' - - const redirectTo = request.nextUrl.clone() - redirectTo.pathname = next - redirectTo.searchParams.delete('token_hash') - redirectTo.searchParams.delete('type') - - if (token_hash && type) { - const supabase = createClient() - - const { error } = await supabase.auth.verifyOtp({ - type, - token_hash, - }) - if (!error) { - redirectTo.searchParams.delete('next') - return NextResponse.redirect(redirectTo) - } - } - - // return the user to an error page with some instructions - redirectTo.pathname = '/error' - return NextResponse.redirect(redirectTo) -} diff --git a/src/app/auth/signin/actions.ts b/src/app/auth/signin/actions.ts deleted file mode 100644 index 7bc8687..0000000 --- a/src/app/auth/signin/actions.ts +++ /dev/null @@ -1,30 +0,0 @@ -'use server' - -import { SignInWithPasswordlessCredentials } from '@supabase/supabase-js' -import { createClient } from '@/lib/supabase/server' -import { SigninFields } from './signin-form' - -export async function signin(formData: SigninFields) { - const supabase = createClient() - - const data = { - email: formData.email, - options: { - emailRedirectTo: process.env.NEXT_PUBLIC_BASE_URL + '/auth/confirm', - }, - } satisfies SignInWithPasswordlessCredentials - - const { error } = await supabase.auth.signInWithOtp(data) - - if (error) { - return { - success: false, - message: error.code, - } - } - - return { - success: true, - message: 'A magic link has been sent to your email address.', - } -} diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 5414bfe..aa59d12 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,32 +1,25 @@ -import Image from 'next/image' -import SignInForm from './signin-form' - -const SignInPage = () => { - return ( -
-
-
-
-

Sign in

-

- Sign in to your account. -

-
- -

{`Hint: If you don't have an account yet, this will create you an account.`}

-
-
-
- Image -
-
- ) -} - -export default SignInPage +import { HeartPulseIcon } from 'lucide-react' +import { Card, CardContent } from '@/components/ui/card' +import SignInForm from './signin-form' + +const SignInPage = () => { + return ( +
+
+
+ +

+ Cardia +

+
+ + + + + +
+
+ ) +} + +export default SignInPage diff --git a/src/app/auth/signin/signin-form.tsx b/src/app/auth/signin/signin-form.tsx index fe3ae6d..3125b63 100644 --- a/src/app/auth/signin/signin-form.tsx +++ b/src/app/auth/signin/signin-form.tsx @@ -1,132 +1,132 @@ -'use client' - -import { useEffect, useState, useTransition } from 'react' -import { useForm } from 'react-hook-form' -import { AlertTriangleIcon } from 'lucide-react' -import { zodResolver } from '@hookform/resolvers/zod' -import { z } from 'zod' -import SubmitButton from '@/components/SubmitButton' -import { Alert, AlertDescription } from '@/components/ui/alert' -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form' -import { Input } from '@/components/ui/input' -import { signinSchema } from '@/lib/form-validations' -import { signin } from './actions' - -export type SigninFields = z.infer - -const SignInForm: React.FC = () => { - const [pending, startTransition] = useTransition() - const [waitCounter, setWaitCounter] = useState(0) - const [waitingForMagicLink, setWaitingForMagicLink] = useState(false) - const [signinError, setSigninError] = useState(undefined) - const form = useForm({ - resolver: zodResolver(signinSchema), - mode: 'onChange', - }) - - const onSubmit = async (formData: SigninFields) => { - setSigninError(undefined) - setWaitingForMagicLink(false) - - startTransition(async () => { - const { success, message } = await signin(formData) - - if (!success) { - setSigninError(message) - } - - if (success) { - setWaitCounter(30) - } - }) - } - - useEffect(() => { - if (signinError) { - setWaitingForMagicLink(false) - setWaitCounter(0) - } - }, [signinError]) - - useEffect(() => { - let countdown: NodeJS.Timeout - - if (form.formState.isSubmitted && waitCounter > 0) { - setWaitingForMagicLink(true) - const duration = waitCounter * 1000 - - countdown = setInterval(() => { - setWaitCounter((prevCounter) => prevCounter - 1) - }, 1000) - - setTimeout(() => { - setWaitingForMagicLink(false) - clearInterval(countdown) - }, duration) - } - - return () => { - clearInterval(countdown) - } - }, [form.formState.isSubmitted, waitCounter]) - - return ( -
- - ( - - Email - - - - - - )} - /> - {signinError && ( - - - {signinError} - - )} - {waitingForMagicLink && ( - - - If an account with this email exists, you will receive a magic - link in your inbox. - - - )} - - - - ) -} - -export default SignInForm +'use client' + +import { useEffect, useState, useTransition } from 'react' +import { useForm } from 'react-hook-form' +import { AlertTriangleIcon } from 'lucide-react' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { signinSchema } from '@/lib/form-validations' +import { signinAction } from '@/actions/auth' +import SubmitButton from '@/components/submit-button' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' + +export type SigninFields = z.infer + +const SignInForm: React.FC = () => { + const [pending, startTransition] = useTransition() + const [waitCounter, setWaitCounter] = useState(0) + const [waitingForMagicLink, setWaitingForMagicLink] = useState(false) + const [signinError, setSigninError] = useState(undefined) + const form = useForm({ + resolver: zodResolver(signinSchema), + mode: 'onChange', + }) + + const onSubmit = async (formData: SigninFields) => { + setSigninError(undefined) + setWaitingForMagicLink(false) + + startTransition(async () => { + const { status, message } = await signinAction(formData) + + if (status === 'error') { + setSigninError(message) + } + + if (status === 'success') { + setWaitCounter(30) + } + }) + } + + useEffect(() => { + if (signinError) { + setWaitingForMagicLink(false) + setWaitCounter(0) + } + }, [signinError]) + + useEffect(() => { + let countdown: NodeJS.Timeout + + if (form.formState.isSubmitted && waitCounter > 0) { + setWaitingForMagicLink(true) + const duration = waitCounter * 1000 + + countdown = setInterval(() => { + setWaitCounter((prevCounter) => prevCounter - 1) + }, 1000) + + setTimeout(() => { + setWaitingForMagicLink(false) + clearInterval(countdown) + }, duration) + } + + return () => { + clearInterval(countdown) + } + }, [form.formState.isSubmitted, waitCounter]) + + return ( +
+ + ( + + Email + + + + + + )} + /> + {signinError && ( + + + {signinError} + + )} + {waitingForMagicLink && ( + + + If an account with this email exists, you will receive a magic + link in your inbox. + + + )} + + + + ) +} + +export default SignInForm diff --git a/src/app/auth/verify-request/page.tsx b/src/app/auth/verify-request/page.tsx new file mode 100644 index 0000000..4b7a9c3 --- /dev/null +++ b/src/app/auth/verify-request/page.tsx @@ -0,0 +1,29 @@ +import { HeartPulseIcon } from 'lucide-react' +import { Card, CardContent } from '@/components/ui/card' +import Link from 'next/link' + +const VerifyRequestPage = () => { + return ( +
+
+
+ +

+ Cardia +

+
+ + +

A sign in link has been sent to your email address.

+

+ If the email did not arrive,{' '} + click here to send again. +

+
+
+
+
+ ) +} + +export default VerifyRequestPage diff --git a/src/app/globals.css b/src/app/globals.css index bbd4963..688ccf8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,76 +1,59 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; - - --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; - - --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; - - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; - - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; - - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; - - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; - - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; - --ring: 240 10% 3.9%; - - --radius: 0.5rem; - } - - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; - - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; - - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; - - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; - - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; - - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; - - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; - - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; - --ring: 240 4.9% 83.9%; - } -} - -@layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } -} \ No newline at end of file +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 224 71.4% 4.1%; + --card: 0 0% 100%; + --card-foreground: 224 71.4% 4.1%; + --popover: 0 0% 100%; + --popover-foreground: 224 71.4% 4.1%; + --primary: 262.1 83.3% 57.8%; + --primary-foreground: 210 20% 98%; + --secondary: 220 14.3% 95.9%; + --secondary-foreground: 220.9 39.3% 11%; + --muted: 220 14.3% 95.9%; + --muted-foreground: 220 8.9% 46.1%; + --accent: 220 14.3% 95.9%; + --accent-foreground: 220.9 39.3% 11%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 20% 98%; + --border: 220 13% 91%; + --input: 220 13% 91%; + --ring: 262.1 83.3% 57.8%; + --radius: 0.5rem; + } + + .dark { + --background: 224 71.4% 4.1%; + --foreground: 210 20% 98%; + --card: 224 71.4% 4.1%; + --card-foreground: 210 20% 98%; + --popover: 224 71.4% 4.1%; + --popover-foreground: 210 20% 98%; + --primary: 263.4 70% 50.4%; + --primary-foreground: 210 20% 98%; + --secondary: 215 27.9% 16.9%; + --secondary-foreground: 210 20% 98%; + --muted: 215 27.9% 16.9%; + --muted-foreground: 217.9 10.6% 64.9%; + --accent: 215 27.9% 16.9%; + --accent-foreground: 210 20% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 20% 98%; + --border: 215 27.9% 16.9%; + --input: 215 27.9% 16.9%; + --ring: 263.4 70% 50.4%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 49bfeac..5c16717 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,27 +1,28 @@ -import type { Metadata } from 'next' -import { Inter } from 'next/font/google' -import { Toaster } from '@/components/ui/toaster' -import './globals.css' - -const inter = Inter({ subsets: ['latin'] }) - -export const metadata: Metadata = { - title: 'Cardia - Bloodpressure Tracker', - description: - 'Cardia is a bloodpressure tracker that helps you keep track of your bloodpressure over time. It is a simple and easy to use app that helps you keep track of your bloodpressure readings and helps you stay healthy.', -} - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode -}>) { - return ( - - - {children} - - - - ) -} +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import { Toaster } from '@/components/ui/toaster' +import './globals.css' + +const inter = Inter({ subsets: ['latin'] }) + +export const metadata: Metadata = { + title: 'Cardia - Bloodpressure Tracker', + description: + 'Cardia is a bloodpressure tracker that helps you keep track of your bloodpressure over time. It is a simple and easy to use app that helps you keep track of your bloodpressure readings and helps you stay healthy.', +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + {children} + + + + ) +} + diff --git a/src/app/middleware.ts b/src/app/middleware.ts deleted file mode 100644 index 3867a2a..0000000 --- a/src/app/middleware.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type NextRequest } from 'next/server' -import { updateSession } from '@/lib/supabase/middleware' - -export async function middleware(request: NextRequest) { - return await updateSession(request) -} - -export const config = { - matcher: [ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', - ], -} diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index f120043..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import Image from 'next/image' - -export default function Home() { - return ( -
-
-

- Get started by editing  - src/app/page.tsx -

- -
- -
- Next.js Logo -
- - -
- ) -} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..6b4891c --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,20 @@ +import NextAuth from 'next-auth' +import Resend from 'next-auth/providers/resend' +import { DrizzleAdapter } from '@auth/drizzle-adapter' +import { db } from './lib/db' + +export const { handlers, auth, signIn, signOut } = NextAuth({ + debug: process.env.NODE_ENV !== 'production' ? true : false, + pages: { + signIn: '/auth/signin', + verifyRequest: '/auth/verify-request', + newUser: '/app/new-user', + }, + adapter: DrizzleAdapter(db), + providers: [Resend({ from: 'noreply@cardia.sloth.sh' })], + callbacks: { + authorized({ auth }) { + return !!auth + }, + }, +}) diff --git a/src/components/DeleteRecordDialog.tsx b/src/components/DeleteRecordDialog.tsx deleted file mode 100644 index 2701203..0000000 --- a/src/components/DeleteRecordDialog.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Trash } from 'lucide-react' -import { - AlertDialog, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogCancel, - AlertDialogAction, - AlertDialogFooter, -} from './ui/alert-dialog' -import { deleteRecord } from '@/actions/records' - -interface Props { - recordId: string -} - -const DeleteRecordDialog: React.FC = ({ recordId }) => { - return ( - - - Delete record - - - - - - Are you sure you want to delete that record? - - - This action cannot be undone. This will permanently delete the - record from the database without any way to recover it. - - - - Cancel - deleteRecord(recordId)}> - Continue - - - - - ) -} - -export default DeleteRecordDialog diff --git a/src/components/ExportRecords.tsx b/src/components/ExportRecords.tsx deleted file mode 100644 index 2d569a0..0000000 --- a/src/components/ExportRecords.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client' - -import { Button } from './ui/button' -import { toast } from './ui/use-toast' - -const ExportRecords: React.FC<{ userId: string }> = ({ userId }) => { - const handleExport = async (event: React.FormEvent) => { - event.preventDefault() - const response = await fetch( - `${process.env.NEXT_PUBLIC_BASE_URL}/api/records/export`, - { - method: 'POST', - body: new FormData(event.currentTarget), - } - ) - - if (response.status !== 200) { - return toast({ - title: 'Export failed', - description: 'An error occurred while exporting your records.', - }) - } - - toast({ - title: 'Export successful', - description: 'Your download should start shortly.', - }) - - response.blob().then((blob) => { - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `records-${Date.now()}.xlsx` - a.click() - }) - } - - return ( -
- - -
- ) -} - -export default ExportRecords diff --git a/src/components/NewRecordDialog.tsx b/src/components/NewRecordDialog.tsx deleted file mode 100644 index 31b404a..0000000 --- a/src/components/NewRecordDialog.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client' - -import { useState } from 'react' -import { z } from 'zod' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog' -import NewRecordForm from './NewRecordForm' - -const formSchema = z.object({ - systolic: z.string().min(0), - diastolic: z.string().min(0), - pulse: z.string().min(0), -}) - -export type NewRecordFields = z.infer - -const NewRecordDialog: React.FC = () => { - const [isDialogOpen, setIsDialogOpen] = useState(false) - - return ( - - - - - - - Add new record - - {`Add a new record to your blood pressure history.`} - - - setIsDialogOpen(false)} /> - - - ) -} - -export default NewRecordDialog diff --git a/src/components/NewRecordForm.tsx b/src/components/NewRecordForm.tsx deleted file mode 100644 index a6d0de7..0000000 --- a/src/components/NewRecordForm.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client' - -import { z } from 'zod' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { createRecord } from '@/actions/records' -import { useAuth } from '@/providers/SupabaseAuthProvider' -import { DialogFooter } from './ui/dialog' -import { - Form, - FormField, - FormItem, - FormLabel, - FormControl, - FormMessage, -} from './ui/form' -import { Input } from './ui/input' -import { Button } from './ui/button' -import { toast } from './ui/use-toast' -import { DateTimePicker } from './ui/date-time-picker' - -interface Props { - closeDialog: () => void -} - -export const newRecordSchema = z.object({ - recordedAt: z.date(), - systolic: z.string().min(0).max(3), - diastolic: z.string().min(0).max(2), - pulse: z.string().min(0).max(3), -}) - -export type NewRecordFields = z.infer - -const NewRecordForm: React.FC = ({ closeDialog }) => { - const { user } = useAuth() - const form = useForm({ - resolver: zodResolver(newRecordSchema), - }) - - const onSubmit = async (data: NewRecordFields) => { - try { - await createRecord(user?.id ?? '', data) - toast({ - title: 'Record created!', - description: 'The record has been successfully created.', - }) - closeDialog() - } catch (error) { - toast({ - title: 'Oops!', - description: 'Could not create the record. Please try again.', - }) - } - } - - return ( -
- - ( - - Systolic - - - - - - )} - /> - ( - - Diastolic - - - - - - )} - /> - ( - - Pulse - - - - - - )} - /> - ( - - Record date - - - - - - )} - /> - - - - - - - ) -} - -export default NewRecordForm diff --git a/src/components/add-record-card.tsx b/src/components/add-record-card.tsx new file mode 100644 index 0000000..76c5770 --- /dev/null +++ b/src/components/add-record-card.tsx @@ -0,0 +1,200 @@ +'use client' + +import React from 'react' +import { useForm } from 'react-hook-form' +import { AlertCircle, LoaderCircle, Save, X } from 'lucide-react' +import { format } from 'date-fns' +import { zodResolver } from '@hookform/resolvers/zod' +import { addUserReading, updateUserReading } from '@/actions/records' +import { readingFormSchema, ReadingFormData } from '@/lib/form-validations' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' + +type AddRecordCardProps = + | { + userId: string + valueId?: never + values?: ReadingFormData + onCancel: () => void + } + | { + userId?: never + valueId: string + values?: ReadingFormData + onCancel: () => void + } + +export const AddRecordCard: React.FC = ({ + userId, + valueId, + values, + onCancel, +}) => { + const form = useForm({ + resolver: zodResolver(readingFormSchema), + defaultValues: { + date: format(new Date(values?.createdAt || new Date()), 'yyyy-MM-dd'), + time: format(new Date(values?.createdAt || new Date()), 'HH:mm'), + // @ts-expect-error - TODO: fix this + systolic: values?.systolic?.toString() || undefined, + // @ts-expect-error - TODO: fix this + diastolic: values?.diastolic?.toString() || undefined, + // @ts-expect-error - TODO: fix this + pulse: values?.pulse?.toString() || undefined, + }, + }) + + const onSubmit = async (data: ReadingFormData) => { + try { + const transformedData = { + ...data, + systolic: data.systolic, + diastolic: data.diastolic, + pulse: data.pulse, + } + + if (!!valueId) { + await updateUserReading(valueId, transformedData) + } else { + await addUserReading(userId!, transformedData) + } + + onCancel() + } catch (error) { + console.error('Something went wrong', error) + } + } + + return ( +
+ +
+ ( + + + + + + + )} + /> + ( + + + + + + + )} + /> +
+ {['systolic', 'diastolic', 'pulse'].map((name) => ( +
+ ( + + +
+ + {form.formState.errors[name as keyof ReadingFormData] && ( + + + + + + +

+ { + form.formState.errors[ + name as keyof ReadingFormData + ]?.message + } +

+
+
+
+ )} +
+
+
+ )} + /> +

+ {name.charAt(0).toUpperCase() + name.slice(1)} +

+
+ ))} +
+ + +
+
+ + ) +} diff --git a/src/components/avatar-upload.tsx b/src/components/avatar-upload.tsx new file mode 100644 index 0000000..aeeb264 --- /dev/null +++ b/src/components/avatar-upload.tsx @@ -0,0 +1,269 @@ +'use client' + +import React, { useState, useCallback } from 'react' +import { useDropzone } from 'react-dropzone' +import Cropper from 'react-easy-crop' +import { CircleUserRound, UploadCloud } from 'lucide-react' +import { deleteUserAvatar, updateUserAvatar } from '@/actions/settings' +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' +import { Button } from './ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog' +import { useToast } from './ui/use-toast' + +interface AvatarUploadProps { + userId: string + currentAvatarUrl?: string + onAvatarChange: (url: string | null | undefined) => void +} + +const AvatarUpload: React.FC = ({ + userId, + currentAvatarUrl, + onAvatarChange, +}) => { + const { toast } = useToast() + + const [isUploading, setIsUploading] = useState(false) + const [isCropping, setIsCropping] = useState(false) + const [crop, setCrop] = useState<{ x: number; y: number }>({ x: 0, y: 0 }) + const [zoom, setZoom] = useState(1) + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null) + const [imageToCrop, setImageToCrop] = useState(null) + + const onCropComplete = useCallback((_: any, croppedAreaPixels: any) => { + setCroppedAreaPixels(croppedAreaPixels) + }, []) + + const handleUpload = useCallback( + async (croppedImage: Blob) => { + setIsUploading(true) + + const formData = new FormData() + const fileExtension = croppedImage.type.split('/')[1] + formData.append('avatar', croppedImage, `avatar.${fileExtension}`) + + try { + const response = await fetch('/api/upload', { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Upload failed') + } + + const data = await response.json() + const newAvatarUrl = data.fileUrl + + const result = await updateUserAvatar(userId, newAvatarUrl) + + if (result.success) { + onAvatarChange(newAvatarUrl) + toast({ + title: 'Avatar uploaded', + description: 'Your new avatar has been successfully uploaded.', + }) + } else { + throw new Error(result.error || 'Failed to update avatar in database') + } + } catch (error) { + console.error('Upload error:', error) + toast({ + title: 'Upload failed', + description: + error instanceof Error + ? error.message + : 'There was an error uploading your avatar. Please try again.', + variant: 'destructive', + }) + } finally { + setIsUploading(false) + setIsCropping(false) + } + }, + [onAvatarChange, toast, userId] + ) + + const getCroppedImg = useCallback( + async ( + imageSrc: string, + pixelCrop: { width: number; height: number; x: number; y: number } + ) => { + const image = new Image() + image.src = imageSrc + await new Promise((resolve) => { + image.onload = resolve + }) + + const canvas = document.createElement('canvas') + canvas.width = pixelCrop.width + canvas.height = pixelCrop.height + const ctx = canvas.getContext('2d') + + if (!ctx) { + throw new Error('Could not get canvas context') + } + + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ) + + const imageType = imageSrc.split(';')[0].split(':')[1] + + return new Promise((resolve) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob) + } else { + throw new Error('Could not create blob') + } + }, imageType) + }) + }, + [] + ) + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0] + const reader = new FileReader() + reader.onload = () => { + setImageToCrop(reader.result as string) + setIsCropping(true) + } + reader.readAsDataURL(file) + } + }, []) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'image/*': ['.jpeg', '.png', '.gif'], + }, + multiple: false, + }) + + const handleCropSave = useCallback(async () => { + if (imageToCrop && croppedAreaPixels) { + const croppedImage = await getCroppedImg(imageToCrop, croppedAreaPixels) + await handleUpload(croppedImage) + } + }, [imageToCrop, croppedAreaPixels, getCroppedImg, handleUpload]) + + const handleRemove = useCallback(async () => { + try { + const result = await deleteUserAvatar(userId) + if (result.success) { + onAvatarChange(null) + toast({ + title: 'Avatar removed', + description: 'Your avatar has been successfully removed.', + }) + } else { + throw new Error(result.error || 'Failed to update avatar in database') + } + } catch (error) { + console.error('Error removing avatar:', error) + toast({ + title: 'Error removing avatar', + description: + error instanceof Error + ? error.message + : 'There was an error removing your avatar. Please try again.', + variant: 'destructive', + }) + } + }, [onAvatarChange, toast, userId]) + + return ( + <> +
+
+ + + + {isUploading ? ( + + ) : isDragActive ? ( + + ) : ( + + )} + + + + {isDragActive && ( +
+ Drop here +
+ )} +
+
+ + {currentAvatarUrl && ( + + )} +
+
+ + + + + Crop Avatar + +
+ {imageToCrop && ( + + )} +
+
+ + +
+
+
+ + ) +} + +export default AvatarUpload diff --git a/src/components/delete-record-dialog.tsx b/src/components/delete-record-dialog.tsx new file mode 100644 index 0000000..2fe1dfb --- /dev/null +++ b/src/components/delete-record-dialog.tsx @@ -0,0 +1,53 @@ +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogCancel, + AlertDialogAction, + AlertDialogFooter, +} from '@/components/ui/alert-dialog' +import { deleteUserReading } from '@/actions/records' + +export interface DeleteRecordDialogProps { + recordId: string + isOpen: boolean + onClose: () => void +} + +const DeleteRecordDialog: React.FC = ({ + recordId, + isOpen, + onClose, +}) => { + const handleDelete = async () => { + try { + await deleteUserReading(recordId) + onClose() + } catch (error) { + console.error('Something went wrong', error) + } + } + return ( + + + + + Are you sure you want to delete that record? + + + This action cannot be undone. This will permanently delete the + record without recovery. + + + + Cancel + Continue + + + + ) +} + +export default DeleteRecordDialog diff --git a/src/components/empty-record.tsx b/src/components/empty-record.tsx new file mode 100644 index 0000000..006a890 --- /dev/null +++ b/src/components/empty-record.tsx @@ -0,0 +1,25 @@ +'use client' + +import { useState } from 'react' +import { SquarePlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { AddRecordCard } from './add-record-card' + +export const EmptyRecord: React.FC<{ userId: string }> = ({ userId }) => { + const [creating, setCreating] = useState(false) + + return !creating ? ( +
+

+ You haven't recorded any blood pressure readings yet. Start + tracking your cardiovascular health today! +

+ +
+ ) : ( + setCreating(false)} /> + ) +} diff --git a/src/components/Navbar.tsx b/src/components/navbar.tsx similarity index 71% rename from src/components/Navbar.tsx rename to src/components/navbar.tsx index 7449339..003d9e8 100644 --- a/src/components/Navbar.tsx +++ b/src/components/navbar.tsx @@ -1,81 +1,66 @@ -'use client' - -import { HeartPulse, LogOut } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' -import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from './ui/dropdown-menu' -import { createClient } from '@/lib/supabase/client' -import { User } from '@/types/user' - -interface Props { - user: User -} - -const Navbar: React.FC = ({ user }) => { - const supabase = createClient() - const router = useRouter() - - const getUserInitials = (): string => { - const name = user.username ?? '' - const initials = name - .split(' ') - .map((n) => n[0]) - .join('') - return initials.toUpperCase() - } - - const handleLogout = async () => { - const { error } = await supabase.auth.signOut() - - if (error) { - console.error('Error logging out:', error.message) - return - } - - router.push('/auth/signin') - } - - return ( -
- - - Cardia - - - - - {user.profilePicture && } - {getUserInitials()} - - - - My Account - - - Settings - - - - Logout - - - -
- ) -} - -export default Navbar +'use client' + +import Link from 'next/link' +import { User } from 'next-auth' +import { signOut } from 'next-auth/react' +import { HeartPulse, LogOut } from 'lucide-react' +import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './ui/dropdown-menu' + +interface NavbarProps { + user?: User +} + +const Navbar: React.FC = ({ user }) => { + const getUserInitials = (): string => { + const name = user?.name ?? '' + const initials = name + .split(' ') + .map((n) => n[0]) + .join('') + return initials.toUpperCase() + } + + return ( +
+ + + Cardia + + + + + {user?.image && } + {getUserInitials()} + + + + My Account + + + Settings + + signOut()} + > + + Logout + + + +
+ ) +} + +export default Navbar diff --git a/src/components/record-card.tsx b/src/components/record-card.tsx new file mode 100644 index 0000000..4dcc243 --- /dev/null +++ b/src/components/record-card.tsx @@ -0,0 +1,117 @@ +'use client' + +import React, { useState } from 'react' +import { Edit3, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import DeleteRecordDialog from './delete-record-dialog' +import { AddRecordCard } from './add-record-card' + +export interface RecordCardProps { + id: string + systolic: number + diastolic: number + pulse: number + createdAt: Date | null +} + +const Options: React.FC<{ onDelete: () => void; onEdit: () => void }> = ({ + onDelete, + onEdit, +}) => ( + + + + + + + + Edit + + + + Delete + + + +) + +export const RecordCard: React.FC = ({ + id, + systolic, + diastolic, + pulse, + createdAt, +}) => { + const [deleteDialog, setDeleteDialog] = useState(false) + const [editing, setEditing] = useState(false) + const formattedDate = new Date(createdAt!).toLocaleDateString() + const formattedTime = new Date(createdAt!).toLocaleTimeString() + + return ( + <> + {!editing && ( +
+
+

{formattedDate}

+

{formattedTime}

+
+
+

+ {systolic} +

+

Systolic

+
+
+

+ {diastolic} +

+

Diastolic

+
+
+

{pulse}

+

Pulse

+
+
+ setEditing(true)} + onDelete={() => setDeleteDialog(true)} + /> +
+
+ )} + + {editing && ( + setEditing(false)} + /> + )} + + setDeleteDialog(false)} + /> + + ) +} diff --git a/src/components/settings-form.tsx b/src/components/settings-form.tsx new file mode 100644 index 0000000..594e795 --- /dev/null +++ b/src/components/settings-form.tsx @@ -0,0 +1,128 @@ +'use client' + +import { User } from 'next-auth' +import { useForm } from 'react-hook-form' +import { useEffect, useState } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { userSettingsSchema } from '@/lib/form-validations' +import { updateUserSettings } from '@/actions/settings' +import { Input } from './ui/input' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from './ui/form' +import SubmitButton from './submit-button' +import AvatarUpload from './avatar-upload' +import { useToast } from './ui/use-toast' + +interface SettingsFormProps { + user?: User + isNewUser?: boolean +} + +const SettingsForm: React.FC = ({ user, isNewUser }) => { + const { toast } = useToast() + + const [isLoading, setIsLoading] = useState(true) + const [avatarUrl, setAvatarUrl] = useState(null) + + const form = useForm({ + resolver: zodResolver(userSettingsSchema), + defaultValues: { + name: '', + email: '', + avatar: '', + }, + }) + + useEffect(() => { + if (user) { + setAvatarUrl(user.image) + setIsLoading(false) + } + }, [user]) + + useEffect(() => { + if (user) { + form.reset({ + name: user.name ?? '', + email: user.email ?? '', + avatar: user.image ?? '', + }) + } + }, [user, form]) + + if (isLoading) return
Loading ...
+ if (!user) return null + + const onSubmit = async (data: FormData) => { + const updatedSettings = { ...data, avatar: avatarUrl ?? undefined } + const result = await updateUserSettings(user.id!, updatedSettings) + + if (!result.success) { + return toast({ + title: 'Update failed', + description: + result.error || 'An error occurred while updating your settings.', + variant: 'destructive', + }) + } + + return toast({ + title: 'Settings updated', + description: 'Your settings have been successfully updated.', + }) + } + + return ( +
+ + setAvatarUrl(url)} + /> + ( + + Your name + + + + + + )} + /> + ( + + Your email + + + + + + )} + /> + + + + ) +} + +export default SettingsForm diff --git a/src/components/SubmitButton.tsx b/src/components/submit-button.tsx similarity index 81% rename from src/components/SubmitButton.tsx rename to src/components/submit-button.tsx index 8d8524d..53d00f3 100644 --- a/src/components/SubmitButton.tsx +++ b/src/components/submit-button.tsx @@ -1,24 +1,25 @@ -'use client' - -import { LoaderCircle } from 'lucide-react' -import { Button, ButtonProps } from './ui/button' - -interface SubmitButtonProps extends ButtonProps { - label?: string - pending?: boolean -} - -const SubmitButton: React.FC = ({ - label = 'Submit', - pending = false, - ...restProps -}) => { - return ( - - ) -} - -export default SubmitButton +'use client' + +import { LoaderCircle } from 'lucide-react' +import { Button, ButtonProps } from './ui/button' + +interface SubmitButtonProps extends ButtonProps { + label?: string + pending?: boolean +} + +const SubmitButton: React.FC = ({ + label = 'Submit', + pending = false, + ...restProps +}) => { + return ( + + ) +} + +export default SubmitButton + diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx new file mode 100644 index 0000000..ed79f90 --- /dev/null +++ b/src/components/toolbar.tsx @@ -0,0 +1,32 @@ +'use client' + +import { useState } from 'react' +import { Filter, SquarePlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { AddRecordCard } from './add-record-card' + +export const Toolbar: React.FC<{ userId: string }> = ({ userId }) => { + const [addRecord, setAddRecord] = useState(false) + + return ( + <> +
+
+ + {/* */} +
+ +
+ + {addRecord && ( + setAddRecord(false)} /> + )} + + ) +} diff --git a/src/components/ui/date-range-picker.tsx b/src/components/ui/date-range-picker.tsx new file mode 100644 index 0000000..532c7c5 --- /dev/null +++ b/src/components/ui/date-range-picker.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import { addDays, format } from 'date-fns' +import { Calendar as CalendarIcon } from 'lucide-react' +import { DateRange } from 'react-day-picker' + +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' + +export function DatePickerWithRange({ + className, +}: React.HTMLAttributes) { + const [date, setDate] = React.useState({ + from: new Date(2022, 0, 20), + to: addDays(new Date(2022, 0, 20), 20), + }) + + return ( +
+ + + + + + console.log('yup')} + /> + + +
+ ) +} diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..30fc44d --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/db/index.ts b/src/db/index.ts deleted file mode 100644 index 7a8fa84..0000000 --- a/src/db/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import 'dotenv/config' -import { Client } from 'pg' -import { drizzle } from 'drizzle-orm/node-postgres' -import * as schema from './schema' - -const connectionString = process.env.DB_URL! -const client = new Client({ - connectionString: connectionString, -}) - -const connectDatabase = async () => { - try { - await client.connect() - } catch (e) { - console.error('Failed to connect to the database', e) - } -} - -connectDatabase() -export const db = drizzle(client, { schema: schema, logger: true }) diff --git a/src/db/schema.ts b/src/db/schema.ts deleted file mode 100644 index 2b5a4ba..0000000 --- a/src/db/schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { relations, sql } from 'drizzle-orm' -import { integer, pgSchema, text, timestamp, uuid } from 'drizzle-orm/pg-core' - -export const authSchema = pgSchema('auth') - -export const authUsers = authSchema.table('users', { - id: uuid('id').primaryKey().notNull(), -}) - -export const cardiaSchema = pgSchema('cardia') - -export const users = cardiaSchema.table('users', { - id: uuid('id') - .primaryKey() - .notNull() - .references(() => authUsers.id, { onDelete: 'cascade' }), - username: text('username'), - profilePicture: text('profile_picture'), -}) - -export const usersRelations = relations(users, ({ many }) => ({ - records: many(records), -})) - -export const records = cardiaSchema.table('record', { - id: uuid('id') - .default(sql`gen_random_uuid()`) - .notNull() - .primaryKey(), - userId: uuid('user_id') - .references(() => users.id, { onDelete: 'cascade' }) - .notNull(), - systolic: integer('systolic').notNull(), - diastolic: integer('diastolic').notNull(), - pulse: integer('pulse').notNull(), - recordedAt: timestamp('recorded_at').defaultNow().notNull(), -}) - -export const recordsRelations = relations(records, ({ one }) => ({ - user: one(users, { - fields: [records.userId], - references: [users.id], - }), -})) diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts new file mode 100644 index 0000000..2ce4ed5 --- /dev/null +++ b/src/lib/db/index.ts @@ -0,0 +1,5 @@ +import { drizzle } from 'drizzle-orm/vercel-postgres' +import { sql } from '@vercel/postgres' +import * as schema from './schema' + +export const db = drizzle(sql, { schema }) diff --git a/src/db/migrate.ts b/src/lib/db/migrate.ts similarity index 87% rename from src/db/migrate.ts rename to src/lib/db/migrate.ts index e237410..012c1c8 100644 --- a/src/db/migrate.ts +++ b/src/lib/db/migrate.ts @@ -1,26 +1,27 @@ -import 'dotenv/config' -import { Pool } from 'pg' -import { drizzle } from 'drizzle-orm/node-postgres' -import { migrate } from 'drizzle-orm/node-postgres/migrator' - -const pool = new Pool({ - connectionString: process.env.DB_URL!, -}) - -const db = drizzle(pool) - -async function main() { - console.log('Migration started...') - await migrate(db, { migrationsFolder: 'drizzle' }) - console.log('Migration completed!') - - pool.end() - process.exit(0) -} - -main().catch((e) => { - console.error('Migration failed: ', e) - - pool.end() - process.exit(0) -}) +import 'dotenv/config' +import { Pool } from 'pg' +import { drizzle } from 'drizzle-orm/node-postgres' +import { migrate } from 'drizzle-orm/node-postgres/migrator' + +const pool = new Pool({ + connectionString: process.env.POSTGRES_URL!, +}) + +const db = drizzle(pool) + +async function main() { + console.log('Migration started...') + await migrate(db, { migrationsFolder: 'drizzle' }) + console.log('Migration completed!') + + pool.end() + process.exit(0) +} + +main().catch((e) => { + console.error('Migration failed: ', e) + + pool.end() + process.exit(0) +}) + diff --git a/src/lib/db/queries.ts b/src/lib/db/queries.ts new file mode 100644 index 0000000..a6b7a3a --- /dev/null +++ b/src/lib/db/queries.ts @@ -0,0 +1,54 @@ +import { desc, eq } from 'drizzle-orm' +import { z } from 'zod' +import { db } from '.' +import { readings } from './schema' +import { readingFormSchema } from '../form-validations' + +export const getAllReadingsByUser = async (userId: string) => { + const records = await db.query.readings.findMany({ + where: eq(readings.userId, userId), + orderBy: desc(readings.createdAt), + }) + + return records +} + +export const addReading = async ( + userId: string, + data: z.infer +) => { + const record = await db + .insert(readings) + .values({ + ...data, + userId, + }) + .returning() + + return record +} + +export const updateReadingById = async ( + id: string, + data: z.infer +) => { + const record = await db + .update(readings) + .set(data) + .where(eq(readings.id, id)) + .returning() + return record +} + +export const deleteReadingById = async (id: string) => { + const record = await db.query.readings.findFirst({ + where: eq(readings.id, id), + }) + + if (!record) { + return null + } + + await db.delete(readings).where(eq(readings.id, id)) + return record +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts new file mode 100644 index 0000000..f58961d --- /dev/null +++ b/src/lib/db/schema.ts @@ -0,0 +1,112 @@ +import { relations } from 'drizzle-orm' +import { + boolean, + timestamp, + pgTable, + text, + primaryKey, + integer, +} from 'drizzle-orm/pg-core' +import type { AdapterAccountType } from 'next-auth/adapters' + +export const users = pgTable('user', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + name: text('name'), + email: text('email').unique(), + emailVerified: timestamp('emailVerified', { mode: 'date' }), + image: text('image'), +}) + +export const accounts = pgTable( + 'account', + { + userId: text('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + type: text('type').$type().notNull(), + provider: text('provider').notNull(), + providerAccountId: text('providerAccountId').notNull(), + refresh_token: text('refresh_token'), + access_token: text('access_token'), + expires_at: integer('expires_at'), + token_type: text('token_type'), + scope: text('scope'), + id_token: text('id_token'), + session_state: text('session_state'), + }, + (account) => ({ + compoundKey: primaryKey({ + columns: [account.provider, account.providerAccountId], + }), + }) +) + +export const sessions = pgTable('session', { + sessionToken: text('sessionToken').primaryKey(), + userId: text('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + expires: timestamp('expires', { mode: 'date' }).notNull(), +}) + +export const verificationTokens = pgTable( + 'verificationToken', + { + identifier: text('identifier').notNull(), + token: text('token').notNull(), + expires: timestamp('expires', { mode: 'date' }).notNull(), + }, + (verificationToken) => ({ + compositePk: primaryKey({ + columns: [verificationToken.identifier, verificationToken.token], + }), + }) +) + +export const authenticators = pgTable( + 'authenticator', + { + credentialID: text('credentialID').notNull().unique(), + userId: text('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + providerAccountId: text('providerAccountId').notNull(), + credentialPublicKey: text('credentialPublicKey').notNull(), + counter: integer('counter').notNull(), + credentialDeviceType: text('credentialDeviceType').notNull(), + credentialBackedUp: boolean('credentialBackedUp').notNull(), + transports: text('transports'), + }, + (authenticator) => ({ + compositePK: primaryKey({ + columns: [authenticator.userId, authenticator.credentialID], + }), + }) +) + +export const readings = pgTable('readings', { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text('userId') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + systolic: integer('systolic').notNull(), + diastolic: integer('diastolic').notNull(), + pulse: integer('pulse').notNull(), + createdAt: timestamp('createdAt', { mode: 'date' }).defaultNow(), +}) + +// --- Relations +export const usersRelations = relations(users, ({ many }) => ({ + bpReadings: many(readings), +})) + +export const readingsRelations = relations(readings, ({ one }) => ({ + user: one(users, { + fields: [readings.userId], + references: [users.id], + }), +})) diff --git a/src/lib/form-validations.ts b/src/lib/form-validations.ts index 62bdaa2..233c350 100644 --- a/src/lib/form-validations.ts +++ b/src/lib/form-validations.ts @@ -1,5 +1,60 @@ -import { z } from 'zod' - -export const signinSchema = z.object({ - email: z.string().email(), -}) +import { z } from 'zod' + +export const signinSchema = z.object({ + email: z.string().email(), +}) + +export const userSettingsSchema = z.object({ + name: z.string(), + email: z.string().email(), +}) + +export const readingFormSchema = z + .object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format. Use YYYY-MM-DD'), + time: z + .string() + .regex(/^([01]\d|2[0-3]):([0-5]\d)$/, 'Invalid time format. Use HH:MM'), + systolic: z.string().refine( + (val) => { + const num = parseInt(val, 10) + return !isNaN(num) && num >= 70 && num <= 250 + }, + { message: 'Systolic must be a number between 70 and 250' } + ), + diastolic: z.string().refine( + (val) => { + const num = parseInt(val, 10) + return !isNaN(num) && num >= 40 && num <= 150 + }, + { message: 'Diastolic must be a number between 40 and 150' } + ), + pulse: z.string().refine( + (val) => { + const num = parseInt(val, 10) + return !isNaN(num) && num >= 40 && num <= 200 + }, + { message: 'Pulse must be a number between 40 and 200' } + ), + }) + .refine( + (data) => { + const dateTime = new Date(`${data.date}T${data.time}`) + return !isNaN(dateTime.getTime()) + }, + { + message: 'Invalid date and time combination', + path: ['date', 'time'], + } + ) + .transform((data) => ({ + ...data, + systolic: parseInt(data.systolic, 10), + diastolic: parseInt(data.diastolic, 10), + pulse: parseInt(data.pulse, 10), + createdAt: new Date(`${data.date}T${data.time}`), + })) + +export type ReadingFormData = z.infer diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts deleted file mode 100644 index 792b457..0000000 --- a/src/lib/supabase/client.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createBrowserClient } from '@supabase/ssr' - -export function createClient() { - return createBrowserClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! - ) -} diff --git a/src/lib/supabase/middleware.ts b/src/lib/supabase/middleware.ts deleted file mode 100644 index 2acc794..0000000 --- a/src/lib/supabase/middleware.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { createServerClient, type CookieOptions } from '@supabase/ssr' -import { NextResponse, type NextRequest } from 'next/server' - -export async function updateSession(request: NextRequest) { - let response = NextResponse.next({ - request: { - headers: request.headers, - }, - }) - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get(name: string) { - return request.cookies.get(name)?.value - }, - set(name: string, value: string, options: CookieOptions) { - request.cookies.set({ - name, - value, - ...options, - }) - response = NextResponse.next({ - request: { - headers: request.headers, - }, - }) - response.cookies.set({ - name, - value, - ...options, - }) - }, - remove(name: string, options: CookieOptions) { - request.cookies.set({ - name, - value: '', - ...options, - }) - response = NextResponse.next({ - request: { - headers: request.headers, - }, - }) - response.cookies.set({ - name, - value: '', - ...options, - }) - }, - }, - } - ) - - await supabase.auth.getUser() - - return response -} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts deleted file mode 100644 index ac91c77..0000000 --- a/src/lib/supabase/server.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { createServerClient, type CookieOptions } from '@supabase/ssr' -import { cookies } from 'next/headers' - -export function createClient() { - const cookieStore = cookies() - - return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get(name: string) { - return cookieStore.get(name)?.value - }, - set(name: string, value: string, options: CookieOptions) { - try { - cookieStore.set({ name, value, ...options }) - } catch (error) { - // The `set` method was called from a Server Component. - // This can be ignored if you have middleware refreshing - // user sessions. - } - }, - remove(name: string, options: CookieOptions) { - try { - cookieStore.set({ name, value: '', ...options }) - } catch (error) { - // The `delete` method was called from a Server Component. - // This can be ignored if you have middleware refreshing - // user sessions. - } - }, - }, - } - ) -} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..588ddac --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,19 @@ +import { createMiddleware, MiddlewareFunctionProps } from '@rescale/nemo' +import { auth } from './auth' + +const middlewares = {} + +// Create middlewares helper +export const middleware = createMiddleware(middlewares) + +export const config = { + /* + * Match all paths except for: + * 1. /api/ routes + * 2. /_next/ (Next.js internals) + * 3. /_static (inside /public) + * 4. /_vercel (Vercel internals) + * 5. Static files (e.g. /favicon.ico, /sitemap.xml, /robots.txt, etc.) + */ + matcher: ['/((?!api/|_next/|_static|_vercel|[\\w-]+\\.\\w+).*)'], +} diff --git a/src/providers/SupabaseAuthProvider.tsx b/src/providers/SupabaseAuthProvider.tsx deleted file mode 100644 index 69aab30..0000000 --- a/src/providers/SupabaseAuthProvider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client' - -import { createContext, useContext, useEffect, useMemo, useState } from 'react' -import { User } from '@supabase/supabase-js' -import { redirect } from 'next/navigation' -import { createClient } from '@/lib/supabase/client' - -interface AuthContextProps { - user: User | null -} - -interface Props { - children: React.ReactNode -} - -const AuthContext = createContext({ - user: null, -}) - -export const useAuth = () => { - return useContext(AuthContext) -} - -export const AuthProvider: React.FC = ({ children }) => { - const [user, setUser] = useState(null) - const supabase = createClient() - - useEffect(() => { - ;(async () => { - const { data, error } = await supabase.auth.getUser() - setUser(data.user) - - if (error) { - redirect('/auth/signin') - } - })() - - return () => { - // this now gets called when the component unmounts - } - }, [supabase.auth]) - - const value = useMemo(() => ({ user }), [user]) - - return {children} -} diff --git a/src/types/user.ts b/src/types/user.ts deleted file mode 100644 index e322bbd..0000000 --- a/src/types/user.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface User { - id: string - username: string | null - profilePicture: string | null -}