From 64b3b0a8542087c229d9c0905cdfbb5e54f73032 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Tue, 2 Sep 2025 16:30:37 -0500 Subject: [PATCH] Added stuff idek --- bun.lock | 87 +++++- convex/CustomPassword.ts | 248 ++--------------- convex/auth.ts | 18 +- next.config.js | 15 - package.json | 3 + public/icons/misc/gitea.svg | 1 + public/icons/tv/enter.svg | 63 +++++ public/icons/tv/exit.svg | 63 +++++ src/app/(auth)/profile/layout.tsx | 14 + src/app/(auth)/profile/page.tsx | 4 + src/app/{ => (auth)}/signin/page.tsx | 0 src/app/global-error.tsx | 79 ++++++ src/app/layout.tsx | 19 +- src/app/page.tsx | 154 +---------- src/app/server/inner.tsx | 31 --- src/app/server/page.tsx | 24 -- .../layout/header/controls/AvatarDropdown.tsx | 72 +++++ .../layout/header/controls/index.tsx | 23 ++ src/components/layout/header/index.tsx | 69 +++++ src/components/providers/TVModeProvider.tsx | 166 +++++++++++ src/components/providers/index.tsx | 1 + src/components/ui/avatar.tsx | 53 ++++ src/components/ui/based-avatar.tsx | 69 +++++ src/components/ui/dropdown-menu.tsx | 257 ++++++++++++++++++ src/components/ui/index.tsx | 11 + src/components/ui/sonner.tsx | 25 ++ 26 files changed, 1102 insertions(+), 467 deletions(-) create mode 100644 public/icons/misc/gitea.svg create mode 100644 public/icons/tv/enter.svg create mode 100644 public/icons/tv/exit.svg create mode 100644 src/app/(auth)/profile/layout.tsx create mode 100644 src/app/(auth)/profile/page.tsx rename src/app/{ => (auth)}/signin/page.tsx (100%) create mode 100644 src/app/global-error.tsx delete mode 100644 src/app/server/inner.tsx delete mode 100644 src/app/server/page.tsx create mode 100644 src/components/layout/header/controls/AvatarDropdown.tsx create mode 100644 src/components/layout/header/controls/index.tsx create mode 100644 src/components/layout/header/index.tsx create mode 100644 src/components/providers/TVModeProvider.tsx create mode 100644 src/components/ui/avatar.tsx create mode 100644 src/components/ui/based-avatar.tsx create mode 100644 src/components/ui/dropdown-menu.tsx create mode 100644 src/components/ui/sonner.tsx diff --git a/bun.lock b/bun.lock index 29e6519..13e9970 100644 --- a/bun.lock +++ b/bun.lock @@ -6,42 +6,45 @@ "dependencies": { "@convex-dev/auth": "^0.0.81", "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tabs": "^1.1.13", - "@sentry/nextjs": "^10.7.0", + "@sentry/nextjs": "^10.8.0", "@t3-oss/env-nextjs": "^0.13.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "convex": "^1.26.0", + "convex": "^1.26.2", "eslint-plugin-prettier": "^5.5.4", "lucide-react": "^0.542.0", "next": "15.2.3", "next-plausible": "^3.12.4", "next-themes": "^0.4.6", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", "react-hook-form": "^7.62.0", "require-in-the-middle": "^7.5.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "typescript-eslint": "^8.41.0", "zod": "^4.1.5", }, "devDependencies": { - "@eslint/eslintrc": "^3", - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "dotenv": "^16.4.7", - "eslint": "^9", + "@eslint/eslintrc": "^3.3.1", + "@tailwindcss/postcss": "^4.1.12", + "@types/node": "^20.19.11", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "dotenv": "^16.6.1", + "eslint": "^9.34.0", "eslint-config-next": "15.2.3", "npm-run-all": "^4.1.5", - "prettier": "^3.5.3", - "tailwindcss": "^4", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.12", "tw-animate-css": "^1.3.7", - "typescript": "^5", + "typescript": "^5.9.2", }, }, }, @@ -296,6 +299,14 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@hookform/resolvers": ["@hookform/resolvers@5.2.1", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-u0+6X58gkjMcxur1wRWokA7XsiiBJ6aK17aPZxhkoYiK5J+HcTx0Vhu9ovXe6H+dVpO6cjrn2FkJTryXEMlryQ=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -528,6 +539,10 @@ "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], @@ -536,10 +551,24 @@ "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], "@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], @@ -558,8 +587,18 @@ "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + "@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.1", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.2.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw=="], @@ -842,6 +881,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], @@ -980,6 +1021,8 @@ "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], @@ -1110,6 +1153,8 @@ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], @@ -1470,6 +1515,12 @@ "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + "react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "read-pkg": ["read-pkg@3.0.0", "", { "dependencies": { "load-json-file": "^4.0.0", "normalize-package-data": "^2.3.2", "path-type": "^3.0.0" } }, "sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], @@ -1540,6 +1591,8 @@ "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], + "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=="], @@ -1642,6 +1695,12 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], diff --git a/convex/CustomPassword.ts b/convex/CustomPassword.ts index 64308b5..d14e772 100644 --- a/convex/CustomPassword.ts +++ b/convex/CustomPassword.ts @@ -1,228 +1,22 @@ -import { - ConvexCredentials, - type ConvexCredentialsUserConfig, -} from "@convex-dev/auth/providers/ConvexCredentials"; -import { - type EmailConfig, - type GenericActionCtxWithAuthConfig, - type GenericDoc, - createAccount, - invalidateSessions, - modifyAccountCredentials, - retrieveAccount, - signInViaProvider, -} from "@convex-dev/auth/server"; -import type { - DocumentByName, - GenericDataModel, - WithoutSystemFields, -} from "convex/server"; -import type { Value } from "convex/values"; -import { Scrypt } from "lucia"; +import { ConvexError } from "convex/values"; +import { Password } from "@convex-dev/auth/providers/Password"; +import type { DataModel } from "./_generated/dataModel"; -export type PasswordConfig = { - id?: string; - /** - * Perform checks on provided params and customize the user - * information stored after sign up, including email normalization. - * - * Called for every flow ("signUp", "signIn", "reset", - * "reset-verification" and "email-verification"). - */ - profile?: ( - /** - * The values passed to the `signIn` function. - */ - params: Record, - /** - * Convex ActionCtx in case you want to read from or write to - * the database. - */ - ctx: GenericActionCtxWithAuthConfig, - ) => WithoutSystemFields> & { - email: string; - }; - /** - * Performs custom validation on password provided during sign up or reset. - * - * Otherwise the default validation is used (password is not empty and - * at least 8 characters in length). - * - * If the provided password is invalid, implementations must throw an Error. - * - * @param password the password supplied during "signUp" or - * "reset-verification" flows. - */ - validatePasswordRequirements?: (password: string) => void; - /** - * Provide hashing and verification functions if you want to control - * how passwords are hashed. - */ - crypto?: ConvexCredentialsUserConfig["crypto"]; - /** - * An Auth.js email provider used to require verification - * before password reset. - */ - reset?: EmailConfig | ((...args: any) => EmailConfig); - /** - * An Auth.js email provider used to require verification - * before sign up / sign in. - */ - verify?: EmailConfig | ((...args: any) => EmailConfig); -} - -/** - * Email and password authentication provider. - * - * Passwords are by default hashed using Scrypt from Lucia. - * You can customize the hashing via the `crypto` option. - * - * Email verification is not required unless you pass - * an email provider to the `verify` option. - */ -export function Password( - config: PasswordConfig = {}, -) { - const provider = config.id ?? "password"; - return ConvexCredentials({ - id: "password", - authorize: async (params, ctx) => { - const flow = params.flow as string; - const passwordToValidate = - flow === "signUp" - ? (params.password as string) - : flow === "reset-verification" - ? (params.newPassword as string) - : null; - if (passwordToValidate !== null) { - if (config.validatePasswordRequirements !== undefined) { - config.validatePasswordRequirements(passwordToValidate); - } else { - validateDefaultPasswordRequirements(passwordToValidate); - } - } - const profile = config.profile?.(params, ctx) ?? defaultProfile(params); - const { email } = profile; - const secret = params.password as string; - let account: GenericDoc; - let user: GenericDoc; - if (flow === "signUp") { - if (secret === undefined) { - throw new Error("Missing `password` param for `signUp` flow"); - } - const created = await createAccount(ctx, { - provider, - account: { id: email, secret }, - profile: profile as any, - shouldLinkViaEmail: config.verify !== undefined, - shouldLinkViaPhone: false, - }); - ({ account, user } = created); - } else if (flow === "signIn") { - if (secret === undefined) { - throw new Error("Missing `password` param for `signIn` flow"); - } - const retrieved = await retrieveAccount(ctx, { - provider, - account: { id: email, secret }, - }); - if (retrieved === null) { - throw new Error("Invalid credentials"); - } - ({ account, user } = retrieved); - // START: Optional, support password reset - } else if (flow === "reset") { - if (!config.reset) { - throw new Error(`Password reset is not enabled for ${provider}`); - } - const { account } = await retrieveAccount(ctx, { - provider, - account: { id: email }, - }); - return await signInViaProvider(ctx, config.reset, { - accountId: account._id, - params, - }); - } else if (flow === "reset-verification") { - if (!config.reset) { - throw new Error(`Password reset is not enabled for ${provider}`); - } - if (params.newPassword === undefined) { - throw new Error( - "Missing `newPassword` param for `reset-verification` flow", - ); - } - const result = await signInViaProvider(ctx, config.reset, { params }); - if (result === null) { - throw new Error("Invalid code"); - } - const { userId, sessionId } = result; - const secret = params.newPassword as string; - await modifyAccountCredentials(ctx, { - provider, - account: { id: email, secret }, - }); - await invalidateSessions(ctx, { userId, except: [sessionId] }); - return { userId, sessionId }; - // END - // START: Optional, email verification during sign in - } else if (flow === "email-verification") { - if (!config.verify) { - throw new Error(`Email verification is not enabled for ${provider}`); - } - const { account } = await retrieveAccount(ctx, { - provider, - account: { id: email }, - }); - return await signInViaProvider(ctx, config.verify, { - accountId: account._id, - params, - }); - // END - } else { - throw new Error( - "Missing `flow` param, it must be one of " + - '"signUp", "signIn", "reset", "reset-verification" or ' + - '"email-verification"!', - ); - } - // START: Optional, email verification during sign in - if (config.verify && !account.emailVerified) { - return await signInViaProvider(ctx, config.verify, { - accountId: account._id, - params, - }); - } - // END - return { userId: user._id }; - }, - crypto: { - async hashSecret(password: string) { - return await new Scrypt().hash(password); - }, - async verifySecret(password: string, hash: string) { - return await new Scrypt().verify(hash, password); - }, - }, - extraProviders: [config.reset, config.verify], - ...config, - }); -} - -function validateDefaultPasswordRequirements(password: string) { - if ( - password.length < 8 || - !/\d/.test(password) || - !/[a-z]/.test(password) || - !/[A-Z]/.test(password) - ) { - throw new Error("Invalid password."); - } -} - -function defaultProfile(params: Record) { - return { - email: params.email as string, - name: params.name as string, - }; -} +export default Password({ + profile(params, ctx) { + return { + email: params.email as string, + name: params.name as string, + }; + }, + validatePasswordRequirements: (password: string) => { + if ( + password.length < 8 || + !/\d/.test(password) || + !/[a-z]/.test(password) || + !/[A-Z]/.test(password) + ) { + throw new ConvexError("Invalid password."); + } + }, +}); diff --git a/convex/auth.ts b/convex/auth.ts index 8b623ac..e388446 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -1,6 +1,20 @@ -import { convexAuth } from '@convex-dev/auth/server'; -import { Password } from './CustomPassword'; +import { convexAuth, getAuthUserId } from '@convex-dev/auth/server'; +import { query } from './_generated/server'; +import Password from './CustomPassword'; export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ providers: [Password], }); + +export const getUser = query(async (ctx) => { + const userId = await getAuthUserId(ctx); + if (!userId) return null; + const user = await ctx.db.get(userId); + if (!user) return null; + return { + id: user._id, + email: user.email ?? null, + name: user.name ?? null, + image: user.image ?? null, + } +}); diff --git a/next.config.js b/next.config.js index cc48602..4312207 100644 --- a/next.config.js +++ b/next.config.js @@ -21,21 +21,6 @@ const nextConfig = withPlausibleProxy({ bodySizeLimit: '10mb', }, }, - turbopack: { - rules: { - '*.svg': { - loaders: [ - { - loader: '@svgr/webpack', - options: { - icon: true, - }, - }, - ], - as: '*.js', - }, - }, - }, }); const sentryConfig = { diff --git a/package.json b/package.json index 1bf3921..76ffe4b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "dependencies": { "@convex-dev/auth": "^0.0.81", "@hookform/resolvers": "^5.2.1", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", @@ -38,6 +40,7 @@ "react-dom": "^19.1.1", "react-hook-form": "^7.62.0", "require-in-the-middle": "^7.5.2", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "typescript-eslint": "^8.41.0", "zod": "^4.1.5" diff --git a/public/icons/misc/gitea.svg b/public/icons/misc/gitea.svg new file mode 100644 index 0000000..d9eb11a --- /dev/null +++ b/public/icons/misc/gitea.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons/tv/enter.svg b/public/icons/tv/enter.svg new file mode 100644 index 0000000..0ec0d06 --- /dev/null +++ b/public/icons/tv/enter.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + diff --git a/public/icons/tv/exit.svg b/public/icons/tv/exit.svg new file mode 100644 index 0000000..658a81c --- /dev/null +++ b/public/icons/tv/exit.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + diff --git a/src/app/(auth)/profile/layout.tsx b/src/app/(auth)/profile/layout.tsx new file mode 100644 index 0000000..6dc208d --- /dev/null +++ b/src/app/(auth)/profile/layout.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; + +export const generateMetadata = (): Metadata => { + return { + title: 'Profile', + }; +}; + +const ProfileLayout = ({ + children, +}: Readonly<{ children: React.ReactNode }>) => { + return
{children}
; +}; +export default ProfileLayout; diff --git a/src/app/(auth)/profile/page.tsx b/src/app/(auth)/profile/page.tsx new file mode 100644 index 0000000..958621c --- /dev/null +++ b/src/app/(auth)/profile/page.tsx @@ -0,0 +1,4 @@ +const Profile = () => { + return
; +}; +export default Profile; diff --git a/src/app/signin/page.tsx b/src/app/(auth)/signin/page.tsx similarity index 100% rename from src/app/signin/page.tsx rename to src/app/(auth)/signin/page.tsx diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..d563cb0 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,79 @@ +'use client'; + +import type { Metadata } from 'next'; +import NextError from 'next/error'; +import { Geist, Geist_Mono } from 'next/font/google'; +import '@/styles/globals.css'; +import { + ConvexClientProvider, + ThemeProvider, + TVModeProvider, +} from '@/components/providers' +import * as Sentry from '@sentry/nextjs'; +import { generateMetadata } from '@/lib/metadata'; +import PlausibleProvider from 'next-plausible'; +import Header from '@/components/layout/header'; +import { useEffect } from 'react'; +import { Button, Toaster } from '@/components/ui'; + +const geistSans = Geist({ + variable: '--font-geist-sans', + subsets: ['latin'], +}); +const geistMono = Geist_Mono({ + variable: '--font-geist-mono', + subsets: ['latin'], +}); +const metadata: Metadata = generateMetadata(); +metadata.title = `Error | Tech Tracker`; +export {metadata}; + +type GlobalErrorProps = { + error: Error & { digest?: string} + reset?: () => void; +}; + +const GlobalError = ({error, reset = undefined }: GlobalErrorProps) => { + useEffect(() => { + Sentry.captureException(error); + }, [error]) + return ( + + + + + + + +
+
+ + {reset !== undefined && ( + + )} + +
+ + + + + + + + + ); +}; +export default GlobalError; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3d84d9f..570230f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,20 +2,24 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import '@/styles/globals.css'; import { ConvexAuthNextjsServerProvider } from '@convex-dev/auth/nextjs/server'; -import { ConvexClientProvider, ThemeProvider } from '@/components/providers'; +import { + ConvexClientProvider, + ThemeProvider, + TVModeProvider, +} from '@/components/providers'; import PlausibleProvider from 'next-plausible'; import { generateMetadata } from '@/lib/metadata'; +import { Toaster } from '@/components/ui'; +import Header from '@/components/layout/header'; const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'], }); - const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'], }); - export const metadata: Metadata = generateMetadata(); export default function RootLayout({ @@ -39,9 +43,14 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} + + +
+ {children} + + + - diff --git a/src/app/page.tsx b/src/app/page.tsx index b3b3d9a..38e0421 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,154 +6,10 @@ import Link from 'next/link'; import { useAuthActions } from '@convex-dev/auth/react'; import { useRouter } from 'next/navigation'; -export default function Home() { +const Home = () => { return ( - <> -
- Convex + Next.js + Convex Auth - -
-
-

- Convex + Next.js + Convex Auth -

- -
- +
+
); -} - -function SignOutButton() { - const { isAuthenticated } = useConvexAuth(); - const { signOut } = useAuthActions(); - const router = useRouter(); - return ( - <> - {isAuthenticated && ( - - )} - - ); -} - -function Content() { - const { viewer, numbers } = - useQuery(api.myFunctions.listNumbers, { - count: 10, - }) ?? {}; - const addNumber = useMutation(api.myFunctions.addNumber); - - if (viewer === undefined || numbers === undefined) { - return ( -
-

loading... (consider a loading skeleton)

-
- ); - } - - return ( -
-

Welcome {viewer ?? 'Anonymous'}!

-

- Click the button below and open this page in another window - this data - is persisted in the Convex cloud database! -

-

- -

-

- Numbers:{' '} - {numbers?.length === 0 - ? 'Click the button!' - : (numbers?.join(', ') ?? '...')} -

-

- Edit{' '} - - convex/myFunctions.ts - {' '} - to change your backend -

-

- Edit{' '} - - app/page.tsx - {' '} - to change your frontend -

-

- See the{' '} - - /server route - {' '} - for an example of loading data in a server component -

-
-

Useful resources:

-
-
- - -
-
- - -
-
-
-
- ); -} - -function ResourceCard({ - title, - description, - href, -}: { - title: string; - description: string; - href: string; -}) { - return ( -
- - {title} - -

{description}

-
- ); -} +}; +export default Home; diff --git a/src/app/server/inner.tsx b/src/app/server/inner.tsx deleted file mode 100644 index 5b26626..0000000 --- a/src/app/server/inner.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'use client'; - -import { type Preloaded, useMutation, usePreloadedQuery } from 'convex/react'; -import { api } from '~/convex/_generated/api'; - -export default function Home({ - preloaded, -}: { - preloaded: Preloaded; -}) { - const data = usePreloadedQuery(preloaded); - const addNumber = useMutation(api.myFunctions.addNumber); - return ( - <> -
-

Reactive client-loaded data

- -
{JSON.stringify(data, null, 2)}
-
-
- - - ); -} diff --git a/src/app/server/page.tsx b/src/app/server/page.tsx deleted file mode 100644 index c5ec225..0000000 --- a/src/app/server/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Home from './inner'; -import { preloadQuery, preloadedQueryResult } from 'convex/nextjs'; -import { api } from '~/convex/_generated/api'; - -export default async function ServerPage() { - const preloaded = await preloadQuery(api.myFunctions.listNumbers, { - count: 3, - }); - - const data = preloadedQueryResult(preloaded); - - return ( -
-

Convex + Next.js

-
-

Non-reactive server-loaded data

- -
{JSON.stringify(data, null, 2)}
-
-
- -
- ); -} diff --git a/src/components/layout/header/controls/AvatarDropdown.tsx b/src/components/layout/header/controls/AvatarDropdown.tsx new file mode 100644 index 0000000..62c7c12 --- /dev/null +++ b/src/components/layout/header/controls/AvatarDropdown.tsx @@ -0,0 +1,72 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { + BasedAvatar, + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui'; +import { useConvexAuth, useQuery } from 'convex/react'; +import { useAuthActions } from '@convex-dev/auth/react'; +import { api } from '~/convex/_generated/api'; + +export const AvatarDropdown = () => { + const router = useRouter(); + const { isLoading, isAuthenticated } = useConvexAuth(); + const { signOut} = useAuthActions(); + const user = useQuery(api.auth.getUser); + + if (isLoading) return ; + if (!isAuthenticated) return
; + return ( + + + + + + {(user?.name ?? user?.email) && ( + <> + + {user.name?.trim() ?? user.email?.trim()} + + + + )} + + + Edit Profile + + + + + + + + + ); +}; diff --git a/src/components/layout/header/controls/index.tsx b/src/components/layout/header/controls/index.tsx new file mode 100644 index 0000000..a05c83a --- /dev/null +++ b/src/components/layout/header/controls/index.tsx @@ -0,0 +1,23 @@ +'use client'; +import { + ThemeToggle, + type ThemeToggleProps, +} from '@/components/providers'; +import { AvatarDropdown } from './AvatarDropdown'; + +export const Controls = (themeToggleProps?: ThemeToggleProps) => { + return ( +
+ + +
+ ); +}; diff --git a/src/components/layout/header/index.tsx b/src/components/layout/header/index.tsx new file mode 100644 index 0000000..0103611 --- /dev/null +++ b/src/components/layout/header/index.tsx @@ -0,0 +1,69 @@ +'use client'; +import Image from 'next/image'; +import Link from 'next/link'; +import { + useTVMode, +} from '@/components/providers'; +import { cn } from '@/lib/utils'; +import { type ComponentProps } from 'react'; +import { Controls } from './controls'; + +const Header = (headerProps: ComponentProps<'header'>) => { + const { tvMode } = useTVMode(); + + if (tvMode) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {/* Left spacer for perfect centering */} +
+
+
+ + {/* Centered logo and title */} +
+ + Tech Tracker Logo +

+ Tech Tracker +

+ +
+ + {/* Right-aligned controls */} +
+ +
+
+
+ ); +}; +export default Header; diff --git a/src/components/providers/TVModeProvider.tsx b/src/components/providers/TVModeProvider.tsx new file mode 100644 index 0000000..25dd238 --- /dev/null +++ b/src/components/providers/TVModeProvider.tsx @@ -0,0 +1,166 @@ +'use client'; +import React, { createContext, useContext, useState } from 'react'; +import type { ReactNode } from 'react'; +import { Button } from '@/components/ui'; +import { type ComponentProps } from 'react'; +import { cn } from '@/lib/utils'; + +type TVModeContextProps = { + tvMode: boolean; + toggleTVMode: () => void; +}; + +type TVToggleProps = { + buttonClassName?: ComponentProps['className']; + buttonProps?: Omit, 'className'>; + size?: number; +}; + +const TVModeContext = createContext(undefined); + +const TVModeProvider = ({ children }: { children: ReactNode }) => { + const [tvMode, setTVMode] = useState(false); + const toggleTVMode = () => { + setTVMode((prev) => !prev); + }; + return ( + + {children} + + ); +}; + +const useTVMode = () => { + const context = useContext(TVModeContext); + if (!context) { + throw new Error('useTVMode must be used within a TVModeProvider'); + } + return context; +}; + +// TV Icon Component with animations +const TVIcon = ({ tvMode, size = 25 }: { tvMode: boolean; size?: number }) => { + return ( +
+ + {/* TV Screen */} + + + {/* TV Stand */} + + + {/* Corner arrows - animate based on mode */} + + {tvMode ? ( + // Exit fullscreen arrows (pointing inward) + <> + + + + + + ) : ( + // Enter fullscreen arrows (pointing outward) + <> + + + + + + )} + + + {/* Optional: Screen content indicator */} + + +
+ ); +}; + +const TVToggle = ({ + buttonClassName, + buttonProps = { + variant: 'outline', + size: 'default', + }, + size = 25, +}: TVToggleProps) => { + const { tvMode, toggleTVMode } = useTVMode(); + + return ( + + ); +}; + +export { TVModeProvider, useTVMode, TVToggle }; diff --git a/src/components/providers/index.tsx b/src/components/providers/index.tsx index 8ab0dcf..2919c46 100644 --- a/src/components/providers/index.tsx +++ b/src/components/providers/index.tsx @@ -1,2 +1,3 @@ export { ConvexClientProvider } from './ConvexClientProvider'; export { ThemeProvider, ThemeToggle, type ThemeToggleProps } from './ThemeProvider'; +export { TVModeProvider, useTVMode, TVToggle } from './TVModeProvider'; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/based-avatar.tsx b/src/components/ui/based-avatar.tsx new file mode 100644 index 0000000..ec0fcee --- /dev/null +++ b/src/components/ui/based-avatar.tsx @@ -0,0 +1,69 @@ +'use client'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; +import { User } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { AvatarImage } from '@/components/ui/avatar'; +import { type ComponentProps } from 'react'; + +type BasedAvatarProps = ComponentProps & { + src?: string | null; + fullName?: string | null; + imageProps?: Omit, 'data-slot'>; + fallbackProps?: ComponentProps; + userIconProps?: ComponentProps; +}; + +const BasedAvatar = ({ + src = null, + fullName = null, + imageProps, + fallbackProps, + userIconProps = { + size: 32, + }, + className, + ...props +}: BasedAvatarProps) => { + return ( + + {src ? ( + + ) : ( + + {fullName ? ( + fullName + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + ) : ( + + )} + + )} + + ); +}; + +export { BasedAvatar }; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..ec51e9c --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index 3b68e2b..2584028 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -1,3 +1,5 @@ +export { Avatar, AvatarImage, AvatarFallback } from './avatar'; +export { BasedAvatar } from './based-avatar'; export { Button, buttonVariants } from './button'; export { Card, @@ -8,6 +10,14 @@ export { CardDescription, CardContent, } from './card'; +export { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from './dropdown-menu'; export { useFormField, Form, @@ -29,3 +39,4 @@ export { TabsTrigger, TabsContent } from './tabs'; +export { Toaster } from './sonner'; diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..957524e --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster }