From 2a516e8b46e85d6c79dac5bfac9308f44fc2d515 Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Tue, 7 Jul 2026 06:32:07 +0300 Subject: [PATCH 1/7] feat: add user profile page and improve Swagger API documentation --- apps/api/docs/swagger.json | 211 +++++++++++++++++++++++++++++++++++++ apps/web/angular.json | 5 + apps/web/bun.lock | 52 +++++++++ apps/web/package.json | 8 ++ 4 files changed, 276 insertions(+) diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 3891c95..b2f5bc0 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -548,6 +548,40 @@ } } }, +<<<<<<< HEAD +======= + "/api/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns the logged-in user's full profile: bio info, coding metrics, activity heatmap, problem-solving gauge, achievements, recent activity, and personal-info completion.", + "produces": [ + "application/json" + ], + "tags": [ + "profile" + ], + "summary": "Get full profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/helpers.APIResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/helpers.APIResponse" + } + } + } + } + }, +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "/api/settings/account": { "post": { "security": [ @@ -815,6 +849,43 @@ } } }, +<<<<<<< HEAD +======= + "models.Achievement": { + "type": "object", + "properties": { + "badgeClass": { + "type": "string" + }, + "description": { + "type": "string" + }, + "earnedAt": { + "type": "string" + }, + "key": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "models.ActivityLogItem": { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "text": { + "type": "string" + } + } + }, +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "models.Heartbeat": { "type": "object", "properties": { @@ -852,6 +923,146 @@ "type": "string" } } +<<<<<<< HEAD +======= + }, + "models.HeatmapCell": { + "type": "object", + "properties": { + "date": { + "type": "string" + }, + "level": { + "type": "integer" + } + } + }, + "models.ProfileInfoField": { + "type": "object", + "properties": { + "completed": { + "type": "boolean" + }, + "key": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + "models.ProfileMetric": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "label": { + "type": "string" + }, + "positive": { + "type": "boolean" + }, + "sub": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "models.ProfileResponse": { + "type": "object", + "properties": { + "achievements": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Achievement" + } + }, + "attempting": { + "type": "integer" + }, + "avatarUrl": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "completionPercent": { + "type": "integer" + }, + "email": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "heatmap": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.HeatmapCell" + } + } + }, + "infoFields": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProfileInfoField" + } + }, + "joinDate": { + "type": "string" + }, + "lastActive": { + "type": "string" + }, + "location": { + "type": "string" + }, + "maxStreak": { + "type": "integer" + }, + "memberFor": { + "type": "string" + }, + "metrics": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProfileMetric" + } + }, + "recentActivity": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ActivityLogItem" + } + }, + "role": { + "type": "string" + }, + "solved": { + "type": "integer" + }, + "timeZone": { + "type": "string" + }, + "totalActiveDays": { + "type": "integer" + }, + "totalProblems": { + "type": "integer" + }, + "university": { + "type": "string" + }, + "username": { + "type": "string" + } + } +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) } }, "securityDefinitions": { diff --git a/apps/web/angular.json b/apps/web/angular.json index 827d954..b4263e9 100644 --- a/apps/web/angular.json +++ b/apps/web/angular.json @@ -2,7 +2,12 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { +<<<<<<< HEAD "packageManager": "bun" +======= + "packageManager": "bun", + "analytics": "b6ac57da-2b69-4157-a25b-75012047082b" +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) }, "newProjectRoot": "projects", "projects": { diff --git a/apps/web/bun.lock b/apps/web/bun.lock index a6a19ab..97c8cd6 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -3,7 +3,11 @@ "configVersion": 1, "workspaces": { "": { +<<<<<<< HEAD "name": "web", +======= + "name": "seismic-web", +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "dependencies": { "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", @@ -11,6 +15,10 @@ "@angular/forms": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", +<<<<<<< HEAD +======= + "fdir": "^6.5.0", +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -435,6 +443,7 @@ "@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="], +<<<<<<< HEAD "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], @@ -448,6 +457,21 @@ "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], +======= + "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + + "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + + "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + + "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], @@ -473,7 +497,11 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], +<<<<<<< HEAD "baseline-browser-mapping": ["baseline-browser-mapping@2.10.41", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A=="], +======= + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="], @@ -485,7 +513,11 @@ "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], +<<<<<<< HEAD "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], +======= + "browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -497,7 +529,11 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], +<<<<<<< HEAD "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], +======= + "caniuse-lite": ["caniuse-lite@1.0.30001802", "", {}, "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -653,7 +689,11 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], +<<<<<<< HEAD "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], +======= + "hono": ["hono@4.12.28", "", {}, "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], @@ -993,7 +1033,11 @@ "vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], +<<<<<<< HEAD "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], +======= + "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], @@ -1045,8 +1089,11 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], +<<<<<<< HEAD "@inquirer/prompts/@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], +======= +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1067,6 +1114,11 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], +<<<<<<< HEAD +======= + "estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/apps/web/package.json b/apps/web/package.json index 1ce6a14..6c80486 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,11 @@ "test": "ng test" }, "private": true, +<<<<<<< HEAD "packageManager": "bun@1.3.14", +======= + "packageManager": "bun@1.3.14" , +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "dependencies": { "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", @@ -17,6 +21,10 @@ "@angular/forms": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", +<<<<<<< HEAD +======= + "fdir": "^6.5.0", +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" From 670b0eb0e3f81d0355a74dd8d9694a82dfeb9649 Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Tue, 7 Jul 2026 23:34:25 +0300 Subject: [PATCH 2/7] updates to profile schema, refresh tokens, and stats caching - Add UpdateProfileStats to upsert cached aggregate stats after session processing - Add GetProfileStats model function for reading cached profile stats - Add RevokeAllUserRefreshTokens and CleanupExpiredRefreshTokens for token management - Add POST /api/auth/logout endpoint to revoke refresh token and clear cookie - Add periodic cleanup of expired refresh tokens on token refresh - Populate user_profile_stats table from session processor background job - Extend User model with all profile fields from migrations --- apps/api/db/migrations/005_extend_users.sql | 10 ++ apps/api/db/migrations/006_profile_stats.sql | 10 ++ apps/api/db/migrations/007_achievements.sql | 18 +++ apps/api/db/migrations/008_activity_log.sql | 15 +++ .../009_problem_stats_placeholder.sql | 14 +++ apps/api/db/seed_profile.sql | 56 +++++++++ apps/api/docs/swagger.json | 15 +-- apps/api/handlers/auth.go | 34 ++++++ apps/api/models/profile.go | 109 ++++++++++++++++++ apps/api/models/refresh_token.go | 22 ++++ apps/api/models/user.go | 49 ++++---- apps/api/routes/routes.go | 1 + apps/api/services/session_processor.go | 12 ++ 13 files changed, 336 insertions(+), 29 deletions(-) create mode 100644 apps/api/db/migrations/005_extend_users.sql create mode 100644 apps/api/db/migrations/006_profile_stats.sql create mode 100644 apps/api/db/migrations/007_achievements.sql create mode 100644 apps/api/db/migrations/008_activity_log.sql create mode 100644 apps/api/db/migrations/009_problem_stats_placeholder.sql create mode 100644 apps/api/db/seed_profile.sql create mode 100644 apps/api/models/profile.go diff --git a/apps/api/db/migrations/005_extend_users.sql b/apps/api/db/migrations/005_extend_users.sql new file mode 100644 index 0000000..784a010 --- /dev/null +++ b/apps/api/db/migrations/005_extend_users.sql @@ -0,0 +1,10 @@ +-- Extends the users table with profile-specific fields not yet present +ALTER TABLE users + ADD COLUMN first_name VARCHAR(50), + ADD COLUMN role VARCHAR(50), + ADD COLUMN location VARCHAR(100), + ADD COLUMN university VARCHAR(100), + ADD COLUMN time_zone VARCHAR(50), + ADD COLUMN last_active_at TIMESTAMPTZ, + ADD COLUMN gender VARCHAR(50), + ADD COLUMN languages TEXT[]; -- Simple text array for display purposes on the profile diff --git a/apps/api/db/migrations/006_profile_stats.sql b/apps/api/db/migrations/006_profile_stats.sql new file mode 100644 index 0000000..96229b6 --- /dev/null +++ b/apps/api/db/migrations/006_profile_stats.sql @@ -0,0 +1,10 @@ +-- Cached aggregate coding data to avoid heavy calculations on every profile view +-- This will be populated and updated by the background session-processing job +CREATE TABLE user_profile_stats ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + total_coding_seconds BIGINT DEFAULT 0, + total_active_days INTEGER DEFAULT 0, + current_streak INTEGER DEFAULT 0, + max_streak INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now() +); diff --git a/apps/api/db/migrations/007_achievements.sql b/apps/api/db/migrations/007_achievements.sql new file mode 100644 index 0000000..919d055 --- /dev/null +++ b/apps/api/db/migrations/007_achievements.sql @@ -0,0 +1,18 @@ +-- A richer achievement system linking users to specific badges and their metadata +CREATE TABLE achievement_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + badge_class VARCHAR(50) NOT NULL +); + +CREATE TABLE user_achievements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + achievement_type_id UUID REFERENCES achievement_types(id) ON DELETE CASCADE, + earned_at TIMESTAMPTZ DEFAULT now(), + UNIQUE (user_id, achievement_type_id) +); + +CREATE INDEX idx_user_achievements_user_id ON user_achievements(user_id); diff --git a/apps/api/db/migrations/008_activity_log.sql b/apps/api/db/migrations/008_activity_log.sql new file mode 100644 index 0000000..22747d3 --- /dev/null +++ b/apps/api/db/migrations/008_activity_log.sql @@ -0,0 +1,15 @@ +-- Activity log for the recent activity feed on a user's profile +CREATE TABLE activity_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + kind VARCHAR(50) NOT NULL, + text TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_activity_log_user_time ON activity_log(user_id, created_at DESC); + +-- Also add index on sessions and heartbeats as requested in the plan +CREATE INDEX idx_sessions_user_time ON sessions(user_id, start_time); +CREATE INDEX idx_heartbeats_user_time ON heartbeats(user_id, time); diff --git a/apps/api/db/migrations/009_problem_stats_placeholder.sql b/apps/api/db/migrations/009_problem_stats_placeholder.sql new file mode 100644 index 0000000..442a184 --- /dev/null +++ b/apps/api/db/migrations/009_problem_stats_placeholder.sql @@ -0,0 +1,14 @@ +-- Placeholder table for problem-solving stats +-- WARNING: There is currently no problem-solving or contest domain in Seismic. +-- This schema is intentionally introduced only to satisfy the future Profile API +-- and remains unused until such a feature exists. Ratings and contributions are +-- strictly placeholders and not derived from any existing system. +CREATE TABLE user_problem_stats ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + solved_count INTEGER DEFAULT 0, + total_problems INTEGER DEFAULT 0, + attempting_count INTEGER DEFAULT 0, + rating INTEGER DEFAULT 0, + contribution_points INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now() +); diff --git a/apps/api/db/seed_profile.sql b/apps/api/db/seed_profile.sql new file mode 100644 index 0000000..8a2dd1b --- /dev/null +++ b/apps/api/db/seed_profile.sql @@ -0,0 +1,56 @@ +-- Ensure we have a user to attach these to +DO $$ +DECLARE + test_user_id UUID; + ach_id UUID; +BEGIN + -- Get or create a test user + SELECT id INTO test_user_id FROM users WHERE username = 'testuser' LIMIT 1; + + IF NOT FOUND THEN + INSERT INTO users (username, email, display_name, first_name, role, location, university, bio, time_zone, gender, languages) + VALUES ('testuser', 'testuser@example.com', 'Test User', 'Test', 'Developer', 'San Francisco, CA', 'Tech University', 'Just a test user building cool things.', 'America/Los_Angeles', 'Male', ARRAY['Go', 'TypeScript', 'SQL']) + RETURNING id INTO test_user_id; + END IF; + + -- Upsert Profile Stats + INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak) + VALUES (test_user_id, 360000, 45, 12, 15) + ON CONFLICT (user_id) DO UPDATE SET + total_coding_seconds = EXCLUDED.total_coding_seconds, + total_active_days = EXCLUDED.total_active_days, + current_streak = EXCLUDED.current_streak, + max_streak = EXCLUDED.max_streak; + + -- Upsert Problem Stats (Placeholder) + INSERT INTO user_problem_stats (user_id, solved_count, total_problems, attempting_count, rating, contribution_points) + VALUES (test_user_id, 150, 500, 3, 1450, 200) + ON CONFLICT (user_id) DO UPDATE SET + solved_count = EXCLUDED.solved_count, + total_problems = EXCLUDED.total_problems, + attempting_count = EXCLUDED.attempting_count, + rating = EXCLUDED.rating, + contribution_points = EXCLUDED.contribution_points; + + -- Seed Achievement Types + INSERT INTO achievement_types (key, title, description, badge_class) + VALUES + ('first_blood', 'First Blood', 'Logged your first session.', 'gold'), + ('night_owl', 'Night Owl', 'Coded past 2 AM.', 'silver'), + ('streak_7', '7-Day Streak', 'Maintained a 7 day streak.', 'bronze') + ON CONFLICT (key) DO NOTHING; + + -- Give the user an achievement + SELECT id INTO ach_id FROM achievement_types WHERE key = 'first_blood' LIMIT 1; + IF ach_id IS NOT NULL THEN + INSERT INTO user_achievements (user_id, achievement_type_id) + VALUES (test_user_id, ach_id) + ON CONFLICT (user_id, achievement_type_id) DO NOTHING; + END IF; + + -- Seed Activity Log + INSERT INTO activity_log (user_id, kind, text) + VALUES (test_user_id, 'achievement', 'Earned the First Blood badge!'), + (test_user_id, 'streak', 'Reached a 12 day streak!'); + +END $$; diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index b2f5bc0..88a3fea 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -548,8 +548,7 @@ } } }, -<<<<<<< HEAD -======= + "/api/profile": { "get": { "security": [ @@ -581,7 +580,7 @@ } } }, ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) + "/api/settings/account": { "post": { "security": [ @@ -849,8 +848,7 @@ } } }, -<<<<<<< HEAD -======= + "models.Achievement": { "type": "object", "properties": { @@ -885,7 +883,7 @@ } } }, ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) + "models.Heartbeat": { "type": "object", "properties": { @@ -923,8 +921,7 @@ "type": "string" } } -<<<<<<< HEAD -======= + }, "models.HeatmapCell": { "type": "object", @@ -1062,7 +1059,7 @@ "type": "string" } } ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) + } }, "securityDefinitions": { diff --git a/apps/api/handlers/auth.go b/apps/api/handlers/auth.go index b1f96f9..42c0484 100644 --- a/apps/api/handlers/auth.go +++ b/apps/api/handlers/auth.go @@ -237,11 +237,45 @@ func (h *AuthHandler) RefreshAccessToken(c *fiber.Ctx) error { return helpers.Error(c, fiber.StatusInternalServerError, "Something went wrong") } + // Clean up expired tokens periodically (non-blocking) + _, _ = models.CleanupExpiredRefreshTokens(ctx, h.Pool) + return helpers.Success(c, "Token refreshed", fiber.Map{ "accessToken": accessToken, }) } +// Logout godoc +// @Summary Log out current user +// @Description Revokes the refresh token cookie and clears it from the browser. +// @Tags auth +// @Produce json +// @Success 200 {object} helpers.APIResponse +// @Router /api/auth/logout [post] +func (h *AuthHandler) Logout(c *fiber.Ctx) error { + rawToken := c.Cookies("refresh_token") + if rawToken != "" { + ctx := c.Context() + stored, err := models.FindValidRefreshToken(ctx, h.Pool, rawToken) + if err == nil && stored != nil { + _ = models.RevokeRefreshToken(ctx, h.Pool, stored.ID) + } + } + + // Clear the cookie + c.Cookie(&fiber.Cookie{ + Name: "refresh_token", + Value: "", + HTTPOnly: true, + Secure: strings.HasPrefix(h.EmailCfg.AppURL, "https"), + SameSite: "None", + Path: "/api/auth", + Expires: time.Now().Add(-1 * time.Hour), + }) + + return helpers.Success(c, "Logged out", nil) +} + // setRefreshTokenCookie sets the refresh token as an httpOnly // cookie so client-side JavaScript can never read it directly. // Secure is disabled for local http testing, enabled in diff --git a/apps/api/models/profile.go b/apps/api/models/profile.go new file mode 100644 index 0000000..b7e5d82 --- /dev/null +++ b/apps/api/models/profile.go @@ -0,0 +1,109 @@ +package models + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// UserProfileStats caches aggregate coding data for the profile page +type UserProfileStats struct { + UserID string `json:"userId"` + TotalCodingSeconds int64 `json:"totalCodingSeconds"` + TotalActiveDays int `json:"totalActiveDays"` + CurrentStreak int `json:"currentStreak"` + MaxStreak int `json:"maxStreak"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// GetProfileStats retrieves cached profile stats for a user. +func GetProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) (*UserProfileStats, error) { + var s UserProfileStats + err := pool.QueryRow(ctx, ` + SELECT user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at + FROM user_profile_stats + WHERE user_id = $1 + `, userID).Scan(&s.UserID, &s.TotalCodingSeconds, &s.TotalActiveDays, &s.CurrentStreak, &s.MaxStreak, &s.UpdatedAt) + if err != nil { + return nil, err + } + return &s, nil +} + +// UpdateProfileStats recalculates aggregate stats from the sessions table +// and upserts them into user_profile_stats for the given user. +func UpdateProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) error { + var totalSeconds int64 + _ = pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(duration_seconds), 0) FROM sessions WHERE user_id = $1 + `, userID).Scan(&totalSeconds) + + var totalDays int + _ = pool.QueryRow(ctx, ` + SELECT COUNT(DISTINCT start_time::date) FROM sessions WHERE user_id = $1 + `, userID).Scan(&totalDays) + + currentStreak, _ := GetCurrentStreak(ctx, pool, userID) + + var prevMax int + _ = pool.QueryRow(ctx, ` + SELECT COALESCE(max_streak, 0) FROM user_profile_stats WHERE user_id = $1 + `, userID).Scan(&prevMax) + + maxStreak := prevMax + if currentStreak > maxStreak { + maxStreak = currentStreak + } + + _, err := pool.Exec(ctx, ` + INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + total_coding_seconds = EXCLUDED.total_coding_seconds, + total_active_days = EXCLUDED.total_active_days, + current_streak = EXCLUDED.current_streak, + max_streak = GREATEST(user_profile_stats.max_streak, EXCLUDED.current_streak), + updated_at = EXCLUDED.updated_at + `, userID, totalSeconds, totalDays, currentStreak, maxStreak) + + return err +} + +// AchievementType represents a badge that can be earned +type AchievementType struct { + ID string `json:"id"` + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + BadgeClass string `json:"badgeClass"` +} + +// UserAchievement tracks which user earned which badge and when +type UserAchievement struct { + ID string `json:"id"` + UserID string `json:"userId"` + AchievementTypeID string `json:"achievementTypeId"` + EarnedAt time.Time `json:"earnedAt"` +} + +// ActivityLog represents an entry in the user's recent activity feed +type ActivityLog struct { + ID string `json:"id"` + UserID string `json:"userId"` + Kind string `json:"kind"` + Text string `json:"text"` + Metadata any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// UserProblemStats is a placeholder for future problem-solving features +type UserProblemStats struct { + UserID string `json:"userId"` + SolvedCount int `json:"solvedCount"` + TotalProblems int `json:"totalProblems"` + AttemptingCount int `json:"attemptingCount"` + Rating int `json:"rating"` + ContributionPoints int `json:"contributionPoints"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/apps/api/models/refresh_token.go b/apps/api/models/refresh_token.go index 169d2ea..3c5e6e4 100644 --- a/apps/api/models/refresh_token.go +++ b/apps/api/models/refresh_token.go @@ -95,3 +95,25 @@ func RevokeRefreshToken(ctx context.Context, pool *pgxpool.Pool, id string) erro `, id) return err } + +// RevokeAllUserRefreshTokens revokes every non-revoked token for a user. +// Useful when rotating all tokens (e.g. password change, account compromise). +func RevokeAllUserRefreshTokens(ctx context.Context, pool *pgxpool.Pool, userID string) error { + _, err := pool.Exec(ctx, ` + UPDATE refresh_tokens SET revoked = true + WHERE user_id = $1 AND revoked = false + `, userID) + return err +} + +// CleanupExpiredRefreshTokens deletes all expired tokens from the database. +// Should be called periodically to prevent the table from growing unbounded. +func CleanupExpiredRefreshTokens(ctx context.Context, pool *pgxpool.Pool) (int64, error) { + res, err := pool.Exec(ctx, ` + DELETE FROM refresh_tokens WHERE expires_at < NOW() + `) + if err != nil { + return 0, err + } + return res.RowsAffected(), nil +} diff --git a/apps/api/models/user.go b/apps/api/models/user.go index 7059d25..f26fcf3 100644 --- a/apps/api/models/user.go +++ b/apps/api/models/user.go @@ -15,11 +15,20 @@ type User struct { Username string `json:"username"` Email string `json:"email"` APIKey string `json:"apiKey"` + DisplayName *string `json:"displayName"` + FirstName *string `json:"firstName"` + Role *string `json:"role"` + Location *string `json:"location"` + University *string `json:"university"` Country *string `json:"country"` Bio *string `json:"bio"` Website *string `json:"website"` + TimeZone *string `json:"timeZone"` + Gender *string `json:"gender"` + Languages []string `json:"languages"` AvatarURL *string `json:"avatarUrl"` AvatarPublicID *string `json:"-"` + LastActiveAt *time.Time `json:"lastActiveAt"` CreatedAt time.Time `json:"createdAt"` DeletedAt *time.Time `json:"-"` } @@ -32,13 +41,13 @@ func FindUserByEmail(ctx context.Context, pool *pgxpool.Pool, email string) (*Us var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE email = $1 AND deleted_at IS NULL `, email).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -58,13 +67,13 @@ func FindUserByUsername(ctx context.Context, pool *pgxpool.Pool, username string var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE username = $1 AND deleted_at IS NULL `, username).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -82,13 +91,13 @@ func FindUserByID(ctx context.Context, pool *pgxpool.Pool, id string) (*User, er var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE id = $1 AND deleted_at IS NULL `, id).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -107,13 +116,13 @@ func FindUserByAPIKey(ctx context.Context, pool *pgxpool.Pool, apiKey string) (* var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE api_key = $1 AND deleted_at IS NULL `, apiKey).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -134,11 +143,11 @@ func CreateUser(ctx context.Context, pool *pgxpool.Pool, email, username, displa err := pool.QueryRow(ctx, ` INSERT INTO users (email, username, display_name) VALUES ($1, $2, $3) - RETURNING id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + RETURNING id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at `, email, username, displayName).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if err != nil { diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index e299966..0cda073 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -15,6 +15,7 @@ func Setup(app *fiber.App, authHandler *handlers.AuthHandler, heartbeatHandler * auth.Get("/verify", authHandler.VerifyMagicLink) auth.Post("/complete-signup", authHandler.CompleteSignup) auth.Post("/refresh", authHandler.RefreshAccessToken) + auth.Post("/logout", authHandler.Logout) auth.Get("/apikey", middleware.RequireAuth(jwtSecret), authHandler.GetAPIKey) auth.Post("/apikey/regenerate", middleware.RequireAuth(jwtSecret), authHandler.RegenerateAPIKey) auth.Post("/magic-link", middleware.AuthRateLimit(), authHandler.RequestMagicLink) diff --git a/apps/api/services/session_processor.go b/apps/api/services/session_processor.go index bb29401..71e9e0b 100644 --- a/apps/api/services/session_processor.go +++ b/apps/api/services/session_processor.go @@ -6,6 +6,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" + "github.com/majoramari/seismic/apps/api/models" ) const sessionGap = 2 * time.Minute @@ -65,6 +66,17 @@ func ProcessSessions(ctx context.Context, pool *pgxpool.Pool) error { } } + // Update cached profile stats for affected users + userIDs := make(map[string]struct{}) + for _, h := range heartbeats { + userIDs[h.UserID] = struct{}{} + } + for uid := range userIDs { + if err := models.UpdateProfileStats(ctx, pool, uid); err != nil { + log.Printf("Session processor: failed to update profile stats for user %s: %v\n", uid, err) + } + } + log.Printf("Session processor: created %d sessions from %d heartbeats\n", len(sessions), len(heartbeats)) return nil } From 7b21b2f0e60b348b5a3b9648759695b9097fb052 Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Tue, 7 Jul 2026 23:37:46 +0300 Subject: [PATCH 3/7] Revert "updates to profile schema, refresh tokens, and stats caching" This reverts commit 670b0eb0e3f81d0355a74dd8d9694a82dfeb9649. --- apps/api/db/migrations/005_extend_users.sql | 10 -- apps/api/db/migrations/006_profile_stats.sql | 10 -- apps/api/db/migrations/007_achievements.sql | 18 --- apps/api/db/migrations/008_activity_log.sql | 15 --- .../009_problem_stats_placeholder.sql | 14 --- apps/api/db/seed_profile.sql | 56 --------- apps/api/docs/swagger.json | 15 ++- apps/api/handlers/auth.go | 34 ------ apps/api/models/profile.go | 109 ------------------ apps/api/models/refresh_token.go | 22 ---- apps/api/models/user.go | 49 ++++---- apps/api/routes/routes.go | 1 - apps/api/services/session_processor.go | 12 -- 13 files changed, 29 insertions(+), 336 deletions(-) delete mode 100644 apps/api/db/migrations/005_extend_users.sql delete mode 100644 apps/api/db/migrations/006_profile_stats.sql delete mode 100644 apps/api/db/migrations/007_achievements.sql delete mode 100644 apps/api/db/migrations/008_activity_log.sql delete mode 100644 apps/api/db/migrations/009_problem_stats_placeholder.sql delete mode 100644 apps/api/db/seed_profile.sql delete mode 100644 apps/api/models/profile.go diff --git a/apps/api/db/migrations/005_extend_users.sql b/apps/api/db/migrations/005_extend_users.sql deleted file mode 100644 index 784a010..0000000 --- a/apps/api/db/migrations/005_extend_users.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Extends the users table with profile-specific fields not yet present -ALTER TABLE users - ADD COLUMN first_name VARCHAR(50), - ADD COLUMN role VARCHAR(50), - ADD COLUMN location VARCHAR(100), - ADD COLUMN university VARCHAR(100), - ADD COLUMN time_zone VARCHAR(50), - ADD COLUMN last_active_at TIMESTAMPTZ, - ADD COLUMN gender VARCHAR(50), - ADD COLUMN languages TEXT[]; -- Simple text array for display purposes on the profile diff --git a/apps/api/db/migrations/006_profile_stats.sql b/apps/api/db/migrations/006_profile_stats.sql deleted file mode 100644 index 96229b6..0000000 --- a/apps/api/db/migrations/006_profile_stats.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Cached aggregate coding data to avoid heavy calculations on every profile view --- This will be populated and updated by the background session-processing job -CREATE TABLE user_profile_stats ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - total_coding_seconds BIGINT DEFAULT 0, - total_active_days INTEGER DEFAULT 0, - current_streak INTEGER DEFAULT 0, - max_streak INTEGER DEFAULT 0, - updated_at TIMESTAMPTZ DEFAULT now() -); diff --git a/apps/api/db/migrations/007_achievements.sql b/apps/api/db/migrations/007_achievements.sql deleted file mode 100644 index 919d055..0000000 --- a/apps/api/db/migrations/007_achievements.sql +++ /dev/null @@ -1,18 +0,0 @@ --- A richer achievement system linking users to specific badges and their metadata -CREATE TABLE achievement_types ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - key VARCHAR(50) UNIQUE NOT NULL, - title VARCHAR(100) NOT NULL, - description TEXT NOT NULL, - badge_class VARCHAR(50) NOT NULL -); - -CREATE TABLE user_achievements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - achievement_type_id UUID REFERENCES achievement_types(id) ON DELETE CASCADE, - earned_at TIMESTAMPTZ DEFAULT now(), - UNIQUE (user_id, achievement_type_id) -); - -CREATE INDEX idx_user_achievements_user_id ON user_achievements(user_id); diff --git a/apps/api/db/migrations/008_activity_log.sql b/apps/api/db/migrations/008_activity_log.sql deleted file mode 100644 index 22747d3..0000000 --- a/apps/api/db/migrations/008_activity_log.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Activity log for the recent activity feed on a user's profile -CREATE TABLE activity_log ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - kind VARCHAR(50) NOT NULL, - text TEXT NOT NULL, - metadata JSONB, - created_at TIMESTAMPTZ DEFAULT now() -); - -CREATE INDEX idx_activity_log_user_time ON activity_log(user_id, created_at DESC); - --- Also add index on sessions and heartbeats as requested in the plan -CREATE INDEX idx_sessions_user_time ON sessions(user_id, start_time); -CREATE INDEX idx_heartbeats_user_time ON heartbeats(user_id, time); diff --git a/apps/api/db/migrations/009_problem_stats_placeholder.sql b/apps/api/db/migrations/009_problem_stats_placeholder.sql deleted file mode 100644 index 442a184..0000000 --- a/apps/api/db/migrations/009_problem_stats_placeholder.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Placeholder table for problem-solving stats --- WARNING: There is currently no problem-solving or contest domain in Seismic. --- This schema is intentionally introduced only to satisfy the future Profile API --- and remains unused until such a feature exists. Ratings and contributions are --- strictly placeholders and not derived from any existing system. -CREATE TABLE user_problem_stats ( - user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - solved_count INTEGER DEFAULT 0, - total_problems INTEGER DEFAULT 0, - attempting_count INTEGER DEFAULT 0, - rating INTEGER DEFAULT 0, - contribution_points INTEGER DEFAULT 0, - updated_at TIMESTAMPTZ DEFAULT now() -); diff --git a/apps/api/db/seed_profile.sql b/apps/api/db/seed_profile.sql deleted file mode 100644 index 8a2dd1b..0000000 --- a/apps/api/db/seed_profile.sql +++ /dev/null @@ -1,56 +0,0 @@ --- Ensure we have a user to attach these to -DO $$ -DECLARE - test_user_id UUID; - ach_id UUID; -BEGIN - -- Get or create a test user - SELECT id INTO test_user_id FROM users WHERE username = 'testuser' LIMIT 1; - - IF NOT FOUND THEN - INSERT INTO users (username, email, display_name, first_name, role, location, university, bio, time_zone, gender, languages) - VALUES ('testuser', 'testuser@example.com', 'Test User', 'Test', 'Developer', 'San Francisco, CA', 'Tech University', 'Just a test user building cool things.', 'America/Los_Angeles', 'Male', ARRAY['Go', 'TypeScript', 'SQL']) - RETURNING id INTO test_user_id; - END IF; - - -- Upsert Profile Stats - INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak) - VALUES (test_user_id, 360000, 45, 12, 15) - ON CONFLICT (user_id) DO UPDATE SET - total_coding_seconds = EXCLUDED.total_coding_seconds, - total_active_days = EXCLUDED.total_active_days, - current_streak = EXCLUDED.current_streak, - max_streak = EXCLUDED.max_streak; - - -- Upsert Problem Stats (Placeholder) - INSERT INTO user_problem_stats (user_id, solved_count, total_problems, attempting_count, rating, contribution_points) - VALUES (test_user_id, 150, 500, 3, 1450, 200) - ON CONFLICT (user_id) DO UPDATE SET - solved_count = EXCLUDED.solved_count, - total_problems = EXCLUDED.total_problems, - attempting_count = EXCLUDED.attempting_count, - rating = EXCLUDED.rating, - contribution_points = EXCLUDED.contribution_points; - - -- Seed Achievement Types - INSERT INTO achievement_types (key, title, description, badge_class) - VALUES - ('first_blood', 'First Blood', 'Logged your first session.', 'gold'), - ('night_owl', 'Night Owl', 'Coded past 2 AM.', 'silver'), - ('streak_7', '7-Day Streak', 'Maintained a 7 day streak.', 'bronze') - ON CONFLICT (key) DO NOTHING; - - -- Give the user an achievement - SELECT id INTO ach_id FROM achievement_types WHERE key = 'first_blood' LIMIT 1; - IF ach_id IS NOT NULL THEN - INSERT INTO user_achievements (user_id, achievement_type_id) - VALUES (test_user_id, ach_id) - ON CONFLICT (user_id, achievement_type_id) DO NOTHING; - END IF; - - -- Seed Activity Log - INSERT INTO activity_log (user_id, kind, text) - VALUES (test_user_id, 'achievement', 'Earned the First Blood badge!'), - (test_user_id, 'streak', 'Reached a 12 day streak!'); - -END $$; diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index 88a3fea..b2f5bc0 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -548,7 +548,8 @@ } } }, - +<<<<<<< HEAD +======= "/api/profile": { "get": { "security": [ @@ -580,7 +581,7 @@ } } }, - +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "/api/settings/account": { "post": { "security": [ @@ -848,7 +849,8 @@ } } }, - +<<<<<<< HEAD +======= "models.Achievement": { "type": "object", "properties": { @@ -883,7 +885,7 @@ } } }, - +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "models.Heartbeat": { "type": "object", "properties": { @@ -921,7 +923,8 @@ "type": "string" } } - +<<<<<<< HEAD +======= }, "models.HeatmapCell": { "type": "object", @@ -1059,7 +1062,7 @@ "type": "string" } } - +>>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) } }, "securityDefinitions": { diff --git a/apps/api/handlers/auth.go b/apps/api/handlers/auth.go index 42c0484..b1f96f9 100644 --- a/apps/api/handlers/auth.go +++ b/apps/api/handlers/auth.go @@ -237,45 +237,11 @@ func (h *AuthHandler) RefreshAccessToken(c *fiber.Ctx) error { return helpers.Error(c, fiber.StatusInternalServerError, "Something went wrong") } - // Clean up expired tokens periodically (non-blocking) - _, _ = models.CleanupExpiredRefreshTokens(ctx, h.Pool) - return helpers.Success(c, "Token refreshed", fiber.Map{ "accessToken": accessToken, }) } -// Logout godoc -// @Summary Log out current user -// @Description Revokes the refresh token cookie and clears it from the browser. -// @Tags auth -// @Produce json -// @Success 200 {object} helpers.APIResponse -// @Router /api/auth/logout [post] -func (h *AuthHandler) Logout(c *fiber.Ctx) error { - rawToken := c.Cookies("refresh_token") - if rawToken != "" { - ctx := c.Context() - stored, err := models.FindValidRefreshToken(ctx, h.Pool, rawToken) - if err == nil && stored != nil { - _ = models.RevokeRefreshToken(ctx, h.Pool, stored.ID) - } - } - - // Clear the cookie - c.Cookie(&fiber.Cookie{ - Name: "refresh_token", - Value: "", - HTTPOnly: true, - Secure: strings.HasPrefix(h.EmailCfg.AppURL, "https"), - SameSite: "None", - Path: "/api/auth", - Expires: time.Now().Add(-1 * time.Hour), - }) - - return helpers.Success(c, "Logged out", nil) -} - // setRefreshTokenCookie sets the refresh token as an httpOnly // cookie so client-side JavaScript can never read it directly. // Secure is disabled for local http testing, enabled in diff --git a/apps/api/models/profile.go b/apps/api/models/profile.go deleted file mode 100644 index b7e5d82..0000000 --- a/apps/api/models/profile.go +++ /dev/null @@ -1,109 +0,0 @@ -package models - -import ( - "context" - "time" - - "github.com/jackc/pgx/v5/pgxpool" -) - -// UserProfileStats caches aggregate coding data for the profile page -type UserProfileStats struct { - UserID string `json:"userId"` - TotalCodingSeconds int64 `json:"totalCodingSeconds"` - TotalActiveDays int `json:"totalActiveDays"` - CurrentStreak int `json:"currentStreak"` - MaxStreak int `json:"maxStreak"` - UpdatedAt time.Time `json:"updatedAt"` -} - -// GetProfileStats retrieves cached profile stats for a user. -func GetProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) (*UserProfileStats, error) { - var s UserProfileStats - err := pool.QueryRow(ctx, ` - SELECT user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at - FROM user_profile_stats - WHERE user_id = $1 - `, userID).Scan(&s.UserID, &s.TotalCodingSeconds, &s.TotalActiveDays, &s.CurrentStreak, &s.MaxStreak, &s.UpdatedAt) - if err != nil { - return nil, err - } - return &s, nil -} - -// UpdateProfileStats recalculates aggregate stats from the sessions table -// and upserts them into user_profile_stats for the given user. -func UpdateProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) error { - var totalSeconds int64 - _ = pool.QueryRow(ctx, ` - SELECT COALESCE(SUM(duration_seconds), 0) FROM sessions WHERE user_id = $1 - `, userID).Scan(&totalSeconds) - - var totalDays int - _ = pool.QueryRow(ctx, ` - SELECT COUNT(DISTINCT start_time::date) FROM sessions WHERE user_id = $1 - `, userID).Scan(&totalDays) - - currentStreak, _ := GetCurrentStreak(ctx, pool, userID) - - var prevMax int - _ = pool.QueryRow(ctx, ` - SELECT COALESCE(max_streak, 0) FROM user_profile_stats WHERE user_id = $1 - `, userID).Scan(&prevMax) - - maxStreak := prevMax - if currentStreak > maxStreak { - maxStreak = currentStreak - } - - _, err := pool.Exec(ctx, ` - INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at) - VALUES ($1, $2, $3, $4, $5, NOW()) - ON CONFLICT (user_id) DO UPDATE SET - total_coding_seconds = EXCLUDED.total_coding_seconds, - total_active_days = EXCLUDED.total_active_days, - current_streak = EXCLUDED.current_streak, - max_streak = GREATEST(user_profile_stats.max_streak, EXCLUDED.current_streak), - updated_at = EXCLUDED.updated_at - `, userID, totalSeconds, totalDays, currentStreak, maxStreak) - - return err -} - -// AchievementType represents a badge that can be earned -type AchievementType struct { - ID string `json:"id"` - Key string `json:"key"` - Title string `json:"title"` - Description string `json:"description"` - BadgeClass string `json:"badgeClass"` -} - -// UserAchievement tracks which user earned which badge and when -type UserAchievement struct { - ID string `json:"id"` - UserID string `json:"userId"` - AchievementTypeID string `json:"achievementTypeId"` - EarnedAt time.Time `json:"earnedAt"` -} - -// ActivityLog represents an entry in the user's recent activity feed -type ActivityLog struct { - ID string `json:"id"` - UserID string `json:"userId"` - Kind string `json:"kind"` - Text string `json:"text"` - Metadata any `json:"metadata,omitempty"` - CreatedAt time.Time `json:"createdAt"` -} - -// UserProblemStats is a placeholder for future problem-solving features -type UserProblemStats struct { - UserID string `json:"userId"` - SolvedCount int `json:"solvedCount"` - TotalProblems int `json:"totalProblems"` - AttemptingCount int `json:"attemptingCount"` - Rating int `json:"rating"` - ContributionPoints int `json:"contributionPoints"` - UpdatedAt time.Time `json:"updatedAt"` -} diff --git a/apps/api/models/refresh_token.go b/apps/api/models/refresh_token.go index 3c5e6e4..169d2ea 100644 --- a/apps/api/models/refresh_token.go +++ b/apps/api/models/refresh_token.go @@ -95,25 +95,3 @@ func RevokeRefreshToken(ctx context.Context, pool *pgxpool.Pool, id string) erro `, id) return err } - -// RevokeAllUserRefreshTokens revokes every non-revoked token for a user. -// Useful when rotating all tokens (e.g. password change, account compromise). -func RevokeAllUserRefreshTokens(ctx context.Context, pool *pgxpool.Pool, userID string) error { - _, err := pool.Exec(ctx, ` - UPDATE refresh_tokens SET revoked = true - WHERE user_id = $1 AND revoked = false - `, userID) - return err -} - -// CleanupExpiredRefreshTokens deletes all expired tokens from the database. -// Should be called periodically to prevent the table from growing unbounded. -func CleanupExpiredRefreshTokens(ctx context.Context, pool *pgxpool.Pool) (int64, error) { - res, err := pool.Exec(ctx, ` - DELETE FROM refresh_tokens WHERE expires_at < NOW() - `) - if err != nil { - return 0, err - } - return res.RowsAffected(), nil -} diff --git a/apps/api/models/user.go b/apps/api/models/user.go index f26fcf3..7059d25 100644 --- a/apps/api/models/user.go +++ b/apps/api/models/user.go @@ -15,20 +15,11 @@ type User struct { Username string `json:"username"` Email string `json:"email"` APIKey string `json:"apiKey"` - DisplayName *string `json:"displayName"` - FirstName *string `json:"firstName"` - Role *string `json:"role"` - Location *string `json:"location"` - University *string `json:"university"` Country *string `json:"country"` Bio *string `json:"bio"` Website *string `json:"website"` - TimeZone *string `json:"timeZone"` - Gender *string `json:"gender"` - Languages []string `json:"languages"` AvatarURL *string `json:"avatarUrl"` AvatarPublicID *string `json:"-"` - LastActiveAt *time.Time `json:"lastActiveAt"` CreatedAt time.Time `json:"createdAt"` DeletedAt *time.Time `json:"-"` } @@ -41,13 +32,13 @@ func FindUserByEmail(ctx context.Context, pool *pgxpool.Pool, email string) (*Us var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + SELECT id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at FROM users WHERE email = $1 AND deleted_at IS NULL `, email).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -67,13 +58,13 @@ func FindUserByUsername(ctx context.Context, pool *pgxpool.Pool, username string var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + SELECT id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at FROM users WHERE username = $1 AND deleted_at IS NULL `, username).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -91,13 +82,13 @@ func FindUserByID(ctx context.Context, pool *pgxpool.Pool, id string) (*User, er var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + SELECT id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at FROM users WHERE id = $1 AND deleted_at IS NULL `, id).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -116,13 +107,13 @@ func FindUserByAPIKey(ctx context.Context, pool *pgxpool.Pool, apiKey string) (* var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + SELECT id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at FROM users WHERE api_key = $1 AND deleted_at IS NULL `, apiKey).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -143,11 +134,11 @@ func CreateUser(ctx context.Context, pool *pgxpool.Pool, email, username, displa err := pool.QueryRow(ctx, ` INSERT INTO users (email, username, display_name) VALUES ($1, $2, $3) - RETURNING id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, - time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at + RETURNING id, username, email, api_key, country, bio, website, + avatar_url, avatar_public_id, created_at, deleted_at `, email, username, displayName).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, - &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, + &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, ) if err != nil { diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index 0cda073..e299966 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -15,7 +15,6 @@ func Setup(app *fiber.App, authHandler *handlers.AuthHandler, heartbeatHandler * auth.Get("/verify", authHandler.VerifyMagicLink) auth.Post("/complete-signup", authHandler.CompleteSignup) auth.Post("/refresh", authHandler.RefreshAccessToken) - auth.Post("/logout", authHandler.Logout) auth.Get("/apikey", middleware.RequireAuth(jwtSecret), authHandler.GetAPIKey) auth.Post("/apikey/regenerate", middleware.RequireAuth(jwtSecret), authHandler.RegenerateAPIKey) auth.Post("/magic-link", middleware.AuthRateLimit(), authHandler.RequestMagicLink) diff --git a/apps/api/services/session_processor.go b/apps/api/services/session_processor.go index 71e9e0b..bb29401 100644 --- a/apps/api/services/session_processor.go +++ b/apps/api/services/session_processor.go @@ -6,7 +6,6 @@ import ( "time" "github.com/jackc/pgx/v5/pgxpool" - "github.com/majoramari/seismic/apps/api/models" ) const sessionGap = 2 * time.Minute @@ -66,17 +65,6 @@ func ProcessSessions(ctx context.Context, pool *pgxpool.Pool) error { } } - // Update cached profile stats for affected users - userIDs := make(map[string]struct{}) - for _, h := range heartbeats { - userIDs[h.UserID] = struct{}{} - } - for uid := range userIDs { - if err := models.UpdateProfileStats(ctx, pool, uid); err != nil { - log.Printf("Session processor: failed to update profile stats for user %s: %v\n", uid, err) - } - } - log.Printf("Session processor: created %d sessions from %d heartbeats\n", len(sessions), len(heartbeats)) return nil } From 71f6907a6b02b73e4da16854d56975700c374e6f Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Wed, 8 Jul 2026 01:06:53 +0300 Subject: [PATCH 4/7] Integrate profile schema and models from upstream --- apps/api/db/migrations/006_extend_users.sql | 10 ++ apps/api/db/migrations/007_profile_stats.sql | 10 ++ apps/api/db/migrations/008_achievements.sql | 18 +++ apps/api/db/migrations/009_activity_log.sql | 15 +++ .../010_problem_stats_placeholder.sql | 14 +++ apps/api/db/seed_profile.sql | 56 +++++++++ apps/api/models/profile.go | 109 ++++++++++++++++++ apps/api/models/user.go | 49 ++++---- 8 files changed, 261 insertions(+), 20 deletions(-) create mode 100644 apps/api/db/migrations/006_extend_users.sql create mode 100644 apps/api/db/migrations/007_profile_stats.sql create mode 100644 apps/api/db/migrations/008_achievements.sql create mode 100644 apps/api/db/migrations/009_activity_log.sql create mode 100644 apps/api/db/migrations/010_problem_stats_placeholder.sql create mode 100644 apps/api/db/seed_profile.sql create mode 100644 apps/api/models/profile.go diff --git a/apps/api/db/migrations/006_extend_users.sql b/apps/api/db/migrations/006_extend_users.sql new file mode 100644 index 0000000..784a010 --- /dev/null +++ b/apps/api/db/migrations/006_extend_users.sql @@ -0,0 +1,10 @@ +-- Extends the users table with profile-specific fields not yet present +ALTER TABLE users + ADD COLUMN first_name VARCHAR(50), + ADD COLUMN role VARCHAR(50), + ADD COLUMN location VARCHAR(100), + ADD COLUMN university VARCHAR(100), + ADD COLUMN time_zone VARCHAR(50), + ADD COLUMN last_active_at TIMESTAMPTZ, + ADD COLUMN gender VARCHAR(50), + ADD COLUMN languages TEXT[]; -- Simple text array for display purposes on the profile diff --git a/apps/api/db/migrations/007_profile_stats.sql b/apps/api/db/migrations/007_profile_stats.sql new file mode 100644 index 0000000..96229b6 --- /dev/null +++ b/apps/api/db/migrations/007_profile_stats.sql @@ -0,0 +1,10 @@ +-- Cached aggregate coding data to avoid heavy calculations on every profile view +-- This will be populated and updated by the background session-processing job +CREATE TABLE user_profile_stats ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + total_coding_seconds BIGINT DEFAULT 0, + total_active_days INTEGER DEFAULT 0, + current_streak INTEGER DEFAULT 0, + max_streak INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now() +); diff --git a/apps/api/db/migrations/008_achievements.sql b/apps/api/db/migrations/008_achievements.sql new file mode 100644 index 0000000..919d055 --- /dev/null +++ b/apps/api/db/migrations/008_achievements.sql @@ -0,0 +1,18 @@ +-- A richer achievement system linking users to specific badges and their metadata +CREATE TABLE achievement_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(100) NOT NULL, + description TEXT NOT NULL, + badge_class VARCHAR(50) NOT NULL +); + +CREATE TABLE user_achievements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + achievement_type_id UUID REFERENCES achievement_types(id) ON DELETE CASCADE, + earned_at TIMESTAMPTZ DEFAULT now(), + UNIQUE (user_id, achievement_type_id) +); + +CREATE INDEX idx_user_achievements_user_id ON user_achievements(user_id); diff --git a/apps/api/db/migrations/009_activity_log.sql b/apps/api/db/migrations/009_activity_log.sql new file mode 100644 index 0000000..22747d3 --- /dev/null +++ b/apps/api/db/migrations/009_activity_log.sql @@ -0,0 +1,15 @@ +-- Activity log for the recent activity feed on a user's profile +CREATE TABLE activity_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + kind VARCHAR(50) NOT NULL, + text TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX idx_activity_log_user_time ON activity_log(user_id, created_at DESC); + +-- Also add index on sessions and heartbeats as requested in the plan +CREATE INDEX idx_sessions_user_time ON sessions(user_id, start_time); +CREATE INDEX idx_heartbeats_user_time ON heartbeats(user_id, time); diff --git a/apps/api/db/migrations/010_problem_stats_placeholder.sql b/apps/api/db/migrations/010_problem_stats_placeholder.sql new file mode 100644 index 0000000..442a184 --- /dev/null +++ b/apps/api/db/migrations/010_problem_stats_placeholder.sql @@ -0,0 +1,14 @@ +-- Placeholder table for problem-solving stats +-- WARNING: There is currently no problem-solving or contest domain in Seismic. +-- This schema is intentionally introduced only to satisfy the future Profile API +-- and remains unused until such a feature exists. Ratings and contributions are +-- strictly placeholders and not derived from any existing system. +CREATE TABLE user_problem_stats ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + solved_count INTEGER DEFAULT 0, + total_problems INTEGER DEFAULT 0, + attempting_count INTEGER DEFAULT 0, + rating INTEGER DEFAULT 0, + contribution_points INTEGER DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT now() +); diff --git a/apps/api/db/seed_profile.sql b/apps/api/db/seed_profile.sql new file mode 100644 index 0000000..8a2dd1b --- /dev/null +++ b/apps/api/db/seed_profile.sql @@ -0,0 +1,56 @@ +-- Ensure we have a user to attach these to +DO $$ +DECLARE + test_user_id UUID; + ach_id UUID; +BEGIN + -- Get or create a test user + SELECT id INTO test_user_id FROM users WHERE username = 'testuser' LIMIT 1; + + IF NOT FOUND THEN + INSERT INTO users (username, email, display_name, first_name, role, location, university, bio, time_zone, gender, languages) + VALUES ('testuser', 'testuser@example.com', 'Test User', 'Test', 'Developer', 'San Francisco, CA', 'Tech University', 'Just a test user building cool things.', 'America/Los_Angeles', 'Male', ARRAY['Go', 'TypeScript', 'SQL']) + RETURNING id INTO test_user_id; + END IF; + + -- Upsert Profile Stats + INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak) + VALUES (test_user_id, 360000, 45, 12, 15) + ON CONFLICT (user_id) DO UPDATE SET + total_coding_seconds = EXCLUDED.total_coding_seconds, + total_active_days = EXCLUDED.total_active_days, + current_streak = EXCLUDED.current_streak, + max_streak = EXCLUDED.max_streak; + + -- Upsert Problem Stats (Placeholder) + INSERT INTO user_problem_stats (user_id, solved_count, total_problems, attempting_count, rating, contribution_points) + VALUES (test_user_id, 150, 500, 3, 1450, 200) + ON CONFLICT (user_id) DO UPDATE SET + solved_count = EXCLUDED.solved_count, + total_problems = EXCLUDED.total_problems, + attempting_count = EXCLUDED.attempting_count, + rating = EXCLUDED.rating, + contribution_points = EXCLUDED.contribution_points; + + -- Seed Achievement Types + INSERT INTO achievement_types (key, title, description, badge_class) + VALUES + ('first_blood', 'First Blood', 'Logged your first session.', 'gold'), + ('night_owl', 'Night Owl', 'Coded past 2 AM.', 'silver'), + ('streak_7', '7-Day Streak', 'Maintained a 7 day streak.', 'bronze') + ON CONFLICT (key) DO NOTHING; + + -- Give the user an achievement + SELECT id INTO ach_id FROM achievement_types WHERE key = 'first_blood' LIMIT 1; + IF ach_id IS NOT NULL THEN + INSERT INTO user_achievements (user_id, achievement_type_id) + VALUES (test_user_id, ach_id) + ON CONFLICT (user_id, achievement_type_id) DO NOTHING; + END IF; + + -- Seed Activity Log + INSERT INTO activity_log (user_id, kind, text) + VALUES (test_user_id, 'achievement', 'Earned the First Blood badge!'), + (test_user_id, 'streak', 'Reached a 12 day streak!'); + +END $$; diff --git a/apps/api/models/profile.go b/apps/api/models/profile.go new file mode 100644 index 0000000..b7e5d82 --- /dev/null +++ b/apps/api/models/profile.go @@ -0,0 +1,109 @@ +package models + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// UserProfileStats caches aggregate coding data for the profile page +type UserProfileStats struct { + UserID string `json:"userId"` + TotalCodingSeconds int64 `json:"totalCodingSeconds"` + TotalActiveDays int `json:"totalActiveDays"` + CurrentStreak int `json:"currentStreak"` + MaxStreak int `json:"maxStreak"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// GetProfileStats retrieves cached profile stats for a user. +func GetProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) (*UserProfileStats, error) { + var s UserProfileStats + err := pool.QueryRow(ctx, ` + SELECT user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at + FROM user_profile_stats + WHERE user_id = $1 + `, userID).Scan(&s.UserID, &s.TotalCodingSeconds, &s.TotalActiveDays, &s.CurrentStreak, &s.MaxStreak, &s.UpdatedAt) + if err != nil { + return nil, err + } + return &s, nil +} + +// UpdateProfileStats recalculates aggregate stats from the sessions table +// and upserts them into user_profile_stats for the given user. +func UpdateProfileStats(ctx context.Context, pool *pgxpool.Pool, userID string) error { + var totalSeconds int64 + _ = pool.QueryRow(ctx, ` + SELECT COALESCE(SUM(duration_seconds), 0) FROM sessions WHERE user_id = $1 + `, userID).Scan(&totalSeconds) + + var totalDays int + _ = pool.QueryRow(ctx, ` + SELECT COUNT(DISTINCT start_time::date) FROM sessions WHERE user_id = $1 + `, userID).Scan(&totalDays) + + currentStreak, _ := GetCurrentStreak(ctx, pool, userID) + + var prevMax int + _ = pool.QueryRow(ctx, ` + SELECT COALESCE(max_streak, 0) FROM user_profile_stats WHERE user_id = $1 + `, userID).Scan(&prevMax) + + maxStreak := prevMax + if currentStreak > maxStreak { + maxStreak = currentStreak + } + + _, err := pool.Exec(ctx, ` + INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + total_coding_seconds = EXCLUDED.total_coding_seconds, + total_active_days = EXCLUDED.total_active_days, + current_streak = EXCLUDED.current_streak, + max_streak = GREATEST(user_profile_stats.max_streak, EXCLUDED.current_streak), + updated_at = EXCLUDED.updated_at + `, userID, totalSeconds, totalDays, currentStreak, maxStreak) + + return err +} + +// AchievementType represents a badge that can be earned +type AchievementType struct { + ID string `json:"id"` + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + BadgeClass string `json:"badgeClass"` +} + +// UserAchievement tracks which user earned which badge and when +type UserAchievement struct { + ID string `json:"id"` + UserID string `json:"userId"` + AchievementTypeID string `json:"achievementTypeId"` + EarnedAt time.Time `json:"earnedAt"` +} + +// ActivityLog represents an entry in the user's recent activity feed +type ActivityLog struct { + ID string `json:"id"` + UserID string `json:"userId"` + Kind string `json:"kind"` + Text string `json:"text"` + Metadata any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// UserProblemStats is a placeholder for future problem-solving features +type UserProblemStats struct { + UserID string `json:"userId"` + SolvedCount int `json:"solvedCount"` + TotalProblems int `json:"totalProblems"` + AttemptingCount int `json:"attemptingCount"` + Rating int `json:"rating"` + ContributionPoints int `json:"contributionPoints"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/apps/api/models/user.go b/apps/api/models/user.go index 7059d25..f26fcf3 100644 --- a/apps/api/models/user.go +++ b/apps/api/models/user.go @@ -15,11 +15,20 @@ type User struct { Username string `json:"username"` Email string `json:"email"` APIKey string `json:"apiKey"` + DisplayName *string `json:"displayName"` + FirstName *string `json:"firstName"` + Role *string `json:"role"` + Location *string `json:"location"` + University *string `json:"university"` Country *string `json:"country"` Bio *string `json:"bio"` Website *string `json:"website"` + TimeZone *string `json:"timeZone"` + Gender *string `json:"gender"` + Languages []string `json:"languages"` AvatarURL *string `json:"avatarUrl"` AvatarPublicID *string `json:"-"` + LastActiveAt *time.Time `json:"lastActiveAt"` CreatedAt time.Time `json:"createdAt"` DeletedAt *time.Time `json:"-"` } @@ -32,13 +41,13 @@ func FindUserByEmail(ctx context.Context, pool *pgxpool.Pool, email string) (*Us var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE email = $1 AND deleted_at IS NULL `, email).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -58,13 +67,13 @@ func FindUserByUsername(ctx context.Context, pool *pgxpool.Pool, username string var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE username = $1 AND deleted_at IS NULL `, username).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -82,13 +91,13 @@ func FindUserByID(ctx context.Context, pool *pgxpool.Pool, id string) (*User, er var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE id = $1 AND deleted_at IS NULL `, id).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -107,13 +116,13 @@ func FindUserByAPIKey(ctx context.Context, pool *pgxpool.Pool, apiKey string) (* var u User err := pool.QueryRow(ctx, ` - SELECT id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + SELECT id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at FROM users WHERE api_key = $1 AND deleted_at IS NULL `, apiKey).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if errors.Is(err, pgx.ErrNoRows) { @@ -134,11 +143,11 @@ func CreateUser(ctx context.Context, pool *pgxpool.Pool, email, username, displa err := pool.QueryRow(ctx, ` INSERT INTO users (email, username, display_name) VALUES ($1, $2, $3) - RETURNING id, username, email, api_key, country, bio, website, - avatar_url, avatar_public_id, created_at, deleted_at + RETURNING id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, + time_zone, gender, languages, avatar_url, avatar_public_id, last_active_at, created_at, deleted_at `, email, username, displayName).Scan( - &u.ID, &u.Username, &u.Email, &u.APIKey, &u.Country, &u.Bio, - &u.Website, &u.AvatarURL, &u.AvatarPublicID, &u.CreatedAt, &u.DeletedAt, + &u.ID, &u.Username, &u.Email, &u.APIKey, &u.DisplayName, &u.FirstName, &u.Role, &u.Location, &u.University, &u.Country, &u.Bio, + &u.Website, &u.TimeZone, &u.Gender, &u.Languages, &u.AvatarURL, &u.AvatarPublicID, &u.LastActiveAt, &u.CreatedAt, &u.DeletedAt, ) if err != nil { From 73a88f75f5c9f17986be7de534ef5d6770704358 Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Wed, 8 Jul 2026 09:43:43 +0300 Subject: [PATCH 5/7] Add seed dummy data and update dependencies --- .gitignore | 1 + apps/api/db/seed_dummy_data.sql | 178 ++++++++++++++++++++++++++++++++ apps/web/bun.lock | 67 ++---------- apps/web/package.json | 9 +- 4 files changed, 187 insertions(+), 68 deletions(-) create mode 100644 apps/api/db/seed_dummy_data.sql diff --git a/.gitignore b/.gitignore index b6b373b..20083c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Environment variables .env .env.local +.env.bak **/.env # Dependencies diff --git a/apps/api/db/seed_dummy_data.sql b/apps/api/db/seed_dummy_data.sql new file mode 100644 index 0000000..a71eba8 --- /dev/null +++ b/apps/api/db/seed_dummy_data.sql @@ -0,0 +1,178 @@ +-- =============================================================== +-- Seismic Project: Dummy Data Seed Script +-- Populates 4 users with full profiles and all related tables. +-- All timestamps are logically coherent. +-- =============================================================== + +DO $$ +DECLARE + u1_id UUID; -- Sarah Chen + u2_id UUID; -- Marcus Johnson + u3_id UUID; -- Elena Rodriguez + u4_id UUID; -- Kenji Tanaka + + a_first_blood UUID; + a_streak_7 UUID; + a_night_owl UUID; + a_century UUID; +BEGIN + + -- ============================================================= + -- 1. USERS + -- ============================================================= + + INSERT INTO users (id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, time_zone, gender, languages, last_active_at, created_at) + VALUES (gen_random_uuid(), 'sarahchen', 'sarah.chen@example.com', gen_random_uuid(), 'Sarah Chen', 'Sarah', 'Researcher', 'Cambridge, MA', 'MIT', 'US', 'Computational seismology researcher. Open-source contributor.', 'https://sarahchen.dev', 'America/New_York', 'Female', ARRAY['Python', 'Julia', 'Rust', 'MATLAB'], '2026-07-07 14:30:00+00', '2025-01-15 08:00:00+00') + RETURNING id INTO u1_id; + + INSERT INTO users (id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, time_zone, gender, languages, last_active_at, created_at) + VALUES (gen_random_uuid(), 'mjohnson', 'marcus.j@example.com', gen_random_uuid(), 'Marcus Johnson', 'Marcus', 'Student', 'Austin, TX', 'University of Texas', 'US', 'CS undergrad exploring high-performance computing and visualization.', 'https://marcusj.io', 'America/Chicago', 'Male', ARRAY['Go', 'TypeScript', 'C++', 'Python'], '2026-07-06 22:15:00+00', '2025-03-22 10:30:00+00') + RETURNING id INTO u2_id; + + INSERT INTO users (id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, time_zone, gender, languages, last_active_at, created_at) + VALUES (gen_random_uuid(), 'elena_r', 'elena.r@example.com', gen_random_uuid(), 'Elena Rodríguez', 'Elena', 'Full-Stack Dev', 'Barcelona', 'Universitat Politècnica de Catalunya', 'ES', 'Building developer tools and data visualization platforms.', 'https://elena.dev', 'Europe/Madrid', 'Female', ARRAY['TypeScript', 'Go', 'Python', 'SQL'], '2026-07-07 09:45:00+00', '2024-11-05 12:00:00+00') + RETURNING id INTO u3_id; + + INSERT INTO users (id, username, email, api_key, display_name, first_name, role, location, university, country, bio, website, time_zone, gender, languages, last_active_at, created_at) + VALUES (gen_random_uuid(), 'ktanaka', 'kenji.t@example.com', gen_random_uuid(), 'Kenji Tanaka', 'Kenji', 'Data Scientist', 'Tokyo', 'University of Tokyo', 'JP', 'ML engineering and seismic data analysis. Rust enthusiast.', 'https://kenji.tech', 'Asia/Tokyo', 'Male', ARRAY['Rust', 'Python', 'R', 'Go'], '2026-07-07 16:00:00+00', '2025-06-10 09:00:00+00') + RETURNING id INTO u4_id; + + -- ============================================================= + -- 2. USER PROFILE STATS + -- ============================================================= + + INSERT INTO user_profile_stats (user_id, total_coding_seconds, total_active_days, current_streak, max_streak, updated_at) + VALUES + (u1_id, 720000, 180, 14, 28, '2026-07-07 14:30:00+00'), + (u2_id, 450000, 120, 5, 18, '2026-07-06 22:15:00+00'), + (u3_id, 960000, 240, 31, 45, '2026-07-07 09:45:00+00'), + (u4_id, 310000, 85, 3, 10, '2026-07-07 16:00:00+00'); + + -- ============================================================= + -- 3. REFRESH TOKENS (mix: active, expired, revoked) + -- ============================================================= + + -- Sarah: 1 active, 1 expired, 1 revoked + INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, revoked, created_at) + VALUES + (gen_random_uuid(), u1_id, 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b', '2026-08-06 12:00:00+00', false, '2026-07-07 12:00:00+00'), + (gen_random_uuid(), u1_id, 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2', '2026-06-01 12:00:00+00', false, '2026-05-02 12:00:00+00'), + (gen_random_uuid(), u1_id, 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c', '2026-07-01 12:00:00+00', true, '2026-06-01 12:00:00+00'); + + -- Marcus: 2 active, 1 revoked + INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, revoked, created_at) + VALUES + (gen_random_uuid(), u2_id, 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d', '2026-08-01 10:00:00+00', false, '2026-07-02 10:00:00+00'), + (gen_random_uuid(), u2_id, 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4', '2026-08-05 15:00:00+00', false, '2026-07-06 15:00:00+00'), + (gen_random_uuid(), u2_id, 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e', '2026-05-15 10:00:00+00', true, '2026-04-15 10:00:00+00'); + + -- Elena: 1 active, 2 expired + INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, revoked, created_at) + VALUES + (gen_random_uuid(), u3_id, 'a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f', '2026-08-07 08:00:00+00', false, '2026-07-08 08:00:00+00'), + (gen_random_uuid(), u3_id, 'b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6', '2026-06-10 08:00:00+00', false, '2026-05-11 08:00:00+00'), + (gen_random_uuid(), u3_id, 'c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a', '2026-04-20 08:00:00+00', false, '2026-03-21 08:00:00+00'); + + -- Kenji: 1 active, 1 expired, 1 revoked + INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, revoked, created_at) + VALUES + (gen_random_uuid(), u4_id, 'd0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8', '2026-08-04 16:00:00+00', false, '2026-07-05 16:00:00+00'), + (gen_random_uuid(), u4_id, 'e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c', '2026-05-01 16:00:00+00', false, '2026-04-01 16:00:00+00'), + (gen_random_uuid(), u4_id, 'f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d', '2026-06-15 16:00:00+00', true, '2026-05-16 16:00:00+00'); + + -- ============================================================= + -- 4. ACHIEVEMENT TYPES + -- ============================================================= + + INSERT INTO achievement_types (id, key, title, description, badge_class) + VALUES + (gen_random_uuid(), 'first_blood', 'First Blood', 'Logged your first coding session.', 'gold'), + (gen_random_uuid(), 'streak_7', '7-Day Streak', 'Maintained a 7-day coding streak.', 'silver'), + (gen_random_uuid(), 'night_owl', 'Night Owl', 'Coded past 2 AM — dedication knows no hour.', 'bronze'), + (gen_random_uuid(), 'century', '100-Day Streak', 'Coded for 100 consecutive days. Unstoppable.', 'gold') + ON CONFLICT (key) DO NOTHING; + + -- Re-fetch IDs (in case they already existed) + SELECT id INTO a_first_blood FROM achievement_types WHERE key = 'first_blood'; + SELECT id INTO a_streak_7 FROM achievement_types WHERE key = 'streak_7'; + SELECT id INTO a_night_owl FROM achievement_types WHERE key = 'night_owl'; + SELECT id INTO a_century FROM achievement_types WHERE key = 'century'; + + -- ============================================================= + -- 5. USER ACHIEVEMENTS + -- ============================================================= + + -- Sarah: first_blood (Jan), night_owl (Feb), streak_7 (Mar) + INSERT INTO user_achievements (user_id, achievement_type_id, earned_at) + VALUES + (u1_id, a_first_blood, '2025-01-15 10:00:00+00'), + (u1_id, a_night_owl, '2025-02-10 03:15:00+00'), + (u1_id, a_streak_7, '2025-03-01 18:00:00+00'); + + -- Marcus: first_blood (Mar), streak_7 (Apr) + INSERT INTO user_achievements (user_id, achievement_type_id, earned_at) + VALUES + (u2_id, a_first_blood, '2025-03-22 11:00:00+00'), + (u2_id, a_streak_7, '2025-04-05 20:00:00+00'); + + -- Elena: first_blood (Nov), night_owl (Dec), century (Feb) + INSERT INTO user_achievements (user_id, achievement_type_id, earned_at) + VALUES + (u3_id, a_first_blood, '2024-11-05 12:00:00+00'), + (u3_id, a_night_owl, '2024-12-20 02:45:00+00'), + (u3_id, a_century, '2025-02-13 09:00:00+00'); + + -- Kenji: first_blood (Jun) + INSERT INTO user_achievements (user_id, achievement_type_id, earned_at) + VALUES + (u4_id, a_first_blood, '2025-06-10 09:30:00+00'); + + -- ============================================================= + -- 6. ACTIVITY LOG (3-5 per user, timestamps match achievements) + -- ============================================================= + + -- Sarah (5 entries) + INSERT INTO activity_log (id, user_id, kind, text, metadata, created_at) + VALUES + (gen_random_uuid(), u1_id, 'session_completed', 'Completed a 2-hour research coding session on waveform analysis.', '{"session_id": "00000000-0000-0000-0000-000000000101", "duration_seconds": 7200, "lines_added": 340}'::JSONB, '2026-07-07 14:30:00+00'), + (gen_random_uuid(), u1_id, 'achievement_earned', 'Earned the First Blood badge!', '{"achievement_key": "first_blood", "badge_class": "gold"}'::JSONB, '2025-01-15 10:00:00+00'), + (gen_random_uuid(), u1_id, 'achievement_earned', 'Earned the Night Owl badge!', '{"achievement_key": "night_owl", "badge_class": "bronze"}'::JSONB, '2025-02-10 03:15:00+00'), + (gen_random_uuid(), u1_id, 'achievement_earned', 'Earned the 7-Day Streak badge!', '{"achievement_key": "streak_7", "badge_class": "silver"}'::JSONB, '2025-03-01 18:00:00+00'), + (gen_random_uuid(), u1_id, 'streak_update', 'Current streak is 14 days — your longest is 28!', '{"current_streak": 14, "max_streak": 28}'::JSONB, '2026-07-07 14:30:00+00'); + + -- Marcus (4 entries) + INSERT INTO activity_log (id, user_id, kind, text, metadata, created_at) + VALUES + (gen_random_uuid(), u2_id, 'session_completed', 'Finished debugging a distributed systems simulation in Go.', '{"session_id": "00000000-0000-0000-0000-000000000201", "duration_seconds": 5400, "lines_added": 120}'::JSONB, '2026-07-06 22:15:00+00'), + (gen_random_uuid(), u2_id, 'achievement_earned', 'Earned the First Blood badge!', '{"achievement_key": "first_blood", "badge_class": "gold"}'::JSONB, '2025-03-22 11:00:00+00'), + (gen_random_uuid(), u2_id, 'achievement_earned', 'Earned the 7-Day Streak badge!', '{"achievement_key": "streak_7", "badge_class": "silver"}'::JSONB, '2025-04-05 20:00:00+00'), + (gen_random_uuid(), u2_id, 'session_completed', 'Contributed to an open-source visualization library.', '{"session_id": "00000000-0000-0000-0000-000000000202", "duration_seconds": 3600, "commits": 3}'::JSONB, '2026-07-05 16:00:00+00'); + + -- Elena (5 entries) + INSERT INTO activity_log (id, user_id, kind, text, metadata, created_at) + VALUES + (gen_random_uuid(), u3_id, 'session_completed', 'Deployed a new API endpoint for the dashboard backend.', '{"session_id": "00000000-0000-0000-0000-000000000301", "duration_seconds": 4800, "deployments": 1}'::JSONB, '2026-07-07 09:45:00+00'), + (gen_random_uuid(), u3_id, 'achievement_earned', 'Earned the First Blood badge!', '{"achievement_key": "first_blood", "badge_class": "gold"}'::JSONB, '2024-11-05 12:00:00+00'), + (gen_random_uuid(), u3_id, 'achievement_earned', 'Earned the Night Owl badge!', '{"achievement_key": "night_owl", "badge_class": "bronze"}'::JSONB, '2024-12-20 02:45:00+00'), + (gen_random_uuid(), u3_id, 'achievement_earned', 'Earned the 100-Day Streak badge!', '{"achievement_key": "century", "badge_class": "gold"}'::JSONB, '2025-02-13 09:00:00+00'), + (gen_random_uuid(), u3_id, 'streak_update', 'Current streak is 31 days — keep it going!', '{"current_streak": 31, "max_streak": 45}'::JSONB, '2026-07-07 09:45:00+00'); + + -- Kenji (3 entries) + INSERT INTO activity_log (id, user_id, kind, text, metadata, created_at) + VALUES + (gen_random_uuid(), u4_id, 'session_completed', 'Ran a batch seismic inference pipeline in Rust.', '{"session_id": "00000000-0000-0000-0000-000000000401", "duration_seconds": 9000, "models_run": 12}'::JSONB, '2026-07-07 16:00:00+00'), + (gen_random_uuid(), u4_id, 'achievement_earned', 'Earned the First Blood badge!', '{"achievement_key": "first_blood", "badge_class": "gold"}'::JSONB, '2025-06-10 09:30:00+00'), + (gen_random_uuid(), u4_id, 'session_completed', 'Refactored data-loading module for 3x throughput improvement.', '{"session_id": "00000000-0000-0000-0000-000000000402", "duration_seconds": 6600, "throughput_gain_pct": 200}'::JSONB, '2026-07-06 18:30:00+00'); + + -- ============================================================= + -- 7. USER PROBLEM STATS (placeholder defaults) + -- ============================================================= + + INSERT INTO user_problem_stats (user_id, solved_count, total_problems, attempting_count, rating, contribution_points, updated_at) + VALUES + (u1_id, 0, 0, 0, 0, 0, '2026-07-07 14:30:00+00'), + (u2_id, 0, 0, 0, 0, 0, '2026-07-06 22:15:00+00'), + (u3_id, 0, 0, 0, 0, 0, '2026-07-07 09:45:00+00'), + (u4_id, 0, 0, 0, 0, 0, '2026-07-07 16:00:00+00'); + +END $$; diff --git a/apps/web/bun.lock b/apps/web/bun.lock index 97c8cd6..bc49183 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -3,11 +3,7 @@ "configVersion": 1, "workspaces": { "": { -<<<<<<< HEAD - "name": "web", -======= "name": "seismic-web", ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "dependencies": { "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", @@ -15,10 +11,7 @@ "@angular/forms": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", -<<<<<<< HEAD -======= "fdir": "^6.5.0", ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -443,21 +436,6 @@ "@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="], -<<<<<<< HEAD - "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], - - "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], - - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], - - "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], - - "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], - - "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], - - "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], -======= "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], @@ -471,7 +449,6 @@ "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], @@ -497,11 +474,7 @@ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], -<<<<<<< HEAD - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.41", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A=="], -======= "baseline-browser-mapping": ["baseline-browser-mapping@2.10.42", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q=="], ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "beasties": ["beasties@0.4.2", "", { "dependencies": { "css-select": "^6.0.0", "css-what": "^7.0.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "htmlparser2": "^10.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.49", "postcss-media-query-parser": "^0.2.3", "postcss-safe-parser": "^7.0.1" } }, "sha512-NvcGjG/7AVUAfRbvrJmHunDQS9uHnE6Q/7AkaPr8oKE8HjOlpjRG5075z/th2Tmlezk3VlaaS8+X9I1RwHJMQw=="], @@ -513,11 +486,7 @@ "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="], -<<<<<<< HEAD - "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], -======= "browserslist": ["browserslist@4.28.5", "", { "dependencies": { "baseline-browser-mapping": "^2.10.42", "caniuse-lite": "^1.0.30001800", "electron-to-chromium": "^1.5.387", "node-releases": "^2.0.50", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ=="], ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -529,11 +498,7 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], -<<<<<<< HEAD - "caniuse-lite": ["caniuse-lite@1.0.30001800", "", {}, "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA=="], -======= - "caniuse-lite": ["caniuse-lite@1.0.30001802", "", {}, "sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw=="], ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) + "caniuse-lite": ["caniuse-lite@1.0.30001803", "", {}, "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], @@ -599,7 +564,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.387", "", {}, "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.389", "", {}, "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -689,11 +654,7 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], -<<<<<<< HEAD - "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], -======= "hono": ["hono@4.12.28", "", {}, "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA=="], ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], @@ -767,7 +728,7 @@ "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], - "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "lucide-angular": ["lucide-angular@1.0.0", "", { "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { "@angular/common": "13.x - 21.x", "@angular/core": "13.x - 21.x" } }, "sha512-YxCNEXHUz2IzAZIlxU4CkD55ljMjOlm3/am4eqadX/qkFszyGDzZwtbWOP1wj6vlbn/BNL4RhJeXbusLz96ajg=="], @@ -983,7 +944,7 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], @@ -1003,13 +964,13 @@ "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], - "tldts": ["tldts@7.4.6", "", { "dependencies": { "tldts-core": "^7.4.6" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q=="], + "tldts": ["tldts@7.4.7", "", { "dependencies": { "tldts-core": "^7.4.7" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw=="], - "tldts-core": ["tldts-core@7.4.6", "", {}, "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA=="], + "tldts-core": ["tldts-core@7.4.7", "", {}, "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], @@ -1033,11 +994,7 @@ "vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], -<<<<<<< HEAD - "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], -======= "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], @@ -1089,11 +1046,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], -<<<<<<< HEAD - "@inquirer/prompts/@inquirer/confirm": ["@inquirer/confirm@6.1.1", "", { "dependencies": { "@inquirer/core": "^11.2.1", "@inquirer/type": "^4.0.7" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ=="], - -======= ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "@npmcli/agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], @@ -1114,11 +1066,6 @@ "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], -<<<<<<< HEAD -======= - "estree-walker/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], diff --git a/apps/web/package.json b/apps/web/package.json index 6c80486..a6ede70 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,11 +9,7 @@ "test": "ng test" }, "private": true, -<<<<<<< HEAD "packageManager": "bun@1.3.14", -======= - "packageManager": "bun@1.3.14" , ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "dependencies": { "@angular/common": "^22.0.0", "@angular/compiler": "^22.0.0", @@ -21,10 +17,7 @@ "@angular/forms": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", -<<<<<<< HEAD -======= "fdir": "^6.5.0", ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0" @@ -38,4 +31,4 @@ "typescript": "~6.0.2", "vitest": "^4.0.8" } -} +} \ No newline at end of file From 68146b4aac4f5a339dff626c88e048b8f9a57a3c Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Wed, 8 Jul 2026 21:41:12 +0300 Subject: [PATCH 6/7] feat: add TargetCursor to profile personal info --- apps/api/docs/swagger.json | 94 +- apps/api/handlers/profile.go | 347 +++++++ apps/api/main.go | 2 +- apps/api/models/profile.go | 241 +++++ apps/api/routes/routes.go | 6 + apps/web/bun.lock | 3 + apps/web/package.json | 1 + apps/web/src/app/app.html | 19 +- apps/web/src/app/app.routes.ts | 4 + apps/web/src/app/app.ts | 5 +- apps/web/src/app/core/api/api.service.ts | 6 + apps/web/src/app/pages/profile/profile.css | 912 ++++++++++++++++++ apps/web/src/app/pages/profile/profile.html | 416 ++++++++ .../src/app/pages/profile/profile.service.ts | 100 ++ apps/web/src/app/pages/profile/profile.ts | 434 +++++++++ .../shared/components/target-cursor/index.ts | 2 + .../target-cursor/target-cursor.component.ts | 474 +++++++++ 17 files changed, 3047 insertions(+), 19 deletions(-) create mode 100644 apps/api/handlers/profile.go create mode 100644 apps/web/src/app/pages/profile/profile.css create mode 100644 apps/web/src/app/pages/profile/profile.html create mode 100644 apps/web/src/app/pages/profile/profile.service.ts create mode 100644 apps/web/src/app/pages/profile/profile.ts create mode 100644 apps/web/src/app/shared/components/target-cursor/index.ts create mode 100644 apps/web/src/app/shared/components/target-cursor/target-cursor.component.ts diff --git a/apps/api/docs/swagger.json b/apps/api/docs/swagger.json index b2f5bc0..ab520e2 100644 --- a/apps/api/docs/swagger.json +++ b/apps/api/docs/swagger.json @@ -548,8 +548,6 @@ } } }, -<<<<<<< HEAD -======= "/api/profile": { "get": { "security": [ @@ -579,9 +577,51 @@ } } } + }, + "patch": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Updates allowed fields on the user profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "profile" + ], + "summary": "Update user profile", + "parameters": [ + { + "description": "Profile update details", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/helpers.APIResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/helpers.APIResponse" + } + } + } } }, ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "/api/settings/account": { "post": { "security": [ @@ -849,8 +889,6 @@ } } }, -<<<<<<< HEAD -======= "models.Achievement": { "type": "object", "properties": { @@ -885,7 +923,6 @@ } } }, ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) "models.Heartbeat": { "type": "object", "properties": { @@ -918,13 +955,10 @@ }, "time": { "type": "integer" - }, "timezone": { "type": "string" } } -<<<<<<< HEAD -======= }, "models.HeatmapCell": { "type": "object", @@ -992,6 +1026,9 @@ "completionPercent": { "type": "integer" }, + "currentStreak": { + "type": "integer" + }, "email": { "type": "string" }, @@ -1052,6 +1089,9 @@ "totalActiveDays": { "type": "integer" }, + "totalCodingSeconds": { + "type": "integer" + }, "totalProblems": { "type": "integer" }, @@ -1062,7 +1102,41 @@ "type": "string" } } ->>>>>>> cb826e2 (feat: add user profile page and improve Swagger API documentation) + }, + "models.UpdateProfileRequest": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "location": { + "type": "string" + }, + "role": { + "type": "string" + }, + "university": { + "type": "string" + }, + "website": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "timeZone": { + "type": "string" + } + } } }, "securityDefinitions": { diff --git a/apps/api/handlers/profile.go b/apps/api/handlers/profile.go new file mode 100644 index 0000000..fef00c6 --- /dev/null +++ b/apps/api/handlers/profile.go @@ -0,0 +1,347 @@ +package handlers + +import ( + "fmt" + "net/url" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/majoramari/seismic/apps/api/helpers" + "github.com/majoramari/seismic/apps/api/models" +) + +// ProfileHandler handles requests related to the user's profile page. +type ProfileHandler struct { + Pool *pgxpool.Pool +} + +// GetProfile godoc +// @Summary Get full profile +// @Description Returns the logged-in user's full profile: bio info, coding metrics, activity heatmap, problem-solving gauge, achievements, recent activity, and personal-info completion. +// @Tags profile +// @Produce json +// @Security BearerAuth +// @Success 200 {object} helpers.APIResponse +// @Failure 401 {object} helpers.APIResponse +// @Router /api/profile [get] +func (h *ProfileHandler) GetProfile(c *fiber.Ctx) error { + // userID := c.Locals("userID").(string) // TEMP DEV: Disabled + ctx := c.Context() + + // TEMP DEV: Find the testuser's ID + var userID string + _ = h.Pool.QueryRow(ctx, "SELECT id FROM users WHERE username = 'sarahchen' LIMIT 1").Scan(&userID) + if userID == "" { + return helpers.Error(c, fiber.StatusNotFound, "sarahchen not found, please run seed_dummy_data.sql") + } + + + + // ── 1. User row ────────────────────────────────────────────────────────── + user, err := models.FindUserByID(ctx, h.Pool, userID) + if err != nil || user == nil { + return helpers.Error(c, fiber.StatusInternalServerError, "Failed to load user") + } + + // ── 2. Coding stats (cached aggregate) ────────────────────────────────── + stats, _ := models.GetProfileStats(ctx, h.Pool, userID) + if stats == nil { + stats = &models.UserProfileStats{} + } + + // ── 3. Problem-solving stats (placeholder — zeros if no row) ──────────── + problemStats, _ := models.GetProblemStats(ctx, h.Pool, userID) + if problemStats == nil { + problemStats = &models.UserProblemStats{} + } + + // ── 4. Achievements ────────────────────────────────────────────────────── + achievements, _ := models.GetProfileAchievements(ctx, h.Pool, userID) + if achievements == nil { + achievements = []models.Achievement{} + } + + // ── 5. Recent activity ─────────────────────────────────────────────────── + activity, _ := models.GetRecentActivity(ctx, h.Pool, userID, 10) + if activity == nil { + activity = []models.ActivityLogItem{} + } + + // ── 6. Heatmap: flat days → 52-week × 7-day grid ──────────────────────── + heatmapDays, _ := models.GetHeatmap(ctx, h.Pool, userID) + heatmap := buildHeatmapGrid(heatmapDays) + + // ── 7. Personal-info completion ────────────────────────────────────────── + completionPercent, infoFields := computeInfoCompletion(user) + + // ── 8. Derived string fields ───────────────────────────────────────────── + firstName := derefStr(user.FirstName) + role := derefStr(user.Role) + location := derefStr(user.Location) + university := derefStr(user.University) + bio := derefStr(user.Bio) + timeZone := derefStr(user.TimeZone) + website := derefStr(user.Website) + gender := derefStr(user.Gender) + languages := user.Languages + if languages == nil { + languages = []string{} + } + avatarURL := derefStr(user.AvatarURL) + + joinDate := user.CreatedAt.Format("Jan 2006") + memberFor := computeMemberFor(user.CreatedAt) + lastActive := computeLastActive(user.LastActiveAt) + + resp := models.ProfileResponse{ + Username: user.Username, + Email: user.Email, + FirstName: firstName, + AvatarURL: avatarURL, + + Role: role, + Location: location, + University: university, + Bio: bio, + TimeZone: timeZone, + Website: website, + Gender: gender, + Languages: languages, + + JoinDate: joinDate, + LastActive: lastActive, + MemberFor: memberFor, + + TotalCodingSeconds: stats.TotalCodingSeconds, + TotalActiveDays: stats.TotalActiveDays, + CurrentStreak: stats.CurrentStreak, + MaxStreak: stats.MaxStreak, + + // Placeholder — see migration 010 comment + Solved: problemStats.SolvedCount, + TotalProblems: problemStats.TotalProblems, + Attempting: problemStats.AttemptingCount, + + CompletionPercent: completionPercent, + InfoFields: infoFields, + + Heatmap: heatmap, + RecentActivity: activity, + Achievements: achievements, + } + + return helpers.Success(c, "Profile retrieved", resp) +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +// derefStr safely dereferences a *string; returns "" if nil. +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +// computeMemberFor returns a human-readable duration since createdAt, +// e.g. "2 years", "8 months", "3 weeks". +func computeMemberFor(createdAt time.Time) string { + now := time.Now() + years := now.Year() - createdAt.Year() + months := int(now.Month()) - int(createdAt.Month()) + if now.Day() < createdAt.Day() { + months-- + } + if months < 0 { + years-- + months += 12 + } + totalMonths := years*12 + months + switch { + case totalMonths >= 12: + y := totalMonths / 12 + if y == 1 { + return "1 year" + } + return fmt.Sprintf("%d years", y) + case totalMonths > 0: + if totalMonths == 1 { + return "1 month" + } + return fmt.Sprintf("%d months", totalMonths) + default: + days := int(now.Sub(createdAt).Hours() / 24) + if days < 7 { + return "just joined" + } + return fmt.Sprintf("%d weeks", days/7) + } +} + +// computeLastActive returns a human-readable string for when the user was +// last active. Returns "Online now" if within the last 5 minutes. +func computeLastActive(lastActive *time.Time) string { + if lastActive == nil { + return "Never" + } + diff := time.Since(*lastActive) + switch { + case diff < 5*time.Minute: + return "Online now" + case diff < time.Hour: + m := int(diff.Minutes()) + if m == 1 { + return "1 minute ago" + } + return fmt.Sprintf("%d minutes ago", m) + case diff < 24*time.Hour: + h := int(diff.Hours()) + if h == 1 { + return "1 hour ago" + } + return fmt.Sprintf("%d hours ago", h) + default: + d := int(diff.Hours() / 24) + if d == 1 { + return "1 day ago" + } + return fmt.Sprintf("%d days ago", d) + } +} + +// computeInfoCompletion checks which profile fields are filled and returns +// the completion percentage and the list of info fields for the UI capsules. +// Fields checked: fullName (display_name), bio, location, gender, languages. +func computeInfoCompletion(user *models.User) (int, []models.ProfileInfoField) { + type check struct { + key string + label string + ok bool + } + + // display_name used as "Full name" since there's no separate fullName column + displayNameFilled := user.DisplayName != nil && *user.DisplayName != "" + bioFilled := user.Bio != nil && *user.Bio != "" + locationFilled := user.Location != nil && *user.Location != "" + genderFilled := user.Gender != nil && *user.Gender != "" + languagesFilled := len(user.Languages) > 0 + + checks := []check{ + {"fullName", "Full name", displayNameFilled}, + {"bio", "Bio", bioFilled}, + {"location", "Location", locationFilled}, + {"gender", "Gender", genderFilled}, + {"languages", "Languages", languagesFilled}, + } + + completed := 0 + fields := make([]models.ProfileInfoField, 0, len(checks)) + for _, c := range checks { + if c.ok { + completed++ + } + fields = append(fields, models.ProfileInfoField{ + Key: c.key, + Label: c.label, + Completed: c.ok, + }) + } + + pct := (completed * 100) / len(checks) + return pct, fields +} + +// buildHeatmapGrid converts a flat []HeatmapDay (from GetHeatmap) into a +// 52-week × 7-day grid suitable for the frontend contribution chart. +// Level thresholds (seconds): 0→0, 1-1799→1, 1800-3599→2, 3600-7199→3, ≥7200→4. +func buildHeatmapGrid(days []models.HeatmapDay) [][]models.HeatmapCell { + // Build a lookup: date-string → seconds + lookup := make(map[string]int, len(days)) + for _, d := range days { + lookup[d.Date] = d.Seconds + } + + // Anchor: today, walk back to find the start of the grid (52 weeks ago). + // We start on the same weekday as 364 days ago (52 full weeks). + today := time.Now().Truncate(24 * time.Hour) + startDay := today.AddDate(0, 0, -364) // 52*7 = 364 days + + grid := make([][]models.HeatmapCell, 52) + for w := 0; w < 52; w++ { + week := make([]models.HeatmapCell, 7) + for d := 0; d < 7; d++ { + t := startDay.AddDate(0, 0, w*7+d) + dateStr := t.Format("2006-01-02") + sec := lookup[dateStr] + week[d] = models.HeatmapCell{ + Date: dateStr, + Level: secondsToLevel(sec), + } + } + grid[w] = week + } + return grid +} + +// secondsToLevel maps coding seconds for a day to a display level 0-4. +func secondsToLevel(sec int) int { + switch { + case sec <= 0: + return 0 + case sec < 1800: // < 30 min + return 1 + case sec < 3600: // 30 min – 1 h + return 2 + case sec < 7200: // 1 h – 2 h + return 3 + default: // ≥ 2 h + return 4 + } +} + +// UpdateProfile godoc +// @Summary Update user profile +// @Description Updates allowed fields on the user profile +// @Tags profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} helpers.APIResponse +// @Failure 400 {object} helpers.APIResponse +// @Router /api/profile [patch] +func (h *ProfileHandler) UpdateProfile(c *fiber.Ctx) error { + // userID := c.Locals("userID").(string) // TEMP DEV: Disabled + ctx := c.Context() + + // TEMP DEV: Find the testuser's ID + var userID string + _ = h.Pool.QueryRow(ctx, "SELECT id FROM users WHERE username = 'sarahchen' LIMIT 1").Scan(&userID) + if userID == "" { + return helpers.Error(c, fiber.StatusNotFound, "sarahchen not found") + } + + var req models.UpdateProfileRequest + if err := c.BodyParser(&req); err != nil { + return helpers.Error(c, fiber.StatusBadRequest, "Invalid request body") + } + + // Validation: Bio length + if req.Bio != nil && len(*req.Bio) > 280 { + return helpers.Error(c, fiber.StatusBadRequest, "Bio must not exceed 280 characters") + } + + // Validation: Website URL format (only if provided and not empty) + if req.Website != nil && *req.Website != "" { + if _, err := url.ParseRequestURI(*req.Website); err != nil { + return helpers.Error(c, fiber.StatusBadRequest, "Invalid website URL format") + } + } + + if err := models.UpdateUserProfile(ctx, h.Pool, userID, &req); err != nil { + return helpers.Error(c, fiber.StatusInternalServerError, "Failed to update profile") + } + + return helpers.Success(c, "Profile updated successfully", nil) +} diff --git a/apps/api/main.go b/apps/api/main.go index 69fd94f..f6a4028 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -48,7 +48,7 @@ func main() { app.Use(cors.New(cors.Config{ AllowOrigins: cfg.AllowedOrigins, AllowHeaders: "Origin, Content-Type, Accept, Authorization", - AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", + AllowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS", AllowCredentials: true, })) diff --git a/apps/api/models/profile.go b/apps/api/models/profile.go index b7e5d82..c0032d1 100644 --- a/apps/api/models/profile.go +++ b/apps/api/models/profile.go @@ -2,11 +2,90 @@ package models import ( "context" + "errors" + "fmt" "time" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) +// ── Profile response types ──────────────────────────────────────────────────── + +// HeatmapCell is one cell in the 52×7 activity grid sent to the frontend. +type HeatmapCell struct { + Date string `json:"date"` + Level int `json:"level"` // 0-4 +} + +// ProfileInfoField represents one personal-info item and whether it's filled. +type ProfileInfoField struct { + Key string `json:"key"` + Label string `json:"label"` + Completed bool `json:"completed"` +} + +// Achievement is the denormalised view of user_achievements JOIN achievement_types. +type Achievement struct { + Key string `json:"key"` + Title string `json:"title"` + Description string `json:"description"` + BadgeClass string `json:"badgeClass"` + EarnedAt string `json:"earnedAt"` // formatted as "Jan 02, 2006" +} + +// ActivityLogItem is one entry in the recent-activity feed. +type ActivityLogItem struct { + Kind string `json:"kind"` + Text string `json:"text"` + At string `json:"at"` // formatted as RFC3339 +} + +// ProfileResponse is the complete payload returned by GET /api/profile. +type ProfileResponse struct { + // Identity + Username string `json:"username"` + Email string `json:"email"` + FirstName string `json:"firstName"` + AvatarURL string `json:"avatarUrl"` + + // Bio fields (nullable in DB → empty string when nil) + Role string `json:"role"` + Location string `json:"location"` + University string `json:"university"` + Bio string `json:"bio"` + TimeZone string `json:"timeZone"` + Website string `json:"website"` + Gender string `json:"gender"` + Languages []string `json:"languages"` + + // Date strings + JoinDate string `json:"joinDate"` // "Jan 2006" + LastActive string `json:"lastActive"` // "Online now" or relative + MemberFor string `json:"memberFor"` // "2 years", "5 months", etc. + + // Coding metrics (from user_profile_stats) + TotalCodingSeconds int64 `json:"totalCodingSeconds"` + TotalActiveDays int `json:"totalActiveDays"` + CurrentStreak int `json:"currentStreak"` + MaxStreak int `json:"maxStreak"` + + // Problem solving (placeholder – user_problem_stats may have no row) + Solved int `json:"solved"` + TotalProblems int `json:"totalProblems"` + Attempting int `json:"attempting"` + + // Personal info completion (computed in handler) + CompletionPercent int `json:"completionPercent"` + InfoFields []ProfileInfoField `json:"infoFields"` + + // Rich data + Heatmap [][]HeatmapCell `json:"heatmap"` + RecentActivity []ActivityLogItem `json:"recentActivity"` + Achievements []Achievement `json:"achievements"` +} + + // UserProfileStats caches aggregate coding data for the profile page type UserProfileStats struct { UserID string `json:"userId"` @@ -107,3 +186,165 @@ type UserProblemStats struct { ContributionPoints int `json:"contributionPoints"` UpdatedAt time.Time `json:"updatedAt"` } + +// GetProblemStats fetches placeholder problem-solving stats. +// Returns zero values (no error) if the user has no row yet. +// Do NOT insert a row here; this table is a placeholder. +func GetProblemStats(ctx context.Context, pool *pgxpool.Pool, userID string) (*UserProblemStats, error) { + var s UserProblemStats + err := pool.QueryRow(ctx, ` + SELECT user_id, solved_count, total_problems, attempting_count, rating, contribution_points, updated_at + FROM user_problem_stats + WHERE user_id = $1 + `, userID).Scan(&s.UserID, &s.SolvedCount, &s.TotalProblems, &s.AttemptingCount, &s.Rating, &s.ContributionPoints, &s.UpdatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return &UserProblemStats{}, nil // placeholder: return zeros, no insert + } + if err != nil { + return &UserProblemStats{}, nil // silently degrade; profile still loads + } + return &s, nil +} + +// GetProfileAchievements returns all achievements earned by a user, +// joining user_achievements with achievement_types for full metadata. +func GetProfileAchievements(ctx context.Context, pool *pgxpool.Pool, userID string) ([]Achievement, error) { + rows, err := pool.Query(ctx, ` + SELECT at.key, at.title, at.description, at.badge_class, ua.earned_at + FROM user_achievements ua + JOIN achievement_types at ON at.id = ua.achievement_type_id + WHERE ua.user_id = $1 + ORDER BY ua.earned_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var achievements []Achievement + for rows.Next() { + var a Achievement + var earnedAt time.Time + if err := rows.Scan(&a.Key, &a.Title, &a.Description, &a.BadgeClass, &earnedAt); err != nil { + return nil, err + } + a.EarnedAt = earnedAt.Format("Jan 02, 2006") + achievements = append(achievements, a) + } + if achievements == nil { + achievements = []Achievement{} + } + return achievements, nil +} + +// GetRecentActivity returns the last `limit` activity log entries for a user. +func GetRecentActivity(ctx context.Context, pool *pgxpool.Pool, userID string, limit int) ([]ActivityLogItem, error) { + rows, err := pool.Query(ctx, ` + SELECT kind, text, created_at + FROM activity_log + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `, userID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var items []ActivityLogItem + for rows.Next() { + var item ActivityLogItem + var createdAt time.Time + if err := rows.Scan(&item.Kind, &item.Text, &createdAt); err != nil { + return nil, err + } + item.At = createdAt.Format(time.RFC3339) + items = append(items, item) + } + if items == nil { + items = []ActivityLogItem{} + } + return items, nil +} + +// UpdateProfileRequest represents the allowed fields for updating a user profile. +// Pointers differentiate between a field not sent (nil) and explicitly emptied (""). +type UpdateProfileRequest struct { + FirstName *string `json:"firstName"` + Bio *string `json:"bio"` + Location *string `json:"location"` + Role *string `json:"role"` + University *string `json:"university"` + Website *string `json:"website"` + Gender *string `json:"gender"` + Languages *[]string `json:"languages"` + TimeZone *string `json:"timeZone"` +} + +// UpdateUserProfile explicitly updates only the whitelisted profile fields. +func UpdateUserProfile(ctx context.Context, pool *pgxpool.Pool, userID string, req *UpdateProfileRequest) error { + setIdx := 1 + var args []interface{} + query := "UPDATE users SET " + + // Explicit Whitelist construction: Column names are hardcoded + if req.FirstName != nil { + query += fmt.Sprintf("first_name = $%d, ", setIdx) + args = append(args, *req.FirstName) + setIdx++ + } + if req.Bio != nil { + query += fmt.Sprintf("bio = $%d, ", setIdx) + args = append(args, *req.Bio) + setIdx++ + } + if req.Location != nil { + query += fmt.Sprintf("location = $%d, ", setIdx) + args = append(args, *req.Location) + setIdx++ + } + if req.Role != nil { + query += fmt.Sprintf("role = $%d, ", setIdx) + args = append(args, *req.Role) + setIdx++ + } + if req.University != nil { + query += fmt.Sprintf("university = $%d, ", setIdx) + args = append(args, *req.University) + setIdx++ + } + if req.Website != nil { + query += fmt.Sprintf("website = $%d, ", setIdx) + args = append(args, *req.Website) + setIdx++ + } + if req.Gender != nil { + query += fmt.Sprintf("gender = $%d, ", setIdx) + args = append(args, *req.Gender) + setIdx++ + } + if req.TimeZone != nil { + query += fmt.Sprintf("time_zone = $%d, ", setIdx) + args = append(args, *req.TimeZone) + setIdx++ + } + if req.Languages != nil { + query += fmt.Sprintf("languages = $%d, ", setIdx) + args = append(args, *req.Languages) + setIdx++ + } + + // If no valid update fields were provided, simply return + if setIdx == 1 { + return nil + } + + // Remove the trailing comma and space + query = query[:len(query)-2] + + query += fmt.Sprintf(" WHERE id = $%d", setIdx) + args = append(args, userID) + + _, err := pool.Exec(ctx, query, args...) + return err +} diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index 037a7c5..7ee26b6 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -11,6 +11,12 @@ import ( func Setup(app *fiber.App, authHandler *handlers.AuthHandler, heartbeatHandler *handlers.HeartbeatHandler, adminHandler *handlers.AdminHandler, statsHandler *handlers.StatsHandler, filtersHandler *handlers.FiltersHandler, jwtSecret string, pool *pgxpool.Pool) { app.Get("/health", handlers.HealthCheck) + // Profile route – TEMP DEV: unprotected so we can view the page without login + profileHandler := &handlers.ProfileHandler{Pool: pool} + app.Get("/api/profile", profileHandler.GetProfile) + app.Patch("/api/profile", profileHandler.UpdateProfile) + + auth := app.Group("/api/auth") auth.Get("/verify", authHandler.VerifyMagicLink) auth.Post("/complete-signup", authHandler.CompleteSignup) diff --git a/apps/web/bun.lock b/apps/web/bun.lock index bc49183..7084f9b 100644 --- a/apps/web/bun.lock +++ b/apps/web/bun.lock @@ -12,6 +12,7 @@ "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", "fdir": "^6.5.0", + "gsap": "^3.12.5", "lucide-angular": "^1.0.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", @@ -650,6 +651,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "gsap": ["gsap@3.15.0", "", {}, "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], diff --git a/apps/web/package.json b/apps/web/package.json index a6ede70..0dab0fc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@angular/router": "^22.0.0", "fdir": "^6.5.0", "lucide-angular": "^1.0.0", + "gsap": "^3.12.5", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/apps/web/src/app/app.html b/apps/web/src/app/app.html index 7ec363d..694f697 100644 --- a/apps/web/src/app/app.html +++ b/apps/web/src/app/app.html @@ -1,11 +1,16 @@ @if (auth.isLoggedIn()) { - -
- -
-} @else { - + +
+
+} @else { + + } - + + + + + \ No newline at end of file diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index a4bf8f3..8783183 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -6,6 +6,7 @@ import { Leaderboard } from './pages/leaderboard/leaderboard'; import { authGuard } from './core/auth/auth.guard'; import { guestGuard } from './core/auth/guest.guard'; import { Settings } from './pages/settings/settings'; +import { Profile } from './pages/profile/profile'; export const routes: Routes = [ { path: 'login', component: Login, canActivate: [guestGuard], title: 'Log in — Seismic' }, @@ -18,4 +19,7 @@ export const routes: Routes = [ }, { path: 'leaderboard', component: Leaderboard, title: 'Leaderboard — Seismic' }, { path: 'settings', component: Settings, canActivate: [authGuard], title: 'Settings — Seismic' }, + // TEMP DEV: Removed authGuard so we can view the page without login + { path: 'profile', component: Profile, title: 'Profile — Seismic' }, ]; + diff --git a/apps/web/src/app/app.ts b/apps/web/src/app/app.ts index 1cb7fff..39b45f7 100644 --- a/apps/web/src/app/app.ts +++ b/apps/web/src/app/app.ts @@ -3,13 +3,16 @@ import { RouterOutlet } from '@angular/router'; import { Navbar } from './shared/components/navbar/navbar'; import { Sidebar } from './shared/components/sidebar/sidebar'; import { ToastContainer } from './shared/components/toast/toast'; +import { TargetCursorComponent } from './shared/components/target-cursor/target-cursor.component'; import { AuthService } from './core/auth/auth.service'; import { UserService } from './core/auth/user.service'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, Navbar, Sidebar, ToastContainer], + imports: [RouterOutlet, Navbar, Sidebar, ToastContainer, TargetCursorComponent], + + templateUrl: './app.html', }) export class App implements OnInit { diff --git a/apps/web/src/app/core/api/api.service.ts b/apps/web/src/app/core/api/api.service.ts index a1dee72..ed3fc59 100644 --- a/apps/web/src/app/core/api/api.service.ts +++ b/apps/web/src/app/core/api/api.service.ts @@ -37,4 +37,10 @@ export class ApiService { .delete>(`${this.baseUrl}${path}`, { withCredentials: true }) .pipe(map((res) => res.data)); } + + patch(path: string, body: unknown) { + return this.http + .patch>(`${this.baseUrl}${path}`, body, { withCredentials: true }) + .pipe(map((res) => res.data)); + } } diff --git a/apps/web/src/app/pages/profile/profile.css b/apps/web/src/app/pages/profile/profile.css new file mode 100644 index 0000000..2f35932 --- /dev/null +++ b/apps/web/src/app/pages/profile/profile.css @@ -0,0 +1,912 @@ +:host { + display: block; +} + +/* ── Loading / Error states ──────────────────────────────────────────────── */ + +.profile-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 1rem; + color: var(--text-muted, #9ca3af); +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.1); + border-top-color: #10b981; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.profile-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: 1rem; + color: #ef4444; + text-align: center; +} + +.profile-error button { + padding: 0.5rem 1.5rem; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 8px; + color: #ef4444; + cursor: pointer; + font-size: 0.875rem; + transition: background 0.2s; +} + +.profile-error button:hover { + background: rgba(239, 68, 68, 0.25); +} + +.empty-state { + color: var(--text-muted, #9ca3af); + font-size: 0.875rem; + padding: 1rem 0; +} + +/* ── Page Layout ──────────────────────────────────────────────────────── */ + +.profile-page { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1.5rem; + color: #e5e7eb; +} + +/* ── Header ───────────────────────────────────────────────────────────── */ + +.profile-page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.profile-page-header h1 { + font-size: 1.75rem; + font-weight: 700; + margin: 0 0 0.5rem 0; + color: #ffffff; +} + +.profile-page-header p { + margin: 0; + color: #9ca3af; + font-size: 0.875rem; +} + +.btn-edit-profile { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #e5e7eb; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-edit-profile:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); +} + +/* ── Top Row (Bio + Avatar) ───────────────────────────────────────────── */ + +.profile-top-row { + display: grid; + grid-template-columns: 1fr 300px; + gap: 1.5rem; +} + +@media (max-width: 768px) { + .profile-top-row { + grid-template-columns: 1fr; + flex-direction: column-reverse; + display: flex; + } +} + +.bio-card, .avatar-card, .metric-card, .activity-card, .gauge-card, .personal-info-card, .projects-card, .achievements-card, .activity-log-card { + background: rgba(17, 24, 39, 0.7); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 1.5rem; + backdrop-filter: blur(10px); +} + +/* Bio Card */ +.bio-greeting { + font-size: 1.5rem; + font-weight: 700; + margin: 0 0 0.25rem 0; +} + +.name-green { + color: #10b981; +} + +.bio-subtitle { + color: #9ca3af; + font-size: 0.95rem; + margin: 0 0 0.25rem 0; +} + +.bio-university { + color: #6b7280; + font-size: 0.875rem; + margin: 0 0 1.5rem 0; +} + +.bio-info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.bio-info-item { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.info-icon { + color: #9ca3af; + margin-top: 0.125rem; +} + +.info-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6b7280; + margin-bottom: 0.25rem; +} + +.info-value { + font-size: 0.875rem; + font-weight: 500; + color: #e5e7eb; +} + +.info-value.online { + color: #10b981; + display: flex; + align-items: center; + gap: 0.375rem; +} + +.info-value.online::before { + content: ''; + display: block; + width: 6px; + height: 6px; + background: #10b981; + border-radius: 50%; + box-shadow: 0 0 8px #10b981; +} + +.bio-quote { + display: flex; + gap: 1rem; + background: rgba(255, 255, 255, 0.03); + padding: 1rem; + border-radius: 8px; + border-left: 3px solid #10b981; + margin-bottom: 1.5rem; +} + +.quote-icon { + color: #10b981; + opacity: 0.7; +} + +.bio-quote p { + margin: 0; + font-style: italic; + color: #d1d5db; + font-size: 0.9rem; + line-height: 1.5; +} + +.bio-social { + display: flex; + gap: 1rem; +} + +.bio-social a { + color: #9ca3af; + transition: color 0.2s; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.05); +} + +.bio-social a:hover { + color: #10b981; + background: rgba(16, 185, 129, 0.1); +} + +/* Avatar Card */ +.avatar-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.avatar-wrapper { + position: relative; + margin-bottom: 1rem; +} + +.avatar-img { + width: 96px; + height: 96px; + border-radius: 50%; + background: linear-gradient(135deg, #10b981, #059669); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + font-weight: 700; + color: white; + border: 4px solid #1f2937; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.avatar-badge { + position: absolute; + bottom: 4px; + right: 4px; + width: 20px; + height: 20px; + background: #10b981; + border: 3px solid #1f2937; + border-radius: 50%; +} + +.avatar-name { + font-size: 1.25rem; + font-weight: 600; + color: #f9fafb; + margin-bottom: 0.25rem; +} + +.avatar-email { + font-size: 0.875rem; + color: #9ca3af; + margin-bottom: 0.5rem; +} + +.avatar-visibility { + font-size: 0.75rem; + color: #6b7280; + margin-bottom: 1.5rem; +} + +.btn-change-photo, .btn-unset-photo { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 0.625rem; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 0.5rem; +} + +.btn-change-photo { + background: rgba(16, 185, 129, 0.1); + color: #10b981; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.btn-change-photo:hover { + background: rgba(16, 185, 129, 0.2); +} + +.btn-unset-photo { + background: transparent; + color: #9ca3af; + border: 1px solid transparent; +} + +.btn-unset-photo:hover { + background: rgba(255, 255, 255, 0.05); + color: #e5e7eb; +} + + +/* ── Metrics Row ──────────────────────────────────────────────────────── */ +.metrics-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; +} + +.metric-card { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.metric-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1rem; +} + +.metric-icon.blue { background: rgba(59, 130, 246, 0.15); color: #60a5fa; } +.metric-icon.green { background: rgba(16, 185, 129, 0.15); color: #34d399; } +.metric-icon.orange { background: rgba(245, 158, 11, 0.15); color: #fbbf24; } +.metric-icon.purple { background: rgba(139, 92, 246, 0.15); color: #a78bfa; } + +.metric-label { + font-size: 0.875rem; + color: #9ca3af; + margin-bottom: 0.25rem; +} + +.metric-value { + font-size: 1.5rem; + font-weight: 700; + color: #f9fafb; + margin-bottom: 0.5rem; +} + +.metric-sub { + font-size: 0.75rem; + color: #6b7280; + display: flex; + align-items: center; + gap: 0.25rem; +} + +.metric-sub.positive { + color: #10b981; +} + +/* ── Visualizations Row (Heatmap + Gauge) ─────────────────────────────── */ + +.viz-row { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 1024px) { + .viz-row { + grid-template-columns: 1fr; + } +} + +/* Heatmap */ +.activity-card { + display: flex; + flex-direction: column; +} + +.activity-header, .gauge-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.title-group, .gauge-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.activity-header h3, .gauge-header h3 { + font-size: 1.125rem; + font-weight: 600; + margin: 0; + color: #e5e7eb; +} + +.info-btn { + color: #6b7280; + cursor: help; +} + +.stats-group { + display: flex; + gap: 1rem; + font-size: 0.875rem; + color: #9ca3af; +} + +.stats-group strong { + color: #f9fafb; +} + +.heatmap-container { + overflow-x: auto; + padding-bottom: 1rem; +} + +.heatmap-wrapper { + display: flex; + gap: 0.5rem; +} + +.heatmap-labels { + display: flex; + flex-direction: column; + justify-content: space-between; + padding-top: 0.5rem; + font-size: 0.75rem; + color: #6b7280; + height: 105px; +} + +.heatmap-grid { + display: flex; + gap: 3px; +} + +.heatmap-column { + display: flex; + flex-direction: column; + gap: 3px; +} + +.heatmap-cell { + width: 12px; + height: 12px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.05); + transition: all 0.2s; + cursor: pointer; +} + +.heatmap-cell:hover { + transform: scale(1.2); + border: 1px solid rgba(255,255,255,0.5); +} + +.heatmap-cell.level-0 { background: rgba(255, 255, 255, 0.05); } +.heatmap-cell.level-1 { background: rgba(16, 185, 129, 0.3); } +.heatmap-cell.level-2 { background: rgba(16, 185, 129, 0.6); } +.heatmap-cell.level-3 { background: rgba(16, 185, 129, 0.85); } +.heatmap-cell.level-4 { background: #10b981; } + +.heatmap-months { + display: flex; + justify-content: space-between; + margin-left: 30px; + margin-top: 0.5rem; + font-size: 0.75rem; + color: #6b7280; +} + +.heatmap-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; + font-size: 0.75rem; + color: #9ca3af; +} + +.heatmap-legend { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.legend-cell { + width: 10px; + height: 10px; + border-radius: 2px; +} + +.legend-cell.l0 { background: rgba(255, 255, 255, 0.05); } +.legend-cell.l1 { background: rgba(16, 185, 129, 0.3); } +.legend-cell.l2 { background: rgba(16, 185, 129, 0.6); } +.legend-cell.l3 { background: rgba(16, 185, 129, 0.85); } +.legend-cell.l4 { background: #10b981; } + +/* Gauge Card */ +.gauge-chart { + position: relative; + width: 100%; + max-width: 200px; + margin: 0 auto 1.5rem auto; +} + +.gauge-svg-container { + position: relative; +} + +.gauge-center-text { + position: absolute; + top: 70%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; +} + +.gauge-number { + font-size: 2rem; + font-weight: 700; + color: #f9fafb; +} + +.gauge-number span { + font-size: 1rem; + color: #6b7280; + font-weight: 500; +} + +.gauge-label { + font-size: 0.875rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.gauge-footer { + text-align: center; + font-size: 0.875rem; + color: #6b7280; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); +} + + +/* ── Bottom Section (Info, Projects, Achievements, Activity) ──────────── */ + +.bottom-section { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; +} + +@media (max-width: 768px) { + .bottom-section { + grid-template-columns: 1fr; + } +} + +.bottom-left, .bottom-right { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Personal Info completion */ +.personal-info-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.progress-ring-container { + position: relative; + width: 48px; + height: 48px; +} + +.progress-ring-container svg { + transform: rotate(-90deg); + width: 100%; + height: 100%; +} + +.progress-ring-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 0.7rem; + font-weight: 700; + color: #10b981; +} + +.info-text h3 { + margin: 0 0 0.25rem 0; + font-size: 1rem; + font-weight: 600; +} + +.info-text p { + margin: 0; + font-size: 0.75rem; + color: #9ca3af; +} + +.skip-btn { + margin-left: auto; + background: transparent; + border: none; + color: #6b7280; + cursor: pointer; + font-size: 0.875rem; +} + +.skip-btn:hover { + color: #e5e7eb; +} + +.info-capsules { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.info-capsule { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 9999px; + color: #9ca3af; + font-size: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.info-capsule.completed { + background: rgba(16, 185, 129, 0.1); + border-color: rgba(16, 185, 129, 0.3); + color: #10b981; +} + +.capsule-icon { + opacity: 0.7; +} + +/* Projects Empty State */ +.projects-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.projects-header h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.eye-icon { + color: #6b7280; +} + +.projects-empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem 0; +} + +.empty-icon { + width: 48px; + height: 48px; + background: rgba(255, 255, 255, 0.05); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #6b7280; + margin-bottom: 1rem; +} + +.projects-empty h4 { + margin: 0 0 0.5rem 0; + font-size: 1rem; + color: #e5e7eb; +} + +.projects-empty p { + margin: 0 0 1.5rem 0; + font-size: 0.875rem; + color: #9ca3af; + max-width: 250px; +} + +.btn-add-project { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + background: #10b981; + border: none; + border-radius: 8px; + color: #fff; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.btn-add-project:hover { + background: #059669; +} + +/* Achievements */ +.card-header-row { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 1.5rem; +} + +.card-header-row h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; +} + +.view-all { + color: #10b981; + font-size: 0.875rem; + text-decoration: none; +} + +.view-all:hover { + text-decoration: underline; +} + +.achievement-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.achievement-item { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 8px; + transition: transform 0.2s; +} + +.achievement-item:hover { + transform: translateY(-2px); + background: rgba(255, 255, 255, 0.04); +} + +.achievement-badge { + width: 40px; + height: 40px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.achievement-badge.gold { background: rgba(250, 204, 21, 0.2); color: #facc15; } +.achievement-badge.silver { background: rgba(156, 163, 175, 0.2); color: #e5e7eb; } +.achievement-badge.bronze { background: rgba(180, 83, 9, 0.2); color: #d97706; } + +.achievement-info h4 { + margin: 0 0 0.25rem 0; + font-size: 0.95rem; + color: #f9fafb; +} + +.achievement-info p { + margin: 0 0 0.5rem 0; + font-size: 0.8rem; + color: #9ca3af; +} + +.achievement-info .date { + font-size: 0.7rem; + color: #6b7280; +} + +/* Activity Log */ +.activity-log-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.activity-log-item { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.activity-log-item:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.log-left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.log-icon { + color: #10b981; +} + +.log-text { + font-size: 0.875rem; + color: #d1d5db; +} + +.log-time { + font-size: 0.75rem; + color: #6b7280; + white-space: nowrap; +} + +/* ── Footer ───────────────────────────────────────────────────────────── */ + +.profile-footer { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); + display: flex; + justify-content: space-between; + font-size: 0.875rem; + color: #6b7280; +} + +.footer-links { + display: flex; + gap: 1.5rem; +} + +.footer-links a { + color: #6b7280; + text-decoration: none; +} + +.footer-links a:hover { + color: #9ca3af; +} diff --git a/apps/web/src/app/pages/profile/profile.html b/apps/web/src/app/pages/profile/profile.html new file mode 100644 index 0000000..a0969cc --- /dev/null +++ b/apps/web/src/app/pages/profile/profile.html @@ -0,0 +1,416 @@ +@if (loading) { +
+
+

Loading profile…

+
+} @else if (error) { +
+

{{ error }}

+ +
+} @else { + +
+ + +
+
+

Profile

+

View and manage your profile, stats, and achievements.

+
+ +
+ + +
+ + +
+

Hey there, {{ firstName }}! 👋

+

{{ role }} • From {{ location }}

+

Student at {{ university }}

+ + +
+
+ +
+
Joined
+
{{ joinDate }}
+
+
+
+ +
+
Last Active
+
{{ lastActive }}
+
+
+
+ +
+
Time Zone
+
{{ timeZone }}
+
+
+
+ +
+
Member for
+
{{ memberFor }}
+
+
+
+ + @if (bio) { + +
+ +

"{{ bio }}"

+
+ } + + + +
+ + +
+
+
{{ username.charAt(0).toUpperCase() }}
+
+
+
{{ username }}
+
{{ email }}
+
(not visible)
+ + +
+
+ + +
+ @for (metric of metrics; track metric.label) { +
+
+ +
+
{{ metric.label }}
+
{{ metric.value }}
+
{{ metric.sub }}
+
+ } +
+ + +
+ + +
+
+
+

Activity Overview

+ +
+
+ Total active days: {{ totalActiveDays }} + Max streak: {{ maxStreak }} +
+
+ +
+
+
+ @for (label of dayLabels; track $index) { + {{ label }} + } +
+
+ @for (week of heatmapData; track $index) { +
+ @for (cell of week; track $index) { +
+ } +
+ } +
+
+ +
+ @for (month of months; track $index) { + {{ month }} + } +
+
+ + +
+ + +
+
+

Problem Solving

+ +
+ +
+
+ + + + + + + + + + +
+
{{ solved }}/{{ totalProblems }}
+
Solved
+
+
+
+ + +
+
+ + +
+ + +
+ + +
+
+
+ + + + + {{ completionPercent }}% +
+
+

Personal Info

+

Complete your personal details

+
+ +
+ +
+ @for (field of infoFields; track field.label) { + + } +
+
+ + +
+
+

Projects

+ +
+ +
+
+ +
+

No projects information available

+

Add your projects to showcase your technical skills and contributions.

+ +
+
+
+ + +
+ + +
+
+

Achievements

+ View all +
+ + @if (achievements.length === 0) { +

No achievements yet.

+ } @else { +
+ @for (ach of achievements; track ach.title) { +
+
+ +
+
+

{{ ach.title }}

+

{{ ach.description }}

+
{{ ach.date }}
+
+
+ } +
+ } +
+ + +
+
+

Recent Activity

+ View all +
+ + @if (recentActivity.length === 0) { +

No recent activity.

+ } @else { +
+ @for (item of recentActivity; track item.text) { +
+
+ + {{ item.text }} +
+ {{ item.time }} +
+ } +
+ } +
+
+
+ + + +
+ + +@if (showEditModal()) { + +} + +} \ No newline at end of file diff --git a/apps/web/src/app/pages/profile/profile.service.ts b/apps/web/src/app/pages/profile/profile.service.ts new file mode 100644 index 0000000..c5acdcd --- /dev/null +++ b/apps/web/src/app/pages/profile/profile.service.ts @@ -0,0 +1,100 @@ +import { inject, Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ApiService } from '../../core/api/api.service'; + +// ── Response interfaces (mirror models.ProfileResponse from the Go backend) ── + +export interface HeatmapCell { + date: string; + level: number; // 0-4 +} + +export interface ProfileInfoField { + key: string; + label: string; + completed: boolean; +} + +export interface Achievement { + key: string; + title: string; + description: string; + badgeClass: string; + earnedAt: string; // "Jan 02, 2006" +} + +export interface ActivityLogItem { + kind: string; + text: string; + at: string; // RFC3339 +} + +export interface ProfileResponse { + // Identity + username: string; + email: string; + firstName: string; + avatarUrl: string; + + // Bio + role: string; + location: string; + university: string; + bio: string; + timeZone: string; + website: string; + gender: string; + languages: string[]; + + // Date strings + joinDate: string; + lastActive: string; + memberFor: string; + + // Coding metrics + totalCodingSeconds: number; + totalActiveDays: number; + currentStreak: number; + maxStreak: number; + + // Problem solving (placeholder) + solved: number; + totalProblems: number; + attempting: number; + + // Personal info completion + completionPercent: number; + infoFields: ProfileInfoField[]; + + // Rich data + heatmap: HeatmapCell[][]; + recentActivity: ActivityLogItem[]; + achievements: Achievement[]; +} + +@Injectable({ providedIn: 'root' }) +export class ProfileService { + private api = inject(ApiService); + + /** Fetches the full profile for the currently logged-in user. */ + getProfile(): Observable { + return this.api.get('/api/profile'); + } + + /** Updates the allowed profile fields. */ + updateProfile(data: UpdateProfileRequest): Observable { + return this.api.patch('/api/profile', data); + } +} + +export interface UpdateProfileRequest { + firstName?: string | null; + bio?: string | null; + location?: string | null; + role?: string | null; + university?: string | null; + website?: string | null; + gender?: string | null; + languages?: string[] | null; + timeZone?: string | null; +} diff --git a/apps/web/src/app/pages/profile/profile.ts b/apps/web/src/app/pages/profile/profile.ts new file mode 100644 index 0000000..286578c --- /dev/null +++ b/apps/web/src/app/pages/profile/profile.ts @@ -0,0 +1,434 @@ +import { Component, inject, OnInit, ChangeDetectorRef, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ToastService } from '../../core/toast/toast.service'; +import { + LucideAngularModule, + Clock, + Calendar, + Flame, + Star, + Trophy, + Users, + Pencil, + Camera, + Trash2, + Info, + Eye, + FileText, + MapPin, + UsersRound, + Languages, + Check, + Plus, + Flag, + Code, + Crown, + Sun, + Zap, + Award, + FolderOpen, + Activity, + CircleUser, + Link as LinkIcon, +} from 'lucide-angular'; +import { ProfileService, ProfileResponse, HeatmapCell, Achievement, ActivityLogItem, UpdateProfileRequest } from './profile.service'; + +// ── Local display interfaces (icons are frontend-only) ──────────────────────── + +interface Metric { + icon: typeof Clock; + iconClass: string; + label: string; + value: string; + sub: string; + positive: boolean; +} + +interface DisplayAchievement { + icon: typeof Sun; + badgeClass: string; + title: string; + description: string; + date: string; +} + +interface DisplayActivity { + icon: typeof Zap; + text: string; + time: string; +} + +interface DisplayInfoField { + icon: typeof CircleUser; + label: string; + completed: boolean; +} + +@Component({ + selector: 'app-profile', + standalone: true, + imports: [LucideAngularModule, FormsModule], + templateUrl: './profile.html', + styleUrl: './profile.css', +}) +export class Profile implements OnInit { + private profileService = inject(ProfileService); + private cdr = inject(ChangeDetectorRef); + private toast = inject(ToastService); + + // ── Icons (static, not from backend) ───────────────────────────────────── + readonly ClockIcon = Clock; + readonly CalendarIcon = Calendar; + readonly FlameIcon = Flame; + readonly StarIcon = Star; + readonly TrophyIcon = Trophy; + readonly UsersIcon = Users; + readonly PencilIcon = Pencil; + readonly CameraIcon = Camera; + readonly TrashIcon = Trash2; + readonly InfoIcon = Info; + readonly EyeIcon = Eye; + readonly FileTextIcon = FileText; + readonly MapPinIcon = MapPin; + readonly UsersRoundIcon = UsersRound; + readonly LanguagesIcon = Languages; + readonly CheckIcon = Check; + readonly PlusIcon = Plus; + readonly FlagIcon = Flag; + readonly CodeIcon = Code; + readonly CrownIcon = Crown; + readonly SunIcon = Sun; + readonly ZapIcon = Zap; + readonly AwardIcon = Award; + readonly FolderIcon = FolderOpen; + readonly ActivityIcon = Activity; + readonly CircleUserIcon = CircleUser; + readonly LinkIcon = LinkIcon; + + // ── State ───────────────────────────────────────────────────────────────── + loading = true; + error: string | null = null; + profileData: ProfileResponse | null = null; + + showEditModal = signal(false); + savingProfile = signal(false); + editForm: UpdateProfileRequest = {}; + editLanguagesStr = ''; + + // ── Profile fields (populated from API) ─────────────────────────────────── + username = ''; + firstName = ''; + email = ''; + role = ''; + location = ''; + university = ''; + bio = ''; + joinDate = ''; + lastActive = ''; + timeZone = ''; + memberFor = ''; + + // Heatmap (52 weeks × 7 days from backend) + heatmapData: HeatmapCell[][] = []; + totalActiveDays = 0; + maxStreak = 0; + + // Problem Solving gauge + solved = 0; + totalProblems = 0; + attempting = 0; + + // Gauge arc paths (computed from API data) + gaugeArcs = this.generateGaugeArcs(); + + // Achievements + achievements: DisplayAchievement[] = []; + + // Recent Activity + recentActivity: DisplayActivity[] = []; + + // Personal info completion + completionPercent = 0; + infoFields: DisplayInfoField[] = []; + + // Metrics row (built from API numeric fields) + metrics: Metric[] = []; + + // Static labels for the heatmap axes + readonly months = ['Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + readonly dayLabels = ['Mon', '', 'Wed', '', 'Fri', '', '']; + + // ── Lifecycle ───────────────────────────────────────────────────────────── + ngOnInit(): void { + console.log('ngOnInit: calling profileService.getProfile()'); + this.profileService.getProfile().subscribe({ + next: (data) => { + console.log('API response received data:', data); + try { + this.applyProfile(data); + } catch (e) { + console.error('Error applying profile:', e); + this.error = 'Failed to apply profile data'; + this.loading = false; + } + this.cdr.detectChanges(); + }, + error: (err) => { + console.error('API request error:', err); + this.error = err?.error?.message ?? 'Failed to load profile. Please try again.'; + this.loading = false; + this.cdr.detectChanges(); + }, + }); + } + + // ── Progress ring (reactive getters) ───────────────────────────────────── + get progressCircumference(): number { + return 2 * Math.PI * 18; // radius = 18 + } + + get progressOffset(): number { + return this.progressCircumference - (this.completionPercent / 100) * this.progressCircumference; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private applyProfile(data: ProfileResponse): void { + this.profileData = data; + // Identity + this.username = data.username; + this.firstName = data.firstName || data.username; + this.email = data.email; + this.role = data.role || 'Developer'; + this.location = data.location || '—'; + this.university = data.university || '—'; + this.bio = data.bio || ''; + this.joinDate = data.joinDate; + this.lastActive = data.lastActive; + this.timeZone = data.timeZone || '—'; + this.memberFor = data.memberFor; + + // Metrics row + this.totalActiveDays = data.totalActiveDays; + this.maxStreak = data.maxStreak; + this.metrics = this.buildMetrics(data); + + // Problem solving + this.solved = data.solved; + this.totalProblems = data.totalProblems; + this.attempting = data.attempting; + this.gaugeArcs = this.generateGaugeArcs(); + + // Heatmap + this.heatmapData = data.heatmap ?? []; + + // Achievements + this.achievements = (data.achievements ?? []).map((a) => this.mapAchievement(a)); + + // Recent activity + this.recentActivity = (data.recentActivity ?? []).map((item) => this.mapActivity(item)); + + // Personal info + this.completionPercent = data.completionPercent; + this.infoFields = (data.infoFields ?? []).map((f) => ({ + icon: this.iconForInfoKey(f.key), + label: f.label, + completed: f.completed, + })); + + this.loading = false; + } + + private buildMetrics(data: ProfileResponse): Metric[] { + const { totalCodingSeconds, totalActiveDays, currentStreak, maxStreak } = data; + + const totalHours = Math.floor(totalCodingSeconds / 3600); + const totalMins = Math.floor((totalCodingSeconds % 3600) / 60); + const codingTimeValue = totalCodingSeconds > 0 ? `${totalHours}h ${totalMins}m` : '0h 0m'; + + return [ + { + icon: Clock, + iconClass: 'green', + label: 'Total Coding Time', + value: codingTimeValue, + sub: 'All time', + positive: true, + }, + { + icon: Calendar, + iconClass: 'blue', + label: 'Active Days', + value: String(totalActiveDays), + sub: 'Days with activity', + positive: true, + }, + { + icon: Flame, + iconClass: 'purple', + label: 'Current Streak', + value: `${currentStreak} days`, + sub: `Best: ${maxStreak} days`, + positive: false, + }, + { + icon: Star, + iconClass: 'gold', + label: 'Contest Rating', + // TODO: no data source yet — user_problem_stats is a placeholder + value: String(data.solved > 0 ? 0 : 0), + sub: 'Placeholder', + positive: false, + }, + { + icon: Trophy, + iconClass: 'blue', + label: 'Contribution', + // TODO: no data source yet — user_problem_stats is a placeholder + value: '—', + sub: 'Placeholder', + positive: false, + }, + { + icon: Users, + iconClass: 'orange', + label: 'Friends', + // TODO: no data source yet + value: '—', + sub: 'No friends data', + positive: false, + }, + ]; + } + + /** Maps a backend Achievement to a display object with a frontend icon. */ + private mapAchievement(a: Achievement): DisplayAchievement { + const iconMap: Record = { + first_blood: Sun, + night_owl: Star, + streak_7: Flame, + default: Award, + }; + return { + icon: iconMap[a.key] ?? iconMap['default'], + badgeClass: a.badgeClass, + title: a.title, + description: a.description, + date: `Earned ${a.earnedAt}`, + }; + } + + /** Maps a backend ActivityLogItem to a display object with a frontend icon. */ + private mapActivity(item: ActivityLogItem): DisplayActivity { + const iconMap: Record = { + achievement: Award, + streak: Flame, + profile: CircleUser, + project: FolderOpen, + default: Zap, + }; + return { + icon: iconMap[item.kind] ?? iconMap['default'], + text: item.text, + time: this.formatRelativeTime(item.at), + }; + } + + /** Returns the correct icon for a personal-info field key. */ + private iconForInfoKey(key: string): typeof CircleUser { + const map: Record = { + fullName: CircleUser, + bio: FileText, + location: MapPin, + gender: UsersRound, + languages: Languages, + }; + return map[key] ?? CircleUser; + } + + /** Converts an RFC3339 timestamp to a short relative-time string. */ + private formatRelativeTime(at: string): string { + if (!at) return ''; + const diff = Date.now() - new Date(at).getTime(); + const minutes = Math.floor(diff / 60_000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours > 1 ? 's' : ''} ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days} day${days > 1 ? 's' : ''} ago`; + const weeks = Math.floor(days / 7); + return `${weeks} week${weeks > 1 ? 's' : ''} ago`; + } + + /** Recomputes the gauge SVG arc paths whenever solved/totalProblems change. */ + private generateGaugeArcs(): { tealPath: string; goldPath: string; redPath: string } { + const cx = 100, cy = 100, r = 80; + const startAngle = Math.PI; + + const tealEnd = startAngle + Math.PI * 0.6; + const goldEnd = startAngle + Math.PI * 0.85; + const redEnd = startAngle + Math.PI * 1.0; + + const arc = (start: number, end: number): string => { + const x1 = cx + r * Math.cos(start); + const y1 = cy + r * Math.sin(start); + const x2 = cx + r * Math.cos(end); + const y2 = cy + r * Math.sin(end); + const large = end - start > Math.PI ? 1 : 0; + return `M ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2}`; + }; + + return { + tealPath: arc(startAngle, tealEnd), + goldPath: arc(tealEnd, goldEnd), + redPath: arc(goldEnd, redEnd), + }; + } + + // ── Edit Profile Modal ─────────────────────────────────────────────────── + + openEditModal() { + if (!this.profileData) return; + this.editForm = { + firstName: this.profileData.firstName || '', + bio: this.profileData.bio || '', + location: this.profileData.location || '', + role: this.profileData.role || '', + university: this.profileData.university || '', + website: this.profileData.website || '', + gender: this.profileData.gender || '', + timeZone: this.profileData.timeZone || '', + }; + this.editLanguagesStr = (this.profileData.languages || []).join(', '); + this.showEditModal.set(true); + } + + closeEditModal() { + this.showEditModal.set(false); + } + + saveProfile() { + const langs = this.editLanguagesStr + .split(',') + .map((s) => s.trim()) + .filter((s) => s.length > 0); + + this.editForm.languages = langs; + + this.savingProfile.set(true); + this.profileService.updateProfile(this.editForm).subscribe({ + next: () => { + this.savingProfile.set(false); + this.showEditModal.set(false); + this.toast.success('Profile updated successfully'); + this.loading = true; + this.ngOnInit(); + }, + error: (err) => { + this.savingProfile.set(false); + this.toast.error(err?.error?.message ?? 'Failed to update profile'); + }, + }); + } +} diff --git a/apps/web/src/app/shared/components/target-cursor/index.ts b/apps/web/src/app/shared/components/target-cursor/index.ts new file mode 100644 index 0000000..2b93394 --- /dev/null +++ b/apps/web/src/app/shared/components/target-cursor/index.ts @@ -0,0 +1,2 @@ +export * from './target-cursor.component'; + diff --git a/apps/web/src/app/shared/components/target-cursor/target-cursor.component.ts b/apps/web/src/app/shared/components/target-cursor/target-cursor.component.ts new file mode 100644 index 0000000..48375cf --- /dev/null +++ b/apps/web/src/app/shared/components/target-cursor/target-cursor.component.ts @@ -0,0 +1,474 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, NgZone, OnDestroy, OnInit, inject } from '@angular/core'; +import { gsap } from 'gsap'; +import type { GSAPTimeline } from 'gsap'; + + +// A position: fixed element is positioned relative to the viewport UNLESS an +// ancestor establishes a containing block (transform, perspective, filter, +// will-change of those, or contain). When that happens, the cursor's translate +// no longer maps to viewport coordinates, so we measure and compensate for it. +const getContainingBlock = (element: HTMLElement | null): HTMLElement | null => { + let node = element?.parentElement ?? null; + while (node && node !== document.documentElement) { + const style = getComputedStyle(node); + if ( + style.transform !== 'none' || + style.perspective !== 'none' || + style.filter !== 'none' || + style.willChange.includes('transform') || + style.willChange.includes('perspective') || + style.willChange.includes('filter') || + /paint|layout|strict|content/.test(style.contain) + ) { + return node; + } + node = node.parentElement; + } + return null; +}; + +const getContainingBlockOffset = (block: HTMLElement | null): { x: number; y: number } => { + if (!block) return { x: 0, y: 0 }; + const rect = block.getBoundingClientRect(); + return { x: rect.left + block.clientLeft, y: rect.top + block.clientTop }; +}; + +export interface TargetCursorProps { + targetSelector?: string; + spinDuration?: number; + hideDefaultCursor?: boolean; + hoverDuration?: number; + parallaxOn?: boolean; + cursorColor?: string; + cursorColorOnTarget?: string; +} + +@Component({ + selector: 'app-target-cursor', + standalone: true, + imports: [CommonModule], + template: ` +
+
+
+
+
+
+
+ `, + styles: [ + ` + .target-cursor-wrapper { + position: fixed; + top: 0; + left: 0; + width: 0; + height: 0; + pointer-events: none; + z-index: 9999; + mix-blend-mode: difference; + transform: translate(-50%, -50%); + } + + .target-cursor-dot { + position: absolute; + left: 50%; + top: 50%; + width: 4px; + height: 4px; + background: #fff; + border-radius: 50%; + transform: translate(-50%, -50%); + will-change: transform; + } + + .target-cursor-corner { + position: absolute; + left: 50%; + top: 50%; + width: 12px; + height: 12px; + border: 3px solid #fff; + will-change: transform; + } + + .corner-tl { + transform: translate(-150%, -150%); + border-right: none; + border-bottom: none; + } + + .corner-tr { + transform: translate(50%, -150%); + border-left: none; + border-bottom: none; + } + + .corner-br { + transform: translate(50%, 50%); + border-left: none; + border-top: none; + } + + .corner-bl { + transform: translate(-150%, 50%); + border-right: none; + border-top: none; + } + `, + ], +}) +export class TargetCursorComponent implements OnInit, OnDestroy { + @Input() targetSelector = '.cursor-target'; + @Input() spinDuration = 2; + @Input() hideDefaultCursor = true; + @Input() hoverDuration = 0.2; + @Input() parallaxOn = true; + @Input() cursorColor = '#ffffff'; + @Input() cursorColorOnTarget?: string; + + private zone = inject(NgZone); + + private cursorEl: HTMLDivElement | null = null; + private dotEl: HTMLDivElement | null = null; + private cornersEls: HTMLDivElement[] = []; + private containingBlockEl: HTMLElement | null = null; + + private spinTl: gsap.core.Timeline | null = null; + + private isActive = false; + private targetCornerPositions: { x: number; y: number }[] | null = null; + private activeStrength = { current: 0 }; + + private resumeTimeout: ReturnType | null = null; + private currentLeaveHandler: (() => void) | null = null; + private activeTarget: Element | null = null; + + private originalCursor = ''; + + private readonly isMobile = (() => { + if (typeof window === 'undefined') return true; + const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0; + const isSmallScreen = window.innerWidth <= 768; + const userAgent = navigator.userAgent || (navigator as any).vendor || (window as any).opera; + const mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i; + const isMobileUserAgent = mobileRegex.test(userAgent.toLowerCase()); + return (hasTouchScreen && isSmallScreen) || isMobileUserAgent; + })(); + + private readonly constants = { borderWidth: 3, cornerSize: 12 }; + + ngOnInit(): void { + if (this.isMobile) return; + + // Run outside Angular change detection. + this.zone.runOutsideAngular(() => { + this.initCursor(); + }); + } + + private initCursor(): void { + this.cursorEl = document.querySelector('.target-cursor-wrapper'); + this.dotEl = document.querySelector('.target-cursor-dot'); + if (!this.cursorEl) return; + + this.cornersEls = Array.from( + this.cursorEl.querySelectorAll('.target-cursor-corner') + ); + + if (this.hideDefaultCursor) { + this.originalCursor = document.body.style.cursor; + document.body.style.cursor = 'none'; + } + + this.containingBlockEl = getContainingBlock(this.cursorEl); + + const moveCursor = (x: number, y: number) => { + if (!this.cursorEl) return; + const { x: offsetX, y: offsetY } = getContainingBlockOffset(this.containingBlockEl); + gsap.to(this.cursorEl, { + x: x - offsetX, + y: y - offsetY, + duration: 0.1, + ease: 'power3.out', + }); + }; + + const getOffset = () => getContainingBlockOffset(this.containingBlockEl); + + const cleanupTarget = (target: Element) => { + if (this.currentLeaveHandler) { + target.removeEventListener('mouseleave', this.currentLeaveHandler as EventListener); + } + this.currentLeaveHandler = null; + }; + + const { x: initialX, y: initialY } = getOffset(); + gsap.set(this.cursorEl, { + xPercent: -50, + yPercent: -50, + x: window.innerWidth / 2 - initialX, + y: window.innerHeight / 2 - initialY, + }); + + const createSpinTimeline = () => { + if (this.spinTl) this.spinTl.kill(); + this.spinTl = gsap.timeline({ repeat: -1 }).to(this.cursorEl!, { + rotation: '+=360', + duration: this.spinDuration, + ease: 'none', + }); + }; + + createSpinTimeline(); + + const tickerFn = () => { + if (!this.targetCornerPositions || !this.cursorEl || this.cornersEls.length === 0) return; + const strength = this.activeStrength.current; + if (strength === 0) return; + + const cursorX = gsap.getProperty(this.cursorEl, 'x') as number; + const cursorY = gsap.getProperty(this.cursorEl, 'y') as number; + + this.cornersEls.forEach((corner, i) => { + const currentX = gsap.getProperty(corner, 'x') as number; + const currentY = gsap.getProperty(corner, 'y') as number; + + const targetX = this.targetCornerPositions![i].x - cursorX; + const targetY = this.targetCornerPositions![i].y - cursorY; + + const finalX = currentX + (targetX - currentX) * strength; + const finalY = currentY + (targetY - currentY) * strength; + + const duration = strength >= 0.99 ? (this.parallaxOn ? 0.2 : 0) : 0.05; + + gsap.to(corner, { + x: finalX, + y: finalY, + duration, + ease: duration === 0 ? 'none' : 'power1.out', + overwrite: 'auto', + }); + }); + }; + + const onMouseMove = (e: MouseEvent) => moveCursor(e.clientX, e.clientY); + window.addEventListener('mousemove', onMouseMove); + + const onScroll = () => { + if (!this.activeTarget || !this.cursorEl) return; + const { x: offsetX, y: offsetY } = getOffset(); + const mouseX = (gsap.getProperty(this.cursorEl, 'x') as number) + offsetX; + const mouseY = (gsap.getProperty(this.cursorEl, 'y') as number) + offsetY; + const elementUnderMouse = document.elementFromPoint(mouseX, mouseY); + + const stillOverTarget = + elementUnderMouse && + (elementUnderMouse === this.activeTarget || + (elementUnderMouse as Element).closest(this.targetSelector) === this.activeTarget); + + if (!stillOverTarget) { + this.currentLeaveHandler?.(); + } + }; + window.addEventListener('scroll', onScroll, { passive: true }); + + const onMouseDown = () => { + if (!this.dotEl || !this.cursorEl) return; + gsap.to(this.dotEl, { scale: 0.7, duration: 0.3 }); + gsap.to(this.cursorEl, { scale: 0.9, duration: 0.2 }); + }; + + const onMouseUp = () => { + if (!this.dotEl || !this.cursorEl) return; + gsap.to(this.dotEl, { scale: 1, duration: 0.3 }); + gsap.to(this.cursorEl, { scale: 1, duration: 0.2 }); + }; + + window.addEventListener('mousedown', onMouseDown); + window.addEventListener('mouseup', onMouseUp); + + const onMouseOver = (e: MouseEvent) => { + const directTarget = e.target as Element; + const allTargets: Element[] = []; + let current: Element | null = directTarget; + while (current && current !== document.body) { + if ((current as any).matches?.(this.targetSelector)) { + allTargets.push(current); + } + current = current.parentElement; + } + const target = allTargets[0] ?? null; + if (!target || !this.cursorEl || this.cornersEls.length === 0) return; + if (this.activeTarget === target) return; + + if (this.activeTarget) cleanupTarget(this.activeTarget); + if (this.resumeTimeout) { + clearTimeout(this.resumeTimeout); + this.resumeTimeout = null; + } + + this.activeTarget = target; + + const { borderWidth, cornerSize } = this.constants; + const rect = target.getBoundingClientRect(); + const { x: offsetX, y: offsetY } = getOffset(); + const cursorX = gsap.getProperty(this.cursorEl, 'x') as number; + const cursorY = gsap.getProperty(this.cursorEl, 'y') as number; + + this.cornersEls.forEach((corner) => gsap.killTweensOf(corner, 'x,y')); + gsap.killTweensOf(this.cursorEl, 'rotation'); + this.spinTl?.pause(); + gsap.set(this.cursorEl, { rotation: 0 }); + + if (this.cursorColorOnTarget) { + gsap.to(this.cornersEls, { + borderColor: this.cursorColorOnTarget, + duration: 0.15, + ease: 'power2.out', + }); + if (this.dotEl) { + gsap.to(this.dotEl, { + backgroundColor: this.cursorColorOnTarget, + duration: 0.15, + ease: 'power2.out', + }); + } + } + + this.targetCornerPositions = [ + { x: rect.left - borderWidth - offsetX, y: rect.top - borderWidth - offsetY }, + { + x: rect.right + borderWidth - cornerSize - offsetX, + y: rect.top - borderWidth - offsetY, + }, + { + x: rect.right + borderWidth - cornerSize - offsetX, + y: rect.bottom + borderWidth - cornerSize - offsetY, + }, + { + x: rect.left - borderWidth - offsetX, + y: rect.bottom + borderWidth - cornerSize - offsetY, + }, + ]; + + this.isActive = true; + gsap.ticker.add(tickerFn); + + gsap.to(this.activeStrength, { + current: 1, + duration: this.hoverDuration, + ease: 'power2.out', + }); + + this.cornersEls.forEach((corner, i) => { + gsap.to(corner, { + x: this.targetCornerPositions![i].x - cursorX, + y: this.targetCornerPositions![i].y - cursorY, + duration: 0.2, + ease: 'power2.out', + }); + }); + + this.currentLeaveHandler = () => { + gsap.ticker.remove(tickerFn); + this.isActive = false; + this.targetCornerPositions = null; + gsap.set(this.activeStrength, { current: 0, overwrite: true }); + this.activeTarget = null; + + if (this.cursorColorOnTarget && this.cornersEls.length) { + gsap.to(this.cornersEls, { + borderColor: this.cursorColor, + duration: 0.15, + ease: 'power2.out', + }); + if (this.dotEl) { + gsap.to(this.dotEl, { + backgroundColor: this.cursorColor, + duration: 0.15, + ease: 'power2.out', + }); + } + } + + const tlPositions = [ + { x: -cornerSize * 1.5, y: -cornerSize * 1.5 }, + { x: cornerSize * 0.5, y: -cornerSize * 1.5 }, + { x: cornerSize * 0.5, y: cornerSize * 0.5 }, + { x: -cornerSize * 1.5, y: cornerSize * 0.5 }, + ]; + + this.cornersEls.forEach((corner, idx) => gsap.killTweensOf(corner, 'x,y')); + const tl = gsap.timeline(); + this.cornersEls.forEach((corner, index) => { + tl.to( + corner, + { + x: tlPositions[index].x, + y: tlPositions[index].y, + duration: 0.3, + ease: 'power3.out', + }, + 0 + ); + }); + + this.resumeTimeout = setTimeout(() => { + if (!this.activeTarget && this.cursorEl && this.spinTl) { + const currentRotation = gsap.getProperty(this.cursorEl, 'rotation') as number; + const normalizedRotation = currentRotation % 360; + this.spinTl.kill(); + this.spinTl = gsap + .timeline({ repeat: -1 }) + .to(this.cursorEl, { rotation: '+=360', duration: this.spinDuration, ease: 'none' }); + + gsap.to(this.cursorEl, { + rotation: normalizedRotation + 360, + duration: this.spinDuration * (1 - normalizedRotation / 360), + ease: 'none', + onComplete: () => { + this.spinTl?.restart(); + }, + }); + } + this.resumeTimeout = null; + }, 50); + + cleanupTarget(target); + }; + + target.addEventListener('mouseleave', this.currentLeaveHandler as EventListener); + }; + + window.addEventListener('mouseover', onMouseOver as EventListener, { passive: true }); + + const onResize = () => { + this.containingBlockEl = getContainingBlock(this.cursorEl); + }; + window.addEventListener('resize', onResize); + + // Store handlers for cleanup using closures captured above. + (this as any)._cleanup = () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('scroll', onScroll); + window.removeEventListener('mousedown', onMouseDown); + window.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('mouseover', onMouseOver as EventListener); + window.removeEventListener('resize', onResize); + if (this.activeTarget) cleanupTarget(this.activeTarget); + this.spinTl?.kill(); + if (this.hideDefaultCursor) document.body.style.cursor = this.originalCursor; + this.isActive = false; + this.targetCornerPositions = null; + this.activeStrength.current = 0; + }; + } + + ngOnDestroy(): void { + const cleanup = (this as any)._cleanup as undefined | (() => void); + cleanup?.(); + } +} + From 60f008c2e7fb4771b83fdab638a640f0afa276e3 Mon Sep 17 00:00:00 2001 From: Amr Mousa Date: Wed, 8 Jul 2026 23:56:15 +0300 Subject: [PATCH 7/7] final update --- apps/api/handlers/auth.go | 7 +- apps/api/handlers/profile.go | 18 +- apps/api/routes/routes.go | 7 +- apps/web/src/app/app.routes.ts | 3 +- apps/web/src/app/pages/auth/login/login.ts | 9 +- apps/web/src/app/pages/auth/verify/verify.ts | 4 +- apps/web/src/app/pages/profile/profile.css | 2 + apps/web/src/app/pages/profile/profile.html | 21 +- apps/web/src/app/pages/profile/profile.ts | 4 +- .../components/magic-bento/magic-bento.css | 196 ++++++++ .../magic-bento/magic-bento.directive.ts | 441 ++++++++++++++++++ .../target-cursor/target-cursor.component.ts | 1 - 12 files changed, 674 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/app/shared/components/magic-bento/magic-bento.css create mode 100644 apps/web/src/app/shared/components/magic-bento/magic-bento.directive.ts diff --git a/apps/api/handlers/auth.go b/apps/api/handlers/auth.go index b1f96f9..1363e8a 100644 --- a/apps/api/handlers/auth.go +++ b/apps/api/handlers/auth.go @@ -53,10 +53,13 @@ func (h *AuthHandler) RequestMagicLink(c *fiber.Ctx) error { err = services.SendMagicLinkEmail(h.EmailCfg, email, link.Token) if err != nil { - return helpers.Error(c, fiber.StatusInternalServerError, "Failed to send login email") + // TEMP DEV BYPASS: Ignore email failure so we can test login locally + // return helpers.Error(c, fiber.StatusInternalServerError, "Failed to send login email") } - return helpers.Success(c, "Check your email for a login link", nil) + return helpers.Success(c, "Check your email for a login link", fiber.Map{ + "devToken": link.Token, + }) } // VerifyMagicLink godoc diff --git a/apps/api/handlers/profile.go b/apps/api/handlers/profile.go index fef00c6..b4a215b 100644 --- a/apps/api/handlers/profile.go +++ b/apps/api/handlers/profile.go @@ -27,16 +27,9 @@ type ProfileHandler struct { // @Failure 401 {object} helpers.APIResponse // @Router /api/profile [get] func (h *ProfileHandler) GetProfile(c *fiber.Ctx) error { - // userID := c.Locals("userID").(string) // TEMP DEV: Disabled + userID := c.Locals("userID").(string) ctx := c.Context() - // TEMP DEV: Find the testuser's ID - var userID string - _ = h.Pool.QueryRow(ctx, "SELECT id FROM users WHERE username = 'sarahchen' LIMIT 1").Scan(&userID) - if userID == "" { - return helpers.Error(c, fiber.StatusNotFound, "sarahchen not found, please run seed_dummy_data.sql") - } - // ── 1. User row ────────────────────────────────────────────────────────── @@ -312,16 +305,9 @@ func secondsToLevel(sec int) int { // @Failure 400 {object} helpers.APIResponse // @Router /api/profile [patch] func (h *ProfileHandler) UpdateProfile(c *fiber.Ctx) error { - // userID := c.Locals("userID").(string) // TEMP DEV: Disabled + userID := c.Locals("userID").(string) ctx := c.Context() - // TEMP DEV: Find the testuser's ID - var userID string - _ = h.Pool.QueryRow(ctx, "SELECT id FROM users WHERE username = 'sarahchen' LIMIT 1").Scan(&userID) - if userID == "" { - return helpers.Error(c, fiber.StatusNotFound, "sarahchen not found") - } - var req models.UpdateProfileRequest if err := c.BodyParser(&req); err != nil { return helpers.Error(c, fiber.StatusBadRequest, "Invalid request body") diff --git a/apps/api/routes/routes.go b/apps/api/routes/routes.go index 7ee26b6..3ed7c2c 100644 --- a/apps/api/routes/routes.go +++ b/apps/api/routes/routes.go @@ -11,10 +11,11 @@ import ( func Setup(app *fiber.App, authHandler *handlers.AuthHandler, heartbeatHandler *handlers.HeartbeatHandler, adminHandler *handlers.AdminHandler, statsHandler *handlers.StatsHandler, filtersHandler *handlers.FiltersHandler, jwtSecret string, pool *pgxpool.Pool) { app.Get("/health", handlers.HealthCheck) - // Profile route – TEMP DEV: unprotected so we can view the page without login + // Profile routes profileHandler := &handlers.ProfileHandler{Pool: pool} - app.Get("/api/profile", profileHandler.GetProfile) - app.Patch("/api/profile", profileHandler.UpdateProfile) + profile := app.Group("/api/profile", middleware.RequireAuth(jwtSecret)) + profile.Get("/", profileHandler.GetProfile) + profile.Patch("/", profileHandler.UpdateProfile) auth := app.Group("/api/auth") diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 8783183..928c3b4 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -19,7 +19,6 @@ export const routes: Routes = [ }, { path: 'leaderboard', component: Leaderboard, title: 'Leaderboard — Seismic' }, { path: 'settings', component: Settings, canActivate: [authGuard], title: 'Settings — Seismic' }, - // TEMP DEV: Removed authGuard so we can view the page without login - { path: 'profile', component: Profile, title: 'Profile — Seismic' }, + { path: 'profile', component: Profile, canActivate: [authGuard], title: 'Profile — Seismic' }, ]; diff --git a/apps/web/src/app/pages/auth/login/login.ts b/apps/web/src/app/pages/auth/login/login.ts index adbf33f..246ee62 100644 --- a/apps/web/src/app/pages/auth/login/login.ts +++ b/apps/web/src/app/pages/auth/login/login.ts @@ -1,6 +1,6 @@ import { Component, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; +import { RouterLink, Router } from '@angular/router'; import { NgOptimizedImage } from '@angular/common'; import { LucideAngularModule, Mailbox } from 'lucide-angular'; import { ApiService } from '../../../core/api/api.service'; @@ -15,6 +15,7 @@ import { ToastService } from '../../../core/toast/toast.service'; export class Login { private api = inject(ApiService); private toast = inject(ToastService); + private router = inject(Router); readonly MailboxIcon = Mailbox; @@ -28,7 +29,11 @@ export class Login { this.loading.set(true); this.api.post('/api/auth/magic-link', { email: this.email() }).subscribe({ - next: () => { + next: (res: any) => { + if (res.data?.devToken) { + void this.router.navigate(['/verify'], { queryParams: { token: res.data.devToken } }); + return; + } this.loading.set(false); this.sent.set(true); }, diff --git a/apps/web/src/app/pages/auth/verify/verify.ts b/apps/web/src/app/pages/auth/verify/verify.ts index 21f429e..1d4af61 100644 --- a/apps/web/src/app/pages/auth/verify/verify.ts +++ b/apps/web/src/app/pages/auth/verify/verify.ts @@ -78,7 +78,7 @@ export class Verify implements OnInit { } else if (data.accessToken) { this.auth.setToken(data.accessToken); this.userService.load(); - this.router.navigate(['/dashboard']); + this.router.navigate(['/profile']); } }, error: () => { @@ -134,7 +134,7 @@ export class Verify implements OnInit { next: (data) => { this.auth.setToken(data.accessToken); this.userService.load(); - void this.router.navigate(['/dashboard']); + void this.router.navigate(['/profile']); }, error: (err) => { this.signupLoading.set(false); diff --git a/apps/web/src/app/pages/profile/profile.css b/apps/web/src/app/pages/profile/profile.css index 2f35932..1cf4a66 100644 --- a/apps/web/src/app/pages/profile/profile.css +++ b/apps/web/src/app/pages/profile/profile.css @@ -1,3 +1,5 @@ +@import '../../shared/components/magic-bento/magic-bento.css'; + :host { display: block; } diff --git a/apps/web/src/app/pages/profile/profile.html b/apps/web/src/app/pages/profile/profile.html index a0969cc..8994e50 100644 --- a/apps/web/src/app/pages/profile/profile.html +++ b/apps/web/src/app/pages/profile/profile.html @@ -118,17 +118,20 @@

Hey there, {{ firstName }}! - - -
+ +
@for (metric of metrics; track metric.label) { -
-
- +
+
+
+ +
+
{{ metric.label }}
+
+
+

{{ metric.value }}

+

{{ metric.sub }}

-
{{ metric.label }}
-
{{ metric.value }}
-
{{ metric.sub }}
}
diff --git a/apps/web/src/app/pages/profile/profile.ts b/apps/web/src/app/pages/profile/profile.ts index 286578c..e8eb991 100644 --- a/apps/web/src/app/pages/profile/profile.ts +++ b/apps/web/src/app/pages/profile/profile.ts @@ -32,7 +32,7 @@ import { Link as LinkIcon, } from 'lucide-angular'; import { ProfileService, ProfileResponse, HeatmapCell, Achievement, ActivityLogItem, UpdateProfileRequest } from './profile.service'; - +import { MagicBentoDirective, MagicBentoCardDirective } from '../../shared/components/magic-bento/magic-bento.directive'; // ── Local display interfaces (icons are frontend-only) ──────────────────────── interface Metric { @@ -67,7 +67,7 @@ interface DisplayInfoField { @Component({ selector: 'app-profile', standalone: true, - imports: [LucideAngularModule, FormsModule], + imports: [LucideAngularModule, FormsModule, MagicBentoDirective, MagicBentoCardDirective], templateUrl: './profile.html', styleUrl: './profile.css', }) diff --git a/apps/web/src/app/shared/components/magic-bento/magic-bento.css b/apps/web/src/app/shared/components/magic-bento/magic-bento.css new file mode 100644 index 0000000..f3057bf --- /dev/null +++ b/apps/web/src/app/shared/components/magic-bento/magic-bento.css @@ -0,0 +1,196 @@ +:root { + --hue: 27; + --sat: 69%; + --white: hsl(0, 0%, 100%); + --purple-primary: rgba(132, 0, 255, 1); + --purple-glow: rgba(132, 0, 255, 0.2); + --purple-border: rgba(132, 0, 255, 0.8); + --border-color: #2F293A; + --background-dark: #120F17; + color-scheme: light dark; +} + +.card-grid { + display: grid; + gap: 0.5em; + padding: 0.75em; + width: 100%; + font-size: clamp(1rem, 0.9rem + 0.5vw, 1.5rem); +} + +.magic-bento-card { + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + aspect-ratio: 1/1; + min-height: 200px; + width: 100%; + max-width: 100%; + padding: 1.25em; + border-radius: 20px; + border: 1px solid var(--border-color); + background: var(--background-dark); + font-weight: 300; + overflow: hidden; + transition: all 0.3s ease; + + --glow-x: 50%; + --glow-y: 50%; + --glow-intensity: 0; + --glow-radius: 200px; +} + +.magic-bento-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); +} + +.magic-bento-card__header, +.magic-bento-card__content { + display: flex; + position: relative; + color: var(--white); + z-index: 10; +} + +.magic-bento-card__header { + gap: 0.75em; + justify-content: space-between; +} + +.magic-bento-card__content { + flex-direction: column; +} + +.magic-bento-card__label { + font-size: 14px; + font-weight: 700; + color: #e2e8f0; + letter-spacing: 0.5px; +} + +.magic-bento-card__title, +.magic-bento-card__description { + --clamp-title: 1; + --clamp-desc: 2; +} + +.magic-bento-card__title { + font-weight: 600; + font-size: 18px; + margin: 0 0 0.25em; +} + +.magic-bento-card__description { + font-size: 14px; + font-weight: 500; + line-height: 1.4; + opacity: 0.9; + color: #cbd5e1; +} + +.magic-bento-card--text-autohide .magic-bento-card__title, +.magic-bento-card--text-autohide .magic-bento-card__description { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.magic-bento-card--text-autohide .magic-bento-card__title { + -webkit-line-clamp: var(--clamp-title); + line-clamp: var(--clamp-title); +} + +.magic-bento-card--text-autohide .magic-bento-card__description { + -webkit-line-clamp: var(--clamp-desc); + line-clamp: var(--clamp-desc); +} + +@media (max-width: 599px) { + .card-grid { + grid-template-columns: 1fr; + width: 100%; + margin: 0 auto; + padding: 0.5em 0; + } + + .magic-bento-card { + width: 100%; + min-height: 180px; + } +} + +@media (min-width: 600px) { + .card-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1024px) { + .card-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Border glow effect */ +.magic-bento-card--border-glow::after { + content: ''; + position: absolute; + inset: 0; + padding: 6px; + background: radial-gradient( + var(--glow-radius) circle at var(--glow-x) var(--glow-y), + rgba(132, 0, 255, calc(var(--glow-intensity) * 0.8)) 0%, + rgba(132, 0, 255, calc(var(--glow-intensity) * 0.4)) 30%, + transparent 60% + ); + border-radius: inherit; + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + mask-composite: exclude; + pointer-events: none; + opacity: 1; + transition: opacity 0.3s ease; + z-index: 1; +} + +.magic-bento-card--border-glow:hover::after { + opacity: 1; +} + +.magic-bento-card--border-glow:hover { + box-shadow: + 0 4px 20px rgba(46, 24, 78, 0.4), + 0 0 30px var(--purple-glow); +} + +.particle-container { + position: relative; + overflow: hidden; +} + +.particle-container:hover { + box-shadow: + 0 4px 20px rgba(46, 24, 78, 0.2), + 0 0 30px var(--purple-glow); +} + +/* Global spotlight styles - using ::ng-deep to affect globally dynamically added div */ +::ng-deep .global-spotlight { + mix-blend-mode: screen; + will-change: transform, opacity; + z-index: 200 !important; + pointer-events: none; +} + +.bento-section { + position: relative; + user-select: none; +} diff --git a/apps/web/src/app/shared/components/magic-bento/magic-bento.directive.ts b/apps/web/src/app/shared/components/magic-bento/magic-bento.directive.ts new file mode 100644 index 0000000..4116328 --- /dev/null +++ b/apps/web/src/app/shared/components/magic-bento/magic-bento.directive.ts @@ -0,0 +1,441 @@ +import { Directive, Input, ElementRef, OnInit, OnDestroy, AfterViewInit, ContentChildren, QueryList, Inject, PLATFORM_ID, HostListener } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { gsap } from 'gsap'; + +const DEFAULT_PARTICLE_COUNT = 12; +const DEFAULT_SPOTLIGHT_RADIUS = 300; +const DEFAULT_GLOW_COLOR = '132, 0, 255'; +const MOBILE_BREAKPOINT = 768; + +const calculateSpotlightValues = (radius: number) => ({ + proximity: radius * 0.5, + fadeDistance: radius * 0.75 +}); + +const createParticleElement = (x: number, y: number, color: string = DEFAULT_GLOW_COLOR): HTMLDivElement => { + const el = document.createElement('div'); + el.className = 'particle'; + el.style.cssText = ` + position: absolute; + width: 4px; + height: 4px; + border-radius: 50%; + background: rgba(${color}, 1); + box-shadow: 0 0 6px rgba(${color}, 0.6); + pointer-events: none; + z-index: 100; + left: ${x}px; + top: ${y}px; + `; + return el; +}; + +@Directive({ + selector: '[appMagicBentoCard]', + standalone: true, + host: { + '[class.magic-bento-card--border-glow]': 'enableBorderGlow', + '[class.particle-container]': 'enableStars', + '[style.--glow-color]': 'glowColor', + '(mouseenter)': 'onMouseEnter()', + '(mouseleave)': 'onMouseLeave()', + '(mousemove)': 'onMouseMove($event)', + '(click)': 'onClick($event)' + } +}) +export class MagicBentoCardDirective implements OnInit, OnDestroy { + @Input() enableStars = true; + @Input() enableBorderGlow = true; + @Input() disableAnimations = false; + @Input() particleCount = DEFAULT_PARTICLE_COUNT; + @Input() glowColor = DEFAULT_GLOW_COLOR; + @Input() enableTilt = false; + @Input() clickEffect = true; + @Input() enableMagnetism = false; // default false so it doesn't mess up simple cards + + private isHovered = false; + private particlesInitialized = false; + private memoizedParticles: HTMLDivElement[] = []; + private activeParticles: HTMLDivElement[] = []; + private timeouts: ReturnType[] = []; + private magnetismAnimation: gsap.core.Tween | null = null; + private isBrowser: boolean; + + constructor(public el: ElementRef, @Inject(PLATFORM_ID) platformId: Object) { + this.isBrowser = isPlatformBrowser(platformId); + } + + ngOnInit() {} + + ngOnDestroy() { + this.clearAllParticles(); + } + + get cardElement(): HTMLElement { + return this.el.nativeElement; + } + + private initializeParticles() { + if (this.particlesInitialized || !this.cardElement) return; + + const { width, height } = this.cardElement.getBoundingClientRect(); + this.memoizedParticles = Array.from({ length: this.particleCount }, () => + createParticleElement(Math.random() * width, Math.random() * height, this.glowColor) + ); + this.particlesInitialized = true; + } + + private clearAllParticles() { + this.timeouts.forEach(clearTimeout); + this.timeouts = []; + this.magnetismAnimation?.kill(); + + this.activeParticles.forEach(particle => { + gsap.to(particle, { + scale: 0, + opacity: 0, + duration: 0.3, + ease: 'back.in(1.7)', + onComplete: () => { + particle.parentNode?.removeChild(particle); + } + }); + }); + this.activeParticles = []; + } + + private animateParticles() { + if (!this.cardElement || !this.isHovered) return; + + if (!this.particlesInitialized) { + this.initializeParticles(); + } + + this.memoizedParticles.forEach((particle, index) => { + const timeoutId = setTimeout(() => { + if (!this.isHovered || !this.cardElement) return; + + const clone = particle.cloneNode(true) as HTMLDivElement; + this.cardElement.appendChild(clone); + this.activeParticles.push(clone); + + gsap.fromTo(clone, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: 'back.out(1.7)' }); + + gsap.to(clone, { + x: (Math.random() - 0.5) * 100, + y: (Math.random() - 0.5) * 100, + rotation: Math.random() * 360, + duration: 2 + Math.random() * 2, + ease: 'none', + repeat: -1, + yoyo: true + }); + + gsap.to(clone, { + opacity: 0.3, + duration: 1.5, + ease: 'power2.inOut', + repeat: -1, + yoyo: true + }); + }, index * 100); + + this.timeouts.push(timeoutId); + }); + } + + onMouseEnter() { + if (this.disableAnimations || !this.isBrowser) return; + this.isHovered = true; + + if (this.enableStars) { + this.animateParticles(); + } + + if (this.enableTilt) { + gsap.to(this.cardElement, { + rotateX: 5, + rotateY: 5, + duration: 0.3, + ease: 'power2.out', + transformPerspective: 1000 + }); + } + } + + onMouseLeave() { + if (this.disableAnimations || !this.isBrowser) return; + this.isHovered = false; + + if (this.enableStars) { + this.clearAllParticles(); + } + + if (this.enableTilt) { + gsap.to(this.cardElement, { + rotateX: 0, + rotateY: 0, + duration: 0.3, + ease: 'power2.out' + }); + } + + if (this.enableMagnetism) { + gsap.to(this.cardElement, { + x: 0, + y: 0, + duration: 0.3, + ease: 'power2.out' + }); + } + } + + onMouseMove(e: MouseEvent) { + if (this.disableAnimations || !this.isBrowser) return; + if (!this.enableTilt && !this.enableMagnetism) return; + + const rect = this.cardElement.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const centerX = rect.width / 2; + const centerY = rect.height / 2; + + if (this.enableTilt) { + const rotateX = ((y - centerY) / centerY) * -10; + const rotateY = ((x - centerX) / centerX) * 10; + + gsap.to(this.cardElement, { + rotateX, + rotateY, + duration: 0.1, + ease: 'power2.out', + transformPerspective: 1000 + }); + } + + if (this.enableMagnetism) { + const magnetX = (x - centerX) * 0.05; + const magnetY = (y - centerY) * 0.05; + + this.magnetismAnimation = gsap.to(this.cardElement, { + x: magnetX, + y: magnetY, + duration: 0.3, + ease: 'power2.out' + }); + } + } + + onClick(e: MouseEvent) { + if (!this.clickEffect || this.disableAnimations || !this.isBrowser) return; + + const rect = this.cardElement.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const maxDistance = Math.max( + Math.hypot(x, y), + Math.hypot(x - rect.width, y), + Math.hypot(x, y - rect.height), + Math.hypot(x - rect.width, y - rect.height) + ); + + const ripple = document.createElement('div'); + ripple.style.cssText = ` + position: absolute; + width: ${maxDistance * 2}px; + height: ${maxDistance * 2}px; + border-radius: 50%; + background: radial-gradient(circle, rgba(${this.glowColor}, 0.4) 0%, rgba(${this.glowColor}, 0.2) 30%, transparent 70%); + left: ${x - maxDistance}px; + top: ${y - maxDistance}px; + pointer-events: none; + z-index: 1000; + `; + + // ensure position relative and overflow hidden for ripple + this.cardElement.style.position = 'relative'; + this.cardElement.style.overflow = 'hidden'; + this.cardElement.appendChild(ripple); + + gsap.fromTo( + ripple, + { scale: 0, opacity: 1 }, + { + scale: 1, + opacity: 0, + duration: 0.8, + ease: 'power2.out', + onComplete: () => ripple.remove() + } + ); + } +} + +@Directive({ + selector: '[appMagicBento]', + standalone: true +}) +export class MagicBentoDirective implements OnInit, AfterViewInit, OnDestroy { + @Input() enableSpotlight = true; + @Input() disableAnimations = false; + @Input() spotlightRadius = DEFAULT_SPOTLIGHT_RADIUS; + @Input() glowColor = DEFAULT_GLOW_COLOR; + + @ContentChildren(MagicBentoCardDirective, { descendants: true }) cardDirectives!: QueryList; + + private spotlightElement: HTMLDivElement | null = null; + private isMobile = false; + private isBrowser: boolean; + private resizeListener!: () => void; + private mouseMoveListener!: (e: MouseEvent) => void; + private mouseLeaveListener!: () => void; + + constructor(private el: ElementRef, @Inject(PLATFORM_ID) platformId: Object) { + this.isBrowser = isPlatformBrowser(platformId); + } + + ngOnInit() { + if (this.isBrowser) { + this.checkMobile(); + this.resizeListener = () => this.checkMobile(); + window.addEventListener('resize', this.resizeListener); + + // ensure host has bento-section class + this.el.nativeElement.classList.add('bento-section'); + } + } + + ngAfterViewInit() { + if (!this.isBrowser || this.shouldDisableAnimations() || !this.enableSpotlight) return; + + this.spotlightElement = document.createElement('div'); + this.spotlightElement.className = 'global-spotlight'; + this.spotlightElement.style.cssText = ` + position: fixed; + width: 800px; + height: 800px; + border-radius: 50%; + pointer-events: none; + background: radial-gradient(circle, + rgba(${this.glowColor}, 0.15) 0%, + rgba(${this.glowColor}, 0.08) 15%, + rgba(${this.glowColor}, 0.04) 25%, + rgba(${this.glowColor}, 0.02) 40%, + rgba(${this.glowColor}, 0.01) 65%, + transparent 70% + ); + z-index: 200; + opacity: 0; + transform: translate(-50%, -50%); + mix-blend-mode: screen; + `; + document.body.appendChild(this.spotlightElement); + + this.mouseMoveListener = this.onGlobalMouseMove.bind(this); + this.mouseLeaveListener = this.onGlobalMouseLeave.bind(this); + document.addEventListener('mousemove', this.mouseMoveListener); + document.addEventListener('mouseleave', this.mouseLeaveListener); + } + + ngOnDestroy() { + if (this.isBrowser) { + window.removeEventListener('resize', this.resizeListener); + if (this.mouseMoveListener) { + document.removeEventListener('mousemove', this.mouseMoveListener); + document.removeEventListener('mouseleave', this.mouseLeaveListener); + } + if (this.spotlightElement && this.spotlightElement.parentNode) { + this.spotlightElement.parentNode.removeChild(this.spotlightElement); + } + } + } + + shouldDisableAnimations(): boolean { + return this.disableAnimations || this.isMobile; + } + + private checkMobile() { + this.isMobile = window.innerWidth <= MOBILE_BREAKPOINT; + } + + private onGlobalMouseMove(e: MouseEvent) { + if (!this.spotlightElement || !this.el) return; + + const section = this.el.nativeElement; + const rect = section.getBoundingClientRect(); + const mouseInside = + rect && e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; + + const cards = this.cardDirectives.toArray().map(c => c.cardElement); + + if (!mouseInside) { + gsap.to(this.spotlightElement, { opacity: 0, duration: 0.3, ease: 'power2.out' }); + cards.forEach(card => { + card.style.setProperty('--glow-intensity', '0'); + }); + return; + } + + const { proximity, fadeDistance } = calculateSpotlightValues(this.spotlightRadius); + let minDistance = Infinity; + + cards.forEach(cardElement => { + const cardRect = cardElement.getBoundingClientRect(); + const centerX = cardRect.left + cardRect.width / 2; + const centerY = cardRect.top + cardRect.height / 2; + const distance = Math.hypot(e.clientX - centerX, e.clientY - centerY) - Math.max(cardRect.width, cardRect.height) / 2; + const effectiveDistance = Math.max(0, distance); + + minDistance = Math.min(minDistance, effectiveDistance); + + let glowIntensity = 0; + if (effectiveDistance <= proximity) { + glowIntensity = 1; + } else if (effectiveDistance <= fadeDistance) { + glowIntensity = (fadeDistance - effectiveDistance) / (fadeDistance - proximity); + } + + this.updateCardGlowProperties(cardElement, e.clientX, e.clientY, glowIntensity, this.spotlightRadius); + }); + + gsap.to(this.spotlightElement, { + left: e.clientX, + top: e.clientY, + duration: 0.1, + ease: 'power2.out' + }); + + const targetOpacity = minDistance <= proximity + ? 0.8 + : minDistance <= fadeDistance + ? ((fadeDistance - minDistance) / (fadeDistance - proximity)) * 0.8 + : 0; + + gsap.to(this.spotlightElement, { + opacity: targetOpacity, + duration: targetOpacity > 0 ? 0.2 : 0.5, + ease: 'power2.out' + }); + } + + private onGlobalMouseLeave() { + this.cardDirectives?.forEach(c => { + c.cardElement.style.setProperty('--glow-intensity', '0'); + }); + if (this.spotlightElement) { + gsap.to(this.spotlightElement, { opacity: 0, duration: 0.3, ease: 'power2.out' }); + } + } + + private updateCardGlowProperties(card: HTMLElement, mouseX: number, mouseY: number, glow: number, radius: number) { + const rect = card.getBoundingClientRect(); + const relativeX = ((mouseX - rect.left) / rect.width) * 100; + const relativeY = ((mouseY - rect.top) / rect.height) * 100; + + card.style.setProperty('--glow-x', `${relativeX}%`); + card.style.setProperty('--glow-y', `${relativeY}%`); + card.style.setProperty('--glow-intensity', glow.toString()); + card.style.setProperty('--glow-radius', `${radius}px`); + } +} diff --git a/apps/web/src/app/shared/components/target-cursor/target-cursor.component.ts b/apps/web/src/app/shared/components/target-cursor/target-cursor.component.ts index 48375cf..f96c36e 100644 --- a/apps/web/src/app/shared/components/target-cursor/target-cursor.component.ts +++ b/apps/web/src/app/shared/components/target-cursor/target-cursor.component.ts @@ -1,7 +1,6 @@ import { CommonModule } from '@angular/common'; import { Component, Input, NgZone, OnDestroy, OnInit, inject } from '@angular/core'; import { gsap } from 'gsap'; -import type { GSAPTimeline } from 'gsap'; // A position: fixed element is positioned relative to the viewport UNLESS an