diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1bd855b --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 + +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB=awaketh + +NEXT_PUBLIC_GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= \ No newline at end of file diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2b46ab7..dbb3ac7 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -43,7 +43,7 @@ jobs: restore-keys: bun-${{ runner.os }}- - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts - name: Run ${{ matrix.name }} run: ${{ matrix.run }} diff --git a/.github/workflows/visual-snapshots.yml b/.github/workflows/visual-snapshots.yml index b485b36..dac1b06 100644 --- a/.github/workflows/visual-snapshots.yml +++ b/.github/workflows/visual-snapshots.yml @@ -16,10 +16,34 @@ concurrency: env: # Directory the Playwright capture writes to and sentry-cli uploads from. SNAPSHOT_OUTPUT_DIR: snapshots + # CI-only database and auth config. prisma.config.ts builds the connection + # string from POSTGRES_* at load time, and the app queries the database when + # pages behind the session proxy are captured. + POSTGRES_USER: ci + POSTGRES_PASSWORD: ci + POSTGRES_DB: awaketh + BETTER_AUTH_URL: http://localhost:3000 + BETTER_AUTH_SECRET: ci-only-secret-for-visual-snapshots jobs: visual: runs-on: blacksmith-4vcpu-ubuntu-2404 + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: ci + POSTGRES_PASSWORD: ci + POSTGRES_DB: awaketh + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ci -d awaketh" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: - uses: actions/checkout@v7 with: @@ -30,7 +54,15 @@ jobs: bun-version: latest - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install --frozen-lockfile --ignore-scripts + + # --ignore-scripts skips the postinstall hook, so generate the Prisma + # client explicitly; the build resolves src/generated/prisma from it. + - name: Generate Prisma client + run: bunx prisma generate + + - name: Apply database migrations + run: bunx prisma migrate deploy - name: Install Playwright browser run: bunx playwright install --with-deps chromium diff --git a/.gitignore b/.gitignore index f6894f2..44832ba 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,8 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env.sentry-build-plugin -.env +.env* +!.env.example # vercel .vercel @@ -51,3 +52,4 @@ yarn-error.log* # IDEA IDEs (For whoever uses these lol) .idea /src/generated/prisma + diff --git a/bun.lock b/bun.lock index c5c9ee6..6bde678 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,9 @@ "name": "awaketh-web", "dependencies": { "@base-ui/react": "^1.6.0", + "@better-auth/passkey": "^1.6.23", + "@icons-pack/react-simple-icons": "^13.13.0", + "@marsidev/react-turnstile": "1.5.3", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@sentry/nextjs": "^10", @@ -20,6 +23,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "shadcn": "^4.13.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", }, @@ -127,6 +131,8 @@ "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA=="], + "@better-auth/passkey": ["@better-auth/passkey@1.6.23", "", { "dependencies": { "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.6.23", "better-call": "1.3.7", "nanostores": "^1.0.1" } }, "sha512-nr5tKaNd/huUwTYX4DUm4HcXgBkixj0lXrRHdy9azY4fFjF49Tyif4sPyRMWnQJYxlRJ1HVEMJq2nGyr5CLXQg=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ=="], "@better-auth/telemetry": ["@better-auth/telemetry@1.6.23", "", { "peerDependencies": { "@better-auth/core": "^1.6.23", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA=="], @@ -229,6 +235,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.12", "", {}, "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -241,6 +249,8 @@ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + "@icons-pack/react-simple-icons": ["@icons-pack/react-simple-icons@13.13.0", "", { "peerDependencies": { "react": "^16.13 || ^17 || ^18 || ^19" } }, "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -305,6 +315,10 @@ "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + + "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.5.3", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-8Dij2jiNGNczq1U4EKpO4do2XepcTPxSMc2ZzvHndO+gcp68tvMULm27z2P99rGkdB89hc3452NZeu2Rti4g6A=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], @@ -357,6 +371,32 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-rsa": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.8.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.8.0", "@peculiar/asn1-pfx": "^2.8.0", "@peculiar/asn1-pkcs8": "^2.8.0", "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "@peculiar/asn1-x509-attr": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.8.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "@peculiar/asn1-x509": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@1.14.3", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA=="], + "@playwright/test": ["@playwright/test@1.61.1", "", { "dependencies": { "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" } }, "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig=="], "@prisma/adapter-pg": ["@prisma/adapter-pg@7.8.0", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.8.0", "@types/pg": "^8.16.0", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg=="], @@ -515,6 +555,10 @@ "@sentry/webpack-plugin": ["@sentry/webpack-plugin@5.4.0", "", { "dependencies": { "@sentry/bundler-plugins": "^10.64.0" }, "peerDependencies": { "webpack": ">=5.0.0" } }, "sha512-J3a0BvUZ75Qxy+v/Ap3Hx4ZEcSjlPHZ/jDtxdRhXQCyNeEb8xq0uUBTI9VLtGk2eNeNucOxOEJ5ngqdNjnEH/A=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.3.0", "", {}, "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ=="], + + "@simplewebauthn/server": ["@simplewebauthn/server@13.3.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/x509": "^1.14.3" } }, "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ=="], + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -713,6 +757,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], @@ -1497,6 +1543,10 @@ "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1517,6 +1567,8 @@ "recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], @@ -1607,6 +1659,8 @@ "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1693,6 +1747,8 @@ "tsx": ["tsx@4.23.0", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -1929,6 +1985,8 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "webpack/enhanced-resolve": ["enhanced-resolve@5.24.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw=="], diff --git a/next.config.ts b/next.config.ts index a6e92ab..2cc4b02 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,13 +1,33 @@ import { withSentryConfig } from '@sentry/nextjs'; import type { NextConfig } from 'next'; -const nextConfig: NextConfig = {/* config options here */}; +const nextConfig: NextConfig = { + async redirects() { + return [ + { + source: `/legal/terms`, + destination: `https://stegnet.com/terms`, + permanent: false, + }, + { + source: `/legal/privacy`, + destination: `https://stegnet.com/privacy`, + permanent: false, + }, + ]; + }, +}; export default withSentryConfig(nextConfig, { org: 'stegnet', project: 'awaketh-web', silent: !process.env.CI, widenClientFileUpload: true, + authToken: process.env.SENTRY_AUTH_TOKEN, + + sourcemaps: { + deleteSourcemapsAfterUpload: true, + }, // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. // This can increase your server load as well as your hosting bill. diff --git a/package.json b/package.json index 65c018e..107475b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ }, "dependencies": { "@base-ui/react": "^1.6.0", + "@better-auth/passkey": "^1.6.23", + "@icons-pack/react-simple-icons": "^13.13.0", + "@marsidev/react-turnstile": "1.5.3", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@sentry/nextjs": "^10", @@ -35,6 +38,7 @@ "react": "19.2.7", "react-dom": "19.2.7", "shadcn": "^4.13.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, diff --git a/prisma.config.ts b/prisma.config.ts index 3ff61da..ce871ce 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,6 +1,7 @@ // Prisma CLI config. Prisma 7 does not auto-load .env, so we load it here. import 'dotenv/config'; import { defineConfig } from 'prisma/config'; +import { buildConnectionString } from './src/lib/database/connection-string'; export default defineConfig({ schema: 'prisma/schema.prisma', @@ -9,6 +10,6 @@ export default defineConfig({ }, datasource: { // Prisma 7 keeps the connection URL out of schema.prisma; the CLI/Migrate reads it here. - url: process.env['DATABASE_URL'], + url: buildConnectionString(), }, }); diff --git a/prisma/migrations/20260715090009_0002/migration.sql b/prisma/migrations/20260715090009_0002/migration.sql new file mode 100644 index 0000000..587d7ac --- /dev/null +++ b/prisma/migrations/20260715090009_0002/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "passkey" ( + "id" TEXT NOT NULL, + "name" TEXT, + "publicKey" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "credentialID" TEXT NOT NULL, + "counter" INTEGER NOT NULL, + "deviceType" TEXT NOT NULL, + "backedUp" BOOLEAN NOT NULL, + "transports" TEXT, + "createdAt" TIMESTAMP(3), + "aaguid" TEXT, + + CONSTRAINT "passkey_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "passkey_userId_idx" ON "passkey"("userId"); + +-- CreateIndex +CREATE INDEX "passkey_credentialID_idx" ON "passkey"("credentialID"); + +-- AddForeignKey +ALTER TABLE "passkey" ADD CONSTRAINT "passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d39af8b..a405ffc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,6 +21,8 @@ model User { sessions Session[] accounts Account[] + passkeys Passkey[] + @@unique([email]) @@map("user") } @@ -72,3 +74,22 @@ model Verification { @@index([identifier]) @@map("verification") } + +model Passkey { + id String @id + name String? + publicKey String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + credentialID String + counter Int + deviceType String + backedUp Boolean + transports String? + createdAt DateTime? + aaguid String? + + @@index([userId]) + @@index([credentialID]) + @@map("passkey") +} diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 816dd52..891f572 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -18,14 +18,17 @@ if ( Sentry.init({ dsn: - process.env.NODE_ENV === 'production' + process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test' ? process.env.NEXT_PUBLIC_WEB_SENTRY_DSN : '', integrations: [ Sentry.consoleLoggingIntegration({ levels: ['log', 'warn', 'error'] }), ], - tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.25 : 1.0, + tracesSampleRate: + process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test' + ? 0.25 + : 1.0, enableLogs: true, attachStacktrace: true, diff --git a/sentry.server.config.ts b/sentry.server.config.ts index e19d4a0..cee177f 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -17,7 +17,7 @@ if ( Sentry.init({ dsn: - process.env.NODE_ENV === 'production' + process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test' ? process.env.NEXT_PUBLIC_WEB_SENTRY_DSN : '', spotlight: process.env.NODE_ENV !== 'production', @@ -25,7 +25,10 @@ Sentry.init({ Sentry.consoleLoggingIntegration({ levels: ['log', 'warn', 'error'] }), ], - tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.25 : 1.0, + tracesSampleRate: + process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test' + ? 0.25 + : 1.0, enableLogs: true, attachStacktrace: true, diff --git a/src/app/auth/sign-in/page.tsx b/src/app/auth/sign-in/page.tsx new file mode 100644 index 0000000..965e6ee --- /dev/null +++ b/src/app/auth/sign-in/page.tsx @@ -0,0 +1,22 @@ +import { LoginForm } from '@/components/login-form'; +import { GalleryVerticalEndIcon } from 'lucide-react'; +import Link from 'next/link'; + +export default function LoginPage() { + return ( +
+
+ +
+ +
+ Awaketh + + +
+
+ ); +} diff --git a/src/app/auth/sign-up/page.tsx b/src/app/auth/sign-up/page.tsx new file mode 100644 index 0000000..b8a9de5 --- /dev/null +++ b/src/app/auth/sign-up/page.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { SignupForm } from '@/components/signup-form'; +import { GalleryVerticalEndIcon } from 'lucide-react'; +import Link from 'next/link'; + +export default function SignupPage() { + return ( +
+
+ +
+ +
+ Awaketh + + +
+
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..78536f7 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { authClient } from '@/lib/auth-client'; + +export default function Dashboard() { + const { data } = authClient.useSession(); + + const fullName = data?.user?.name; + + return ( +
+

Dashboard

+

Welcome to the dashboard, {fullName}!

+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 486daa1..3670c86 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import { ThemeProvider } from '@/components/theme-provider'; +import { Toaster } from '@/components/ui/sonner'; import './globals.css'; const geistSans = Geist({ @@ -37,6 +38,7 @@ export default function RootLayout({ disableTransitionOnChange > {children} + diff --git a/src/components/auth/registration-card.tsx b/src/components/auth/registration-card.tsx new file mode 100644 index 0000000..c4e01e7 --- /dev/null +++ b/src/components/auth/registration-card.tsx @@ -0,0 +1,140 @@ +'use client'; + +import React, { useRef } from 'react'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { authClient } from '@/lib/auth-client'; +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'; +import { sanitizeWord } from '@/lib/auth-functions/name-sanitization'; +import { toast } from 'sonner'; +import Link from 'next/link'; + +export function SignUpCard() { + const router = useRouter(); + const tokenRef = useRef(null); + + async function onSubmit(event: React.SubmitEvent) { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + const firstName = formData.get('firstName') as string; + const lastName = formData.get('lastName') as string; + + const { error } = await authClient.signUp.email({ + name: sanitizeWord(firstName) + ' ' + sanitizeWord(lastName), + email: email, + password: password, + fetchOptions: { + headers: { + 'x-captcha-response': tokenRef.current?.getResponse(), + }, + }, + }); + + if (error) { + toast.error(error.message); + return; + } + toast.success( + 'Account created successfully! Please check your email to verify your account.', + ); + + router.push('/'); + } + + return ( + <> + + + Awaketh account registration + + Get started with your awaketh experience. + + + + + + +
+
+
+
+ + +
+ +
+ + +
+
+
+ + +
+
+ +
+ +
+
+
+ + + + +
+ + ); +} diff --git a/src/components/auth/sign-in-card.tsx b/src/components/auth/sign-in-card.tsx new file mode 100644 index 0000000..9e45a02 --- /dev/null +++ b/src/components/auth/sign-in-card.tsx @@ -0,0 +1,124 @@ +'use client'; + +import React, { useRef } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { authClient } from '@/lib/auth-client'; +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'; +import { KeyRound } from 'lucide-react'; +import Link from 'next/link'; + +export function SignInCard() { + const tokenRef = useRef(null); + + async function onSubmit(event: React.SubmitEvent) { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + const { error } = await authClient.signIn.email({ + email: email, + password: password, + fetchOptions: { + headers: { + 'x-captcha-response': tokenRef.current?.getResponse(), + }, + }, + }); + + console.log(error); + } + + async function onPasskeySignIn() { + const { data, error } = await authClient.signIn.passkey({ + autoFill: false, + }); + + console.log(data); + console.log(error); + } + + return ( + <> + + + Log into your Awaketh account + + Enter your email below to log into your account. + + + + + + +
+
+
+ + +
+ + +
+
+
+ +
+ + + +
+ +
+
+
+
+ + ); +} diff --git a/src/components/login-form.tsx b/src/components/login-form.tsx new file mode 100644 index 0000000..6e0a32d --- /dev/null +++ b/src/components/login-form.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldSeparator, +} from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { SiGoogle } from '@icons-pack/react-simple-icons'; +import { KeyRound } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import React, { useRef, useState } from 'react'; +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'; +import { toast } from 'sonner'; +import { authClient } from '@/lib/auth-client'; + +type PendingMethod = 'email' | 'passkey' | 'google'; + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<'div'>) { + const router = useRouter(); + const tokenRef = useRef(null); + const [pending, setPending] = useState(null); + + async function onSubmit(event: React.SubmitEvent) { + event.preventDefault(); + if (pending) return; + + const formData = new FormData(event.currentTarget); + + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + setPending('email'); + try { + let captchaToken: string; + try { + captchaToken = await tokenRef.current!.getResponsePromise(); + } catch { + toast.error('Captcha check failed. Please try again.'); + return; + } + + const { error } = await authClient.signIn.email({ + email: email, + password: password, + fetchOptions: { + headers: { + 'x-captcha-response': captchaToken, + }, + }, + }); + + if (error) { + toast.error(error.message ?? 'Could not sign you in.'); + return; + } + + router.push('/dashboard'); + } finally { + // Turnstile tokens are single-use, so issue a fresh one for the next attempt. + tokenRef.current?.reset(); + setPending(null); + } + } + + async function onPasskeySignIn() { + if (pending) return; + + setPending('passkey'); + try { + const { error } = await authClient.signIn.passkey({ + autoFill: false, + }); + + if (error) { + toast.error(error.message ?? 'Passkey sign-in failed.'); + return; + } + + router.push('/dashboard'); + } finally { + setPending(null); + } + } + + async function onGoogleSignIn() { + if (pending) return; + + setPending('google'); + + const { error } = await authClient.signIn.social({ + provider: 'google', + callbackURL: '/', + }); + + // A successful call navigates away to Google, so the only path back here + // is a failure — leave the button disabled otherwise. + if (error) { + toast.error(error.message ?? 'Google sign-in failed.'); + setPending(null); + } + } + + return ( +
+ + + Welcome back + + Login with your Google account or with your passkey. + + + +
+ + + + + + + Or continue with + + + Email + + + + + + + + + + + Don't have an account?{' '} + Sign up + + + +
+
+
+ + By clicking continue, you agree to our{' '} + Terms of Service and{' '} + Privacy Policy. + +
+ ); +} diff --git a/src/components/signup-form.tsx b/src/components/signup-form.tsx new file mode 100644 index 0000000..5198b74 --- /dev/null +++ b/src/components/signup-form.tsx @@ -0,0 +1,169 @@ +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from '@/components/ui/field'; +import { Input } from '@/components/ui/input'; +import { useRouter } from 'next/navigation'; +import { useRef } from 'react'; +import { sanitizeWord } from '@/lib/auth-functions/name-sanitization'; +import { Turnstile, TurnstileInstance } from '@marsidev/react-turnstile'; +import { authClient } from '@/lib/auth-client'; +import { toast } from 'sonner'; + +export function SignupForm({ + className, + ...props +}: React.ComponentProps<'div'>) { + const router = useRouter(); + const tokenRef = useRef(null); + + async function onSubmit(event: React.SubmitEvent) { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const email = formData.get('email') as string; + const password = formData.get('password') as string; + + const firstName = formData.get('firstName') as string; + const lastName = formData.get('lastName') as string; + + let captchaToken: string; + try { + captchaToken = await tokenRef.current!.getResponsePromise(); + } catch { + tokenRef.current?.reset(); + toast.error('Captcha check failed. Please try again.'); + return; + } + + const { error } = await authClient.signUp.email({ + name: sanitizeWord(firstName) + ' ' + sanitizeWord(lastName), + email: email, + password: password, + fetchOptions: { + headers: { + 'x-captcha-response': captchaToken, + }, + }, + }); + + // Turnstile tokens are single-use, so issue a fresh one for the next attempt. + tokenRef.current?.reset(); + + if (error) { + toast.error(error.message); + return; + } + toast.success( + 'Account created successfully! Please check your email to verify your account.', + ); + + router.push('/'); + } + return ( +
+ + + Create your account + + Enter your information to get started with Awaketh. + + + +
+ +
+ + First Name + + + + Last Name + + +
+ + Email + + + + + + Password + + + + + Confirm Password + + + + + + Must be at least 8 characters long. + + + + + + + Already have an account? Sign in + + +
+
+
+
+ + By signing up, you agree to our{' '} + Terms of Service and{' '} + Privacy Policy. + +
+ ); +} diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..4458dae --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..2bc5bb6 --- /dev/null +++ b/src/components/ui/field.tsx @@ -0,0 +1,238 @@ +"use client" + +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-2 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +