diff --git a/README.md b/README.md index 4e89a20..8b86179 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # Project 3 - Animal Training App +Jaahnvi Toolsidas +Andrew Liu + ## Description Welcome to the final project of this year's dev bootcamp! For this project, you will create a full-stack animal training management app (this is a mini version of an app that BoG developed for Healing4Heroes). Your job is to develop a frontend and backend that interact with each other for deployment functionality to manage different users, animals, and training logs. Schemas for these data models can be found in `Schemas.md`. diff --git a/hork.png b/hork.png new file mode 100644 index 0000000..493656a Binary files /dev/null and b/hork.png differ diff --git a/package-lock.json b/package-lock.json index f15a1ff..c020ed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,21 @@ "name": "project2-f25", "version": "0.1.0", "dependencies": { + "argon2": "^0.44.0", + "jsonwebtoken": "^9.0.3", + "lucide-react": "^1.7.0", + "mongoose": "^9.3.1", "next": "16.0.1", "react": "19.2.0", "react-dom": "19.2.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "tailwindcss": "^4", + "tailwindcss": "^4.2.2", "typescript": "^5" } }, @@ -44,6 +49,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -522,6 +533,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@next/env": { "version": "16.0.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.1.tgz", @@ -656,6 +676,15 @@ "node": ">= 10" } }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -681,6 +710,13 @@ "tailwindcss": "4.1.16" } }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/oxide": { "version": "4.1.16", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", @@ -936,6 +972,31 @@ "tailwindcss": "4.1.16" } }, + "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.24", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", @@ -966,6 +1027,52 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/caniuse-lite": { "version": "1.0.30001751", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", @@ -992,6 +1099,37 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -1009,6 +1147,15 @@ "node": ">=8" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -1030,6 +1177,12 @@ "dev": true, "license": "ISC" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -1040,6 +1193,58 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", + "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -1301,6 +1506,57 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lucide-react": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", + "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1311,6 +1567,116 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mongodb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", + "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.3.1.tgz", + "integrity": "sha512-58DuQti+LlRS74/UfWN4F3wZsC0Yr1dgTWZ2Wd3/TuSvm6rIdyAjDWbx2xGyuBooqJYdAWotVv4mQgVdivh+3Q==", + "license": "MIT", + "dependencies": { + "kareem": "3.2.0", + "mongodb": "~7.1", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -1409,6 +1775,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1444,6 +1839,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -1465,6 +1869,26 @@ "react": "^19.2.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1476,7 +1900,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -1527,6 +1950,33 @@ "@img/sharp-win32-x64": "0.34.4" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1536,6 +1986,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -1560,9 +2019,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.16", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", - "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "dev": true, "license": "MIT" }, @@ -1580,6 +2039,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1606,6 +2077,43 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } } } } diff --git a/package.json b/package.json index 693e5fc..c74bc8e 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,21 @@ "start": "next start" }, "dependencies": { + "argon2": "^0.44.0", + "jsonwebtoken": "^9.0.3", + "lucide-react": "^1.7.0", + "mongoose": "^9.3.1", + "next": "16.0.1", "react": "19.2.0", - "react-dom": "19.2.0", - "next": "16.0.1" + "react-dom": "19.2.0" }, "devDependencies": { - "typescript": "^5", + "@tailwindcss/postcss": "^4", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4" + "tailwindcss": "^4.2.2", + "typescript": "^5" } } diff --git a/src/app/favicon.ico b/public/favicon.ico similarity index 100% rename from src/app/favicon.ico rename to public/favicon.ico diff --git a/public/images/animalPicture.png b/public/images/animalPicture.png new file mode 100644 index 0000000..5573b04 Binary files /dev/null and b/public/images/animalPicture.png differ diff --git a/server/jwt.ts b/server/jwt.ts new file mode 100644 index 0000000..3690cb5 --- /dev/null +++ b/server/jwt.ts @@ -0,0 +1,11 @@ +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET as string; + +export function signToken(payload: { userId: string; admin: boolean }) { + return jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" }); +} + +export function verifyToken(token: string) { + return jwt.verify(token, JWT_SECRET) as { userId: string; admin: boolean }; +} diff --git a/server/mongodb/actions/animal.ts b/server/mongodb/actions/animal.ts new file mode 100644 index 0000000..d246d93 --- /dev/null +++ b/server/mongodb/actions/animal.ts @@ -0,0 +1,41 @@ +import { AnimalData } from "../types/types"; +import Animal from "../models/Animal"; +import mongoose from "mongoose"; + +export async function createAnimal(animalData: AnimalData) { + const newAnimal = new Animal(animalData); + await newAnimal.save(); + return newAnimal; +} + +export async function getAnimal(animalId: string) { + const retrievedAnimal = await Animal.findById(animalId); + return retrievedAnimal; +} + +export async function getAnimalsByOwner(ownerId: string) { + return Animal.find({ owner: ownerId }).populate('owner', 'fullName'); +} + +export async function getAllAnimals(limit: number = 100, lastId?: string) { + const query: any = lastId ? { _id: { $gt: new mongoose.Types.ObjectId(lastId) } } : {}; + const retrievedAnimals = await Animal.find(query).limit(limit).populate('owner', 'fullName'); + return retrievedAnimals; +} + +export async function updateAnimal(animalId: string, newData: Partial) { + const updatedAnimal = await Animal.findByIdAndUpdate(animalId, { $set: newData }, { new: true, strict: false }); + return updatedAnimal; +} + +export async function deleteAnimal(animalId: string) { + await Animal.findByIdAndDelete(animalId); +} + +export async function adjustAnimalHours(animalId: string, delta: number) { + await Animal.findByIdAndUpdate( + animalId, + [{ $set: { hoursTrained: { $max: [0, { $add: ['$hoursTrained', delta] }] } } }], + { updatePipeline: true } as any, + ); +} diff --git a/server/mongodb/actions/traininglog.ts b/server/mongodb/actions/traininglog.ts new file mode 100644 index 0000000..66e0ec5 --- /dev/null +++ b/server/mongodb/actions/traininglog.ts @@ -0,0 +1,44 @@ +import { TrainingLogData } from "../types/types"; +import TrainingLog from "../models/TrainingLog"; +import mongoose from "mongoose"; + +export async function createTrainingLog(trainingLogData: TrainingLogData) { + const newTrainingLog = new TrainingLog(trainingLogData); + await newTrainingLog.save(); + return newTrainingLog; +} + +export async function getTrainingLog(trainingLogId: string) { + const retrievedTrainingLog = await TrainingLog.findById(trainingLogId); + return retrievedTrainingLog; +} + +export async function getAllTrainingLogs(limit: number = 10, lastId?: string) { + const query: any = lastId ? { _id: { $gt: new mongoose.Types.ObjectId(lastId) } } : {}; + const retrievedTrainingLogs = await TrainingLog.find(query) + .populate('user', 'fullName') + .populate('animal', 'name breed') + .limit(limit); + return retrievedTrainingLogs; +} + +export async function getTrainingLogsByUser(userId: string) { + const retrievedTrainingLogs = await TrainingLog.find({ user: userId }) + .populate('user', 'fullName') + .populate('animal', 'name breed') + .sort({ date: -1 }); + return retrievedTrainingLogs; +} + +export async function updateTrainingLog(trainingLogId: string, newData: Partial) { + const updatedTrainingLog = await TrainingLog.findByIdAndUpdate(trainingLogId, newData, { new: true }); + return updatedTrainingLog; +} + +export async function deleteTrainingLog(trainingLogId: string) { + await TrainingLog.findByIdAndDelete(trainingLogId); +} + +export async function deleteTrainingLogsByAnimal(animalId: string) { + await TrainingLog.deleteMany({ animal: animalId }); +} diff --git a/server/mongodb/actions/user.ts b/server/mongodb/actions/user.ts new file mode 100644 index 0000000..fedb213 --- /dev/null +++ b/server/mongodb/actions/user.ts @@ -0,0 +1,29 @@ +import { UserData } from "../types/types"; +import User from "../models/User"; +import mongoose from "mongoose"; + +export async function createUser(userData: UserData) { + const newUser = new User(userData); + await newUser.save(); + return newUser; +} + +export async function getUser(userId: string) { + const retrievedUser = await User.findById(userId); + return retrievedUser; +} + +export async function getAllUsers(limit: number = 10, lastId?: string) { + const query: any = lastId ? { _id: { $gt: new mongoose.Types.ObjectId(lastId) } } : {}; + const retrievedUsers = await User.find(query, { password: 0 }).limit(limit); + return retrievedUsers; +} + +export async function getUserByEmail(email: string) { + const retrievedUser = await User.findOne({ email }); + return retrievedUser; +} + +export async function deleteUser(userId: string) { + await User.findByIdAndDelete(userId); +} diff --git a/server/mongodb/connectDb.ts b/server/mongodb/connectDb.ts new file mode 100644 index 0000000..b8188f2 --- /dev/null +++ b/server/mongodb/connectDb.ts @@ -0,0 +1,10 @@ +import mongoose from "mongoose" + +export default async function conectDb() { + try { + await mongoose.connect(process.env.DB_URL!); + console.log("connected to mongodb"); + } catch (e) { + console.log("unable to connect", e); + } +} diff --git a/server/mongodb/index.js b/server/mongodb/index.js new file mode 100644 index 0000000..e69de29 diff --git a/server/mongodb/models/Animal.ts b/server/mongodb/models/Animal.ts new file mode 100644 index 0000000..8d47dfc --- /dev/null +++ b/server/mongodb/models/Animal.ts @@ -0,0 +1,43 @@ +// Animal { +// _id: ObjectId // animal's ID +// name: string // animal's name +// breed: string // animal's breed +// owner: ObjectId // id of the animal's owner +// hoursTrained: number // total number of hours the animal has been trained for +// profilePicture: string // url to an image that can be displayed in an tag +// } + +import mongoose from "mongoose" + +const animalSchema = new mongoose.Schema({ + name: { + type: String, + required: true, + }, + breed: { + type: String, + required: true, + }, + owner: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + hoursTrained: { + type: Number, + default: 0, + min: 0, + }, + birthdate: { + type: Date, + }, + note: { + type: String, + default: '', + }, + profilePicture: { + type: String, + } +}); + +export default mongoose.models.Animal || mongoose.model("Animal", animalSchema); diff --git a/server/mongodb/models/TrainingLog.ts b/server/mongodb/models/TrainingLog.ts new file mode 100644 index 0000000..cbddd4d --- /dev/null +++ b/server/mongodb/models/TrainingLog.ts @@ -0,0 +1,42 @@ +// TrainingLog { +// _id: ObjectId // training log's id +// user: ObjectId // user this training log corresponds to +// animal: ObjectId // animal this training log corresponds to +// title: string // title of training log +// date: Date // date of training log +// description: string // description of training log +// hours: number // number of hours the training log records +// } + +import mongoose from "mongoose" + +const trainingLogSchema = new mongoose.Schema({ + user: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + }, + animal: { + type: mongoose.Schema.Types.ObjectId, + ref: "Animal", + required: true, + }, + title: { + type: String, + required: true, + }, + date: { + type: Date, + required: true, + }, + description: { + type: String, + required: true, + }, + hours: { + type: Number, + required: true, + } +}); + +export default mongoose.models.TrainingLog || mongoose.model("TrainingLog", trainingLogSchema); diff --git a/server/mongodb/models/User.ts b/server/mongodb/models/User.ts new file mode 100644 index 0000000..b31c0b4 --- /dev/null +++ b/server/mongodb/models/User.ts @@ -0,0 +1,31 @@ +// User { +// _id: ObjectId // user's ID +// fullName: string // user's full name +// email: string // user's email +// password: string // user's password +// admin: boolean // holds whether or not a user is an admin +// } + +import mongoose from "mongoose" + +const userSchema = new mongoose.Schema({ + fullName: { + type: String, + required: true, + }, + email: { + type: String, + required: true, + unique: true + }, + password: { + type: String, + required: true + }, + admin: { + type: Boolean, + default: false + } +}); + +export default mongoose.models.User || mongoose.model("User", userSchema); diff --git a/server/mongodb/types/types.ts b/server/mongodb/types/types.ts new file mode 100644 index 0000000..0ba22ec --- /dev/null +++ b/server/mongodb/types/types.ts @@ -0,0 +1,24 @@ +export interface UserData { + fullName: string; + email: string; + password: string; + admin?: boolean; +} + +export interface AnimalData { + name: string; + breed: string; + owner: string; // ObjectId as a string + hoursTrained?: number; + birthdate?: Date; + note?: string; + profilePicture?: string; +} + +export interface TrainingLogData { + animal: string; // ObjectId as a string + title: string; + date: Date; + description: string; + hours: number; // in hours +} diff --git a/server/utils/getServerSideUser.ts b/server/utils/getServerSideUser.ts new file mode 100644 index 0000000..504b5d0 --- /dev/null +++ b/server/utils/getServerSideUser.ts @@ -0,0 +1,39 @@ +import { GetServerSidePropsContext, GetServerSidePropsResult } from "next"; +import { verifyToken } from "@server/jwt"; +import { getUser } from "@server/mongodb/actions/user"; +import connectDb from "@server/mongodb/connectDb"; + +export interface SessionUser { + fullName: string; + admin: boolean; +} + +//call inside getServerSideProps to get auth user, ret redirect to / if token is missing or invalid +export async function getServerSideUser( + context: GetServerSidePropsContext +): Promise<{ user: SessionUser } | GetServerSidePropsResult> { + const token = context.req.cookies?.token; + + if (!token) { + return { redirect: { destination: "/", permanent: false } }; + } + + try { + const payload = verifyToken(token); + await connectDb(); + const user = await getUser(payload.userId); + + if (!user) { + return { redirect: { destination: "/", permanent: false } }; + } + + return { + user: { + fullName: user.fullName, + admin: user.admin ?? false, + }, + }; + } catch { + return { redirect: { destination: "/", permanent: false } }; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx deleted file mode 100644 index f7fa87e..0000000 --- a/src/app/layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - {children} - - - ); -} diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 295f8fd..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Image from "next/image"; - -export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); -} diff --git a/src/components/AnimalCard.tsx b/src/components/AnimalCard.tsx new file mode 100644 index 0000000..c953dfa --- /dev/null +++ b/src/components/AnimalCard.tsx @@ -0,0 +1,130 @@ +/*import React, { useEffect, useState } from 'react'; + +interface AnimalCardProps { + animal: { + _id: string; + name: string; + breed: string; + owner: string; + hoursTrained?: number; + profilePicture?: string; + }; +} + +export default function AnimalCard({ animal }: AnimalCardProps) { + const isId = animal.owner.length === 24; + const [ownerName, setOwnerName] = useState(isId ? '' : animal.owner); + + useEffect(() => { + if (!isId) return; + fetch(`/api/user?id=${animal.owner}`) + .then((r) => r.json()) + .then((data) => { + if (data.userData?.fullName) setOwnerName(data.userData.fullName); + }); + }, [animal.owner, isId]); + + const ownerInitial = ownerName.charAt(0).toUpperCase(); + + return ( +
+ +
+ {animal.name} +
+ + +
+ +
+ {ownerInitial} +
+ +
+

+ {animal.name} - {animal.breed} +

+ +
+ {ownerName} + + Trained: {animal.hoursTrained ?? 0} hours +
+
+
+
+ ); +}*/ + +import React, { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import { Pencil } from 'lucide-react'; + +interface AnimalCardProps { + animal: { + _id: string; + name: string; + breed: string; + owner: string; + hoursTrained?: number; + profilePicture?: string; + }; + onEdit?: () => void; +} + +export default function AnimalCard({ animal }: AnimalCardProps) { + const router = useRouter(); + const isId = animal.owner.length === 24; + const [ownerName, setOwnerName] = useState(isId ? '' : animal.owner); + + useEffect(() => { + if (!isId) return; + fetch(`/api/user?id=${animal.owner}`) + .then((r) => r.json()) + .then((data) => { + if (data.userData?.fullName) setOwnerName(data.userData.fullName); + }); + }, [animal.owner, isId]); + + const ownerInitial = ownerName.charAt(0).toUpperCase(); + + return ( +
+
+ {animal.name} +
+ +
+
+ {ownerInitial} +
+ +
+

+ {animal.name} - {animal.breed} +

+ +
+ {ownerName} + + Trained: {animal.hoursTrained ?? 0} hours +
+
+ +
+
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..de5b64d --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { + Pencil, + Bone, + Folder, + Rabbit, + Users, + LogOut +} from 'lucide-react'; + +interface SidebarProps { + user: { + fullName: string; + admin: boolean; + }; +} + +export default function Sidebar({ user }: SidebarProps) { + const router = useRouter(); + + const handleLogout = async () => { + await fetch('/api/logout', { method: 'POST' }); + router.push('/?logout=true'); + }; + + const NavItem = ({ href, icon: Icon, label, active = false }: any) => ( + + + {label} + + ); + + return ( + + ); +} diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx new file mode 100644 index 0000000..0f526a1 --- /dev/null +++ b/src/components/Titlebar.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import appLogo from "@images/appLogo.png"; +import searchLogo from "@images/searchLogo.png"; + +interface TitleBarProps { + searchValue?: string; + onSearchChange?: (value: string) => void; + hideSearch?: boolean; +} + +const TitleBar = ({ searchValue, onSearchChange, hideSearch }: TitleBarProps) => { + return ( +
+
+ {/* left: logo +title */} +
+ Logo + Progress +
+ + {/* search bar: centered in full bar by default- shifts right when no space */} + {!hideSearch && ( +
+
+ Search + onSearchChange?.(e.target.value)} + className="w-full bg-white border border-gray-300 rounded-md pl-9 pr-4 py-2 text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300" + /> +
+
+ )} +
+
+ ); +}; + +export default TitleBar; diff --git a/src/components/TrainingLogCard.tsx b/src/components/TrainingLogCard.tsx new file mode 100644 index 0000000..8e7df9d --- /dev/null +++ b/src/components/TrainingLogCard.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Pencil } from 'lucide-react'; +import { useRouter } from 'next/router'; + +interface TrainingLogProps { + //fix + log: { + _id: string; + title: string; + description: string; + date: string | number; + hours: number; + user: { fullName: string }; + animal: { name: string; breed: string }; + }; +} + +export default function TrainingLogCard({ log }: TrainingLogProps) { + const router = useRouter(); + const [yearStr, monthStr, dayStr] = String(log.date).split('T')[0].split('-'); + + const day = parseInt(dayStr); + const year = parseInt(yearStr); + const dateObj = new Date(year, parseInt(monthStr) - 1, day); + const month = dateObj.toLocaleString('default', { month: 'short' }); + + return ( +
+ +
+ {day} + + {month} - {year} + +
+ +
+
+
+

{log.title}

+ • {log.hours} hours +
+ +

+ {log.user.fullName} - {log.animal.breed} - {log.animal.name} +

+
+ +

+ {log.description} +

+
+ +
+ +
+
+ ); +} diff --git a/src/components/UserCard.tsx b/src/components/UserCard.tsx new file mode 100644 index 0000000..0ad4bc6 --- /dev/null +++ b/src/components/UserCard.tsx @@ -0,0 +1,65 @@ +/*import React from 'react'; + +interface UserCardProps { + fullName: string; + role: string; +} + +export default function UserCard({ fullName, role }: UserCardProps) { + const initial = fullName.charAt(0).toUpperCase(); + + return ( +
+
+ {initial} +
+ +
+ + {fullName} + +
+ {role} +
+
+
+ ); +}*/ + +import React from 'react'; +import { X } from 'lucide-react'; + +interface UserCardProps { + fullName: string; + role: string; + onDelete?: () => void; //fix +} + +export default function UserCard({ fullName, role, onDelete }: UserCardProps) { + const initial = fullName.charAt(0).toUpperCase(); + + return ( +
+
+ {initial} +
+ +
+ + {fullName} + +
+ {role} +
+
+ + +
+ ); +} \ No newline at end of file diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx new file mode 100644 index 0000000..f58f2e0 --- /dev/null +++ b/src/pages/_app.tsx @@ -0,0 +1,24 @@ +import '@/styles/globals.css' + +import type { AppProps } from 'next/app' +import Head from 'next/head' + +export default function App({ Component, pageProps }: AppProps) { + return ( + <> + + + + + + + + + ) +} diff --git a/src/pages/api/admin/animals.ts b/src/pages/api/admin/animals.ts new file mode 100644 index 0000000..ff3646d --- /dev/null +++ b/src/pages/api/admin/animals.ts @@ -0,0 +1,45 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { AnimalData } from "@server/mongodb/types/types"; +import { getAllAnimals, deleteAnimal } from "@server/mongodb/actions/animal"; +import connectDb from "@server/mongodb/connectDb"; + +type AnimalApiData = { + animalData?: AnimalData[]; + message: string; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if ( req.method === "GET" ) { + try { + connectDb(); + const animals = await getAllAnimals(); + const serialized = animals.map((a: any) => ({ + ...a.toObject(), + _id: a._id.toString(), + owner: a.owner?.fullName ?? a.owner?.toString() ?? '', + })); + + res.status(200).json({ + animalData: serialized, + message: "Animals retrieved successfully" + }); + } catch (error) { + res.status(500).json({ + message: "Error retrieving animals" + }); + } + } else if (req.method === "DELETE") { + try { + const { id } = req.query; + if (!id) return res.status(400).json({ message: "Missing animal ID" }); + await connectDb(); + await deleteAnimal(id as string); + res.status(200).json({ message: "Animal deleted successfully" }); + } catch (error) { + res.status(500).json({ message: "Error deleting animal" }); + } + } +} diff --git a/src/pages/api/admin/training.ts b/src/pages/api/admin/training.ts new file mode 100644 index 0000000..3e8ef44 --- /dev/null +++ b/src/pages/api/admin/training.ts @@ -0,0 +1,44 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { TrainingLogData } from "@server/mongodb/types/types"; +import { getAllTrainingLogs, getTrainingLog, deleteTrainingLog } from "@server/mongodb/actions/traininglog"; +import { adjustAnimalHours } from "@server/mongodb/actions/animal"; +import connectDb from "@server/mongodb/connectDb"; + +type TrainingLogApiData = { + trainingLogData?: TrainingLogData[]; + message: string; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if ( req.method === "GET" ) { + try { + connectDb(); + const trainingLogs = await getAllTrainingLogs(); + + res.status(200).json({ + trainingLogData: trainingLogs, + message: "Training logs retrieved successfully" + }); + } catch (error) { + res.status(500).json({ + message: "Error retrieving training logs" + }); + } + } else if (req.method === "DELETE") { + try { + const { id } = req.query; + if (!id) return res.status(400).json({ message: "Missing training log ID" }); + await connectDb(); + const log = await getTrainingLog(id as string); + if (!log) return res.status(404).json({ message: "Training log not found" }); + await adjustAnimalHours(log.animal.toString(), -(log.hours ?? 0)); + await deleteTrainingLog(id as string); + res.status(200).json({ message: "Training log deleted successfully" }); + } catch (error) { + res.status(500).json({ message: "Error deleting training log" }); + } + } +} diff --git a/src/pages/api/admin/users.ts b/src/pages/api/admin/users.ts new file mode 100644 index 0000000..9b9875b --- /dev/null +++ b/src/pages/api/admin/users.ts @@ -0,0 +1,40 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { UserData } from "@server/mongodb/types/types"; +import { getAllUsers, deleteUser } from "@server/mongodb/actions/user"; +import connectDb from "@server/mongodb/connectDb"; + +type UserApiData = { + userData?: UserData[]; + message: string; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if ( req.method === "GET" ) { + try { + connectDb(); + const users = await getAllUsers(); + + res.status(200).json({ + userData: users, + message: "Users retrieved successfully" + }); + } catch (error) { + res.status(500).json({ + message: "Error retrieving users" + }); + } + } else if (req.method === "DELETE") { + try { + const { id } = req.query; + if (!id) return res.status(400).json({ message: "Missing user ID" }); + await connectDb(); + await deleteUser(id as string); + res.status(200).json({ message: "User deleted successfully" }); + } catch (error) { + res.status(500).json({ message: "Error deleting user" }); + } + } +} diff --git a/src/pages/api/animal.ts b/src/pages/api/animal.ts new file mode 100644 index 0000000..4bc1929 --- /dev/null +++ b/src/pages/api/animal.ts @@ -0,0 +1,120 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { AnimalData } from "@server/mongodb/types/types"; +import { createAnimal, getAnimal, updateAnimal, deleteAnimal, getAnimalsByOwner } from "@server/mongodb/actions/animal"; +import { deleteTrainingLogsByAnimal } from "@server/mongodb/actions/traininglog"; +import connectDb from "../../../server/mongodb/connectDb"; +import { verifyToken } from "@server/jwt"; + +type AnimalApiData = { + animalData?: AnimalData | AnimalData[]; + message: string; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if ( req.method === "GET" ) { + try { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ message: "Unauthorized" }); + const { userId } = verifyToken(token); + await connectDb(); + + const { id } = req.query; + if (id) { + const animal = await getAnimal(id as string); + if (!animal) return res.status(404).json({ message: "Animal not found" }); + const obj = animal.toObject(); + const serialized = { ...obj, _id: obj._id.toString(), owner: obj.owner?.toString() ?? '', note: obj.note ?? '', birthdate: obj.birthdate ?? null }; + return res.status(200).json({ animalData: serialized, message: "Animal retrieved successfully" }); + } + + const animals = await getAnimalsByOwner(userId); + const serialized = animals.map((a: any) => ({ + ...a.toObject(), + _id: a._id.toString(), + owner: a.owner?.fullName ?? a.owner?.toString() ?? '', + })); + return res.status(200).json({ animalData: serialized, message: "Animals retrieved successfully" }); + } catch (error) { + return res.status(500).json({ message: "Error retrieving animals" }); + } + } else if ( req.method === "POST" ) { + try { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ message: "Unauthorized" }); + const { userId } = verifyToken(token); + + if (!req.body.name || !req.body.breed) { + return res.status(400).json({ message: "Missing required fields" }); + } + const animalData = { + name: req.body.name, + breed: req.body.breed, + owner: userId, + hoursTrained: req.body.hoursTrained || 0, + profilePicture: req.body.profilePicture || "", + } as AnimalData; + + await connectDb(); + const animal = await createAnimal(animalData); + res.status(200).json({ + animalData: animal, + message: "Animal created successfully" + }); + } catch (error) { + res.status(500).json({ + message: "Error creating animal" + }); + } + } else if ( req.method === "PATCH" ) { + try { + const { id, name, breed, hoursTrained } = req.body; + if (!id) { + return res.status(400).json({ message: "Missing animal ID" }); + } + + const updates: Partial<{ name: string; breed: string; hoursTrained: number; birthdate: Date; note: string }> = {}; + if (name !== undefined) updates.name = name; + if (breed !== undefined) updates.breed = breed; + if (hoursTrained !== undefined) updates.hoursTrained = hoursTrained; + if (req.body.birthdate !== undefined) updates.birthdate = req.body.birthdate; + if (req.body.note !== undefined) updates.note = req.body.note; + + await connectDb(); + const animal = await updateAnimal(id, updates); + if (!animal) { + return res.status(404).json({ message: "Animal not found" }); + } + res.status(200).json({ + animalData: animal, + message: "Animal updated successfully" + }); + } catch (error) { + res.status(500).json({ + message: "Error updating animal" + }); + } + } else if (req.method === "DELETE") { + try { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ message: "Unauthorized" }); + const { userId } = verifyToken(token); + + const { id } = req.query; + if (!id) return res.status(400).json({ message: "Missing animal ID" }); + + await connectDb(); + const animal = await getAnimal(id as string); + if (!animal) return res.status(404).json({ message: "Animal not found" }); + if (animal.owner.toString() !== userId) return res.status(403).json({ message: "Unauthorized" }); + + await deleteTrainingLogsByAnimal(id as string); + await deleteAnimal(id as string); + return res.status(200).json({ message: "Animal deleted successfully" }); + } catch (error) { + return res.status(500).json({ message: "Error deleting animal" }); + } + } +} diff --git a/src/pages/api/hello.ts b/src/pages/api/hello.ts new file mode 100644 index 0000000..ea77e8f --- /dev/null +++ b/src/pages/api/hello.ts @@ -0,0 +1,13 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type { NextApiRequest, NextApiResponse } from "next"; + +type Data = { + name: string; +}; + +export default function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + res.status(200).json({ name: "John Doe" }); +} diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts new file mode 100644 index 0000000..499f06f --- /dev/null +++ b/src/pages/api/logout.ts @@ -0,0 +1,9 @@ +import type { NextApiRequest, NextApiResponse } from "next"; + +export default function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "POST") { + return res.status(405).json({ message: "Method not allowed" }); + } + res.setHeader("Set-Cookie", "token=; HttpOnly; Path=/; Max-Age=0; SameSite=Strict"); + res.status(200).json({ message: "Logged out successfully" }); +} diff --git a/src/pages/api/training.ts b/src/pages/api/training.ts new file mode 100644 index 0000000..0e0abe1 --- /dev/null +++ b/src/pages/api/training.ts @@ -0,0 +1,151 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { TrainingLogData } from "@server/mongodb/types/types"; +import { createTrainingLog, getTrainingLog, updateTrainingLog, deleteTrainingLog, getTrainingLogsByUser } from "@server/mongodb/actions/traininglog"; +import { adjustAnimalHours, getAnimal } from "@server/mongodb/actions/animal"; +import connectDb from "../../../server/mongodb/connectDb"; +import { verifyToken } from "@server/jwt"; + +type TrainingLogApiData = { + trainingLogData?: TrainingLogData | TrainingLogData[]; + message: string; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if ( req.method === "GET" ) { + try { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ message: "Unauthorized" }); + const { userId } = verifyToken(token); + await connectDb(); + + const { id } = req.query; + if (id) { + const log = await getTrainingLog(id as string); + if (!log) return res.status(404).json({ message: "Training log not found" }); + if (log.user.toString() !== userId) return res.status(403).json({ message: "Unauthorized" }); + return res.status(200).json({ trainingLogData: log, message: "Training log retrieved successfully" }); + } + + const trainingLogs = await getTrainingLogsByUser(userId); + return res.status(200).json({ trainingLogData: trainingLogs, message: "Training logs retrieved successfully" }); + } catch (error) { + return res.status(500).json({ message: "Error retrieving training logs" }); + } + } else if ( req.method === "POST" ) { + try { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ message: "Unauthorized" }); + const { userId } = verifyToken(token); + + if (!req.body.animal || !req.body.date || !req.body.hours) { + return res.status(400).json({ message: "Missing required fields" }); + } + + await connectDb(); + const animal = await getAnimal(req.body.animal); + if (!animal) { + return res.status(400).json({ message: "Animal not found" }); + } + if (animal.owner.toString() !== userId) { + return res.status(400).json({ message: "Animal does not belong to this user" }); + } + + const trainingLogData = { + user: userId, + animal: req.body.animal, + title: req.body.title, + date: req.body.date, + description: req.body.description || "", + hours: req.body.hours, + } as TrainingLogData; + + const trainingLog = await createTrainingLog(trainingLogData); + await adjustAnimalHours(req.body.animal, req.body.hours); + res.status(200).json({ + trainingLogData: trainingLog, + message: "Training log created successfully" + }); + } catch (error) { + console.error("POST /api/training error:", error); + res.status(500).json({ + message: error instanceof Error ? error.message : "Error creating training log" + }); + } + } else if ( req.method === "PATCH" ) { + try { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ message: "Unauthorized" }); + const { userId } = verifyToken(token); + + if (!req.body.id) { + return res.status(400).json({ message: "Missing training log ID" }); + } + + await connectDb(); + + const existingLog = await getTrainingLog(req.body.id); + if (!existingLog) { + return res.status(500).json({ message: "Training log not found" }); + } + if (existingLog.user.toString() !== userId) { + return res.status(403).json({ message: "Unauthorized to update this training log" }); + } + + const oldHours = existingLog.hours ?? 0; + const trainingLog = await updateTrainingLog(req.body.id, { + title: req.body.title, + animal: req.body.animal, + date: req.body.date, + description: req.body.description, + hours: req.body.hours, + }); + if (!trainingLog) { + return res.status(500).json({ message: "Training log not found" }); + } + + const newAnimalId = (req.body.animal ?? existingLog.animal).toString(); + const oldAnimalId = existingLog.animal.toString(); + const newHours = req.body.hours ?? existingLog.hours; + if (newAnimalId !== oldAnimalId) { + await adjustAnimalHours(oldAnimalId, -oldHours); + await adjustAnimalHours(newAnimalId, newHours); + } else { + await adjustAnimalHours(oldAnimalId, newHours - oldHours); + } + + res.status(200).json({ + trainingLogData: trainingLog, + message: "Training log updated successfully" + }); + } catch (error) { + console.error("PATCH /api/training error:", error); + res.status(500).json({ + message: error instanceof Error ? error.message : "Error updating training log" + }); + } + } else if ( req.method === "DELETE" ) { + try { + const token = req.cookies?.token; + if (!token) return res.status(401).json({ message: "Unauthorized" }); + const { userId } = verifyToken(token); + + const { id } = req.query; + if (!id) return res.status(400).json({ message: "Missing training log ID" }); + + await connectDb(); + const existingLog = await getTrainingLog(id as string); + if (!existingLog) return res.status(404).json({ message: "Training log not found" }); + if (existingLog.user.toString() !== userId) return res.status(403).json({ message: "Unauthorized" }); + + await adjustAnimalHours(existingLog.animal.toString(), -(existingLog.hours ?? 0)); + await deleteTrainingLog(id as string); + + return res.status(200).json({ message: "Training log deleted successfully" }); + } catch (error) { + return res.status(500).json({ message: "Error deleting training log" }); + } + } +} diff --git a/src/pages/api/user.ts b/src/pages/api/user.ts new file mode 100644 index 0000000..f5a6c7a --- /dev/null +++ b/src/pages/api/user.ts @@ -0,0 +1,77 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { UserData } from "@server/mongodb/types/types"; +import { createUser, getUser, getAllUsers, deleteUser } from "@server/mongodb/actions/user"; +import connectDb from "@server/mongodb/connectDb"; +import argon2 from "argon2"; + +type UserApiData = { + userData?: UserData | Omit[]; // user data w/o password for GET + message: string; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if ( req.method === "POST" ) { + try { + if (!req.body.fullName || !req.body.email || !req.body.password) { + return res.status(400).json({ message: "Missing required fields" }); + } + + const hashedPassword = await argon2.hash(req.body.password, { type: argon2.argon2d }); + + const userData = { + fullName: req.body.fullName, + email: req.body.email, + password: hashedPassword, + admin: req.body.admin || false, + } as UserData; + + connectDb(); + const user = await createUser(userData); + res.status(200).json({ + userData: user, + message: "User created successfully" + }); + } catch (error) { + console.error("Error creating user:", error); + res.status(500).json({ + message: "Error creating user" + }); + } + } else if ( req.method === "GET" ) { + try { + await connectDb(); + if (req.query.id) { + const user = await getUser(req.query.id as string); + if (!user) return res.status(404).json({ message: "User not found" }); + const { password: _, ...safeUser } = user.toObject(); + return res.status(200).json({ userData: safeUser, message: "User retrieved successfully" }); + } + const users = await getAllUsers(); + res.status(200).json({ + userData: users, + message: "Users retrieved successfully" + }); + } catch (error) { + res.status(500).json({ + message: "Error retrieving users" + }); + } + } else if ( req.method === "DELETE" ) { + try { + if (!req.body.userId) { + return res.status(500).json({ message: "Deleting requires user ID" }); + } + + connectDb(); + await deleteUser(req.body.userId); + res.status(200).json({ message: "User deleted successfully" }); + } catch (error) { + res.status(500).json({ message: "Error deleting user" }); + } + } else { + res.status(405).json({ message: "Method not allowed" }); + } +} diff --git a/src/pages/api/user/verify.ts b/src/pages/api/user/verify.ts new file mode 100644 index 0000000..8538798 --- /dev/null +++ b/src/pages/api/user/verify.ts @@ -0,0 +1,58 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getUserByEmail } from "@server/mongodb/actions/user"; +import connectDb from "@server/mongodb/connectDb"; +import { signToken } from "@server/jwt"; +import argon2 from "argon2"; + +type VerifyApiData = { + userId?: string; + admin?: boolean; + message: string; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if ( req.method === "POST" ) { + try { + if (!req.body.email || !req.body.password) { + return res.status(400).json({ message: "Missing required fields" }); + } + + connectDb(); + const user = await getUserByEmail(req.body.email); + + if (!user) { + return res.status(500).json({ message: "User not found" }); + } + + const passwordMatch = await argon2.verify(user.password, req.body.password); + + if (!passwordMatch) { + return res.status(500).json({ message: "Invalid password" }); + } + + const token = signToken({ + userId: user._id.toString(), + admin: user.admin, + }); + + res.setHeader("Set-Cookie", `token=${token}; HttpOnly; Path=/; Max-Age=3600`); + + res.status(200).json({ + userId: user._id.toString(), + admin: user.admin, + message: "User verified successfully" + }); + } catch (error) { + res.status(500).json({ + message: "Error verifying user" + }); + } + } else { + res.status(500).json({ + message: "Invalid request method" + }); + } +} diff --git a/src/pages/dashboard/all-animals.tsx b/src/pages/dashboard/all-animals.tsx new file mode 100644 index 0000000..5fea0d5 --- /dev/null +++ b/src/pages/dashboard/all-animals.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import TitleBar from '@/components/Titlebar'; +import Sidebar from '@/components/Sidebar'; +import AnimalCard from '@/components/AnimalCard'; +import { GetServerSidePropsContext } from 'next'; +import { getServerSideUser, SessionUser } from '@server/utils/getServerSideUser'; +import { AnimalData } from '@server/mongodb/types/types'; + +type Animal = AnimalData & { _id: string }; + +export default function Animals({ user }: { user: SessionUser }) { + const [animals, setAnimals] = useState([]); + const [query, setQuery] = useState(''); + const router = useRouter(); + + useEffect(() => { + if (!user.admin) { + const timeout = setTimeout(() => router.back(), 2500); + return () => clearTimeout(timeout); + } + fetch('/api/admin/animals') + .then((res) => res.json()) + .then((data) => setAnimals(data.animalData ?? [])); + }, [user.admin]); + + const filtered = query + ? animals.filter((a) => a.name.toLowerCase().includes(query.toLowerCase())) + : animals; + + if (!user.admin) { + return ( +
+ +
+ +
+

You are not an admin and cannot access this page.

+

Redirecting you back...

+
+
+
+ ); + } + + return ( +
+ + +
+ + +
+
+

+ All animals +

+
+ +
+
+ {filtered.map((animal) => ( + + ))} +
+
+
+
+
+ ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const result = await getServerSideUser(context); + if (!('user' in result)) return result; + return { props: { user: result.user } }; +} diff --git a/src/pages/dashboard/all-training-logs.tsx b/src/pages/dashboard/all-training-logs.tsx new file mode 100644 index 0000000..30f2306 --- /dev/null +++ b/src/pages/dashboard/all-training-logs.tsx @@ -0,0 +1,93 @@ +import { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import TitleBar from '@/components/Titlebar'; +import Sidebar from '@/components/Sidebar'; +import TrainingLogCard from '@/components/TrainingLogCard'; +import { GetServerSidePropsContext } from 'next'; +import { getServerSideUser, SessionUser } from '@server/utils/getServerSideUser'; + +export default function AllTraining({ user }: { user: SessionUser }) { + const router = useRouter(); + const [allLogs, setAllLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [query, setQuery] = useState(''); + + useEffect(() => { + if (!user.admin) { + const timeout = setTimeout(() => router.back(), 2500); + return () => clearTimeout(timeout); + } + async function fetchAllLogs() { + try { + const response = await fetch('/api/admin/training'); + const data = await response.json(); + + if (response.ok) { + setAllLogs(data.trainingLogData || []); + } + } catch (error) { + console.error("Failed to fetch logs:", error); + } finally { + setLoading(false); + } + } + + fetchAllLogs(); + }, [user.admin]); + + if (!user.admin) { + return ( +
+ +
+ +
+

You are not an admin and cannot access this page.

+

Redirecting you back...

+
+
+
+ ); + } + + return ( +
+ + +
+ + +
+
+

All training logs

+
+ +
+ {loading ? ( +

Loading all logs...

+ ) : allLogs.length > 0 ? ( +
+ {(query ? allLogs.filter((log: any) => log.title.toLowerCase().includes(query.toLowerCase())) : allLogs).map((log: any) => ( + + ))} +
+ ) : ( +
+

No training logs found in the system.

+
+ )} +
+
+
+
+ ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const result = await getServerSideUser(context); + if (!('user' in result)) return result; + return { props: { user: result.user } }; +} diff --git a/src/pages/dashboard/all-users.tsx b/src/pages/dashboard/all-users.tsx new file mode 100644 index 0000000..3b50828 --- /dev/null +++ b/src/pages/dashboard/all-users.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import TitleBar from '@/components/Titlebar'; +import Sidebar from '@/components/Sidebar'; +import UserCard from '@/components/UserCard'; +import { GetServerSidePropsContext } from 'next'; +import { getServerSideUser, SessionUser } from '@server/utils/getServerSideUser'; + +export default function AllUsers({ user }: { user: SessionUser }) { + const router = useRouter(); + const [users, setUsers] = useState([]); + const [query, setQuery] = useState(''); + + useEffect(() => { + if (!user.admin) { + const timeout = setTimeout(() => router.back(), 2500); + return () => clearTimeout(timeout); + } + fetch('/api/admin/users') + .then((res) => res.json()) + .then((data) => setUsers(data.userData ?? [])); + }, [user.admin]); + + const filtered = query + ? users.filter((u) => u.fullName?.toLowerCase().includes(query.toLowerCase())) + : users; + + if (!user.admin) { + return ( +
+ +
+ +
+

You are not an admin and cannot access this page.

+

Redirecting you back...

+
+
+
+ ); + } + + return ( +
+ + +
+ + +
+
+

All users

+
+ +
+
+ {filtered.map((user) => ( + { + await fetch(`/api/admin/users?id=${user._id}`, { method: 'DELETE' }); + setUsers((prev) => prev.filter((x) => x._id !== user._id)); + }} + /> + ))} +
+
+
+
+
+ ); +} + +export async function getServerSideProps(context: GetServerSidePropsContext) { + const result = await getServerSideUser(context); + if (!('user' in result)) return result; + return { props: { user: result.user } }; +} diff --git a/src/pages/dashboard/animals/[id]/edit.tsx b/src/pages/dashboard/animals/[id]/edit.tsx new file mode 100644 index 0000000..c7762c7 --- /dev/null +++ b/src/pages/dashboard/animals/[id]/edit.tsx @@ -0,0 +1,250 @@ +import React, { useState, useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { X } from 'lucide-react'; +import TitleBar from '@/components/Titlebar'; +import Sidebar from '@/components/Sidebar'; +import { GetServerSidePropsContext } from 'next'; +import { getServerSideUser, SessionUser } from '@server/utils/getServerSideUser'; + +const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December' +]; + +export default function EditAnimal({ user }: { user: SessionUser }) { + const router = useRouter(); + const { id, returnTo } = router.query; + const backPath = typeof returnTo === 'string' ? returnTo : '/dashboard/animals'; + + const [name, setName] = useState(''); + const [breed, setBreed] = useState(''); + const [hoursTrained, setHoursTrained] = useState(0); + const [month, setMonth] = useState(months[0]); + const [day, setDay] = useState(1); + const [year, setYear] = useState(new Date().getFullYear()); + const [note, setNote] = useState(''); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(''); + + async function handleDelete() { + if (!id || !confirm('Are you sure you want to delete this animal?')) return; + setDeleting(true); + try { + const res = await fetch(`/api/animal?id=${id}`, { method: 'DELETE' }); + if (res.ok) { + router.push(backPath); + } else { + const data = await res.json(); + setError(data.message || 'Failed to delete'); + } + } catch { + setError('Failed to delete'); + } finally { + setDeleting(false); + } + } + + useEffect(() => { + if (!id) return; + fetch(`/api/animal?id=${id}`) + .then(r => r.json()) + .then(data => { + const animal = data.animalData; + if (animal) { + setName(animal.name || ''); + setBreed(animal.breed || ''); + setHoursTrained(animal.hoursTrained ?? 0); + setNote(animal.note || ''); + if (animal.birthdate) { + const d = new Date(animal.birthdate); + setMonth(months[d.getMonth()]); + setDay(d.getDate()); + setYear(d.getFullYear()); + } + } + }) + .finally(() => setLoading(false)); + }, [id]); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setSaving(true); + try { + const birthdate = new Date(year, months.indexOf(month), day).toISOString(); + const res = await fetch('/api/animal', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, name, breed, birthdate, note }), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.message || 'Failed to save'); + } else { + router.push(backPath); + } + } catch { + setError('Failed to save'); + } finally { + setSaving(false); + } + } + + if (loading) { + return ( +
+ +
+ +
+

Loading...

+
+
+
+ ); + } + + return ( +
+ + +
+ + +
+
+

Animals

+ +
+ +
+
+ +
+ + setName(e.target.value)} + required + className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 placeholder-gray-400" + /> +
+ +
+ + setBreed(e.target.value)} + required + className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 placeholder-gray-400" + /> +
+ +
+ + +

Calculated automatically from training logs.

+
+ +
+
+ +
+ +
+ +
+
+
+ +
+ + setDay(Number(e.target.value))} + className="w-full p-3 border border-gray-300 rounded-lg text-center focus:outline-none focus:ring-1 focus:ring-gray-400" + /> +
+ +
+ + setYear(Number(e.target.value))} + className="w-full p-3 border border-gray-300 rounded-lg text-center focus:outline-none focus:ring-1 focus:ring-gray-400" + /> +
+
+ +
+ +