Lots of dev
All checks were successful
ci / docker_image (push) Successful in 2m55s
ci / deploy (push) Successful in 51s

This commit is contained in:
Drake Marino 2026-02-03 00:23:43 -06:00
parent 2c2b08aa1a
commit 4ea6549ac7
66 changed files with 2125 additions and 172 deletions

2
.gitignore vendored
View File

@ -2,6 +2,8 @@
node_modules node_modules
uploads
# Output # Output
.output .output
.vercel .vercel

View File

@ -24,7 +24,8 @@ export default defineConfig(
rules: { rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off' 'no-undef': 'off',
'svelte/no-navigation-without-resolve': 'off'
} }
}, },
{ {

640
package-lock.json generated
View File

@ -13,7 +13,10 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mode-watcher": "^1.1.0",
"nodemailer": "^7.0.13",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"sharp": "^0.34.5",
"svelte-preprocess": "^6.0.3" "svelte-preprocess": "^6.0.3"
}, },
"devDependencies": { "devDependencies": {
@ -26,7 +29,10 @@
"@sveltejs/vite-plugin-svelte": "^6.2.0", "@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22", "@types/node": "^22",
"@types/nodemailer": "^7.0.9",
"@vitest/browser": "^3.2.4", "@vitest/browser": "^3.2.4",
"bits-ui": "^2.15.4", "bits-ui": "^2.15.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -86,6 +92,16 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@emnapi/runtime": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.10", "version": "0.25.10",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz",
@ -786,6 +802,471 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@internationalized/date": { "node_modules/@internationalized/date": {
"version": "3.10.1", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
@ -1768,6 +2249,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/chai": { "node_modules/@types/chai": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz",
@ -1805,6 +2296,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/node": {
"version": "22.18.10", "version": "22.18.10",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.10.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.10.tgz",
@ -1815,6 +2324,16 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/nodemailer": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -2768,7 +3287,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -3660,7 +4178,6 @@
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
@ -4370,6 +4887,69 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/mode-watcher": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
"integrity": "sha512-mUT9RRGPDYenk59qJauN1rhsIMKBmWA3xMF+uRwE8MW/tjhaDSCCARqkSuDTq8vr4/2KcAxIGVjACxTjdk5C3g==",
"license": "MIT",
"dependencies": {
"runed": "^0.25.0",
"svelte-toolbelt": "^0.7.1"
},
"peerDependencies": {
"svelte": "^5.27.0"
}
},
"node_modules/mode-watcher/node_modules/runed": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.25.0.tgz",
"integrity": "sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==",
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/mode-watcher/node_modules/svelte-toolbelt": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.7.1.tgz",
"integrity": "sha512-HcBOcR17Vx9bjaOceUvxkY3nGmbBmCBBbuWLLEWO6jtmWH8f/QoWmbyUfQZrpDINH39en1b8mptfPQT9VKQ1xQ==",
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.23.2",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
},
"node_modules/mode-watcher/node_modules/svelte-toolbelt/node_modules/runed": {
"version": "0.23.4",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.23.4.tgz",
"integrity": "sha512-9q8oUiBYeXIDLWNK5DfCWlkL0EW3oGbk845VdKlPeia28l751VpfesaB/+7pI6rnbx1I6rqoZ2fZxptOJLxILA==",
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"dependencies": {
"esm-env": "^1.0.0"
},
"peerDependencies": {
"svelte": "^5.7.0"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@ -4451,6 +5031,15 @@
"node-gyp-build-test": "build-test.js" "node-gyp-build-test": "build-test.js"
} }
}, },
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -5326,6 +5915,50 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -5513,7 +6146,6 @@
"version": "1.0.14", "version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"inline-style-parser": "0.2.7" "inline-style-parser": "0.2.7"
@ -5903,7 +6535,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "devOptional": true,
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tw-animate-css": { "node_modules/tw-animate-css": {

View File

@ -25,7 +25,10 @@
"@sveltejs/vite-plugin-svelte": "^6.2.0", "@sveltejs/vite-plugin-svelte": "^6.2.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.13",
"@types/bcrypt": "^6.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22", "@types/node": "^22",
"@types/nodemailer": "^7.0.9",
"@vitest/browser": "^3.2.4", "@vitest/browser": "^3.2.4",
"bits-ui": "^2.15.4", "bits-ui": "^2.15.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -55,7 +58,10 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"mode-watcher": "^1.1.0",
"nodemailer": "^7.0.13",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"sharp": "^0.34.5",
"svelte-preprocess": "^6.0.3" "svelte-preprocess": "^6.0.3"
} }
} }

4
src/app.d.ts vendored
View File

@ -3,7 +3,9 @@
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} interface Locals {
user: UserPayload | null;
}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}

25
src/hooks.server.ts Normal file
View File

@ -0,0 +1,25 @@
import jwt from 'jsonwebtoken';
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
export const handle = async ({ event, resolve }) => {
const JWT = event.cookies.get('jwt');
if (process.env.JWT_SECRET === undefined) {
throw new Error('JWT_SECRET not defined');
}
if (!JWT) {
event.locals.user = null;
} else {
try {
event.locals.user = jwt.verify(JWT, process.env.JWT_SECRET);
} catch {
event.cookies.delete('jwt', { path: '/' });
event.locals.user = null;
}
}
return await resolve(event);
};

View File

@ -1,4 +1,4 @@
import type { User } from '$lib/types'; import type { User } from '$lib/types/user';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import sql from '$lib/db/db.server'; import sql from '$lib/db/db.server';
import type { Cookies } from '@sveltejs/kit'; import type { Cookies } from '@sveltejs/kit';
@ -49,22 +49,22 @@ export function setJWT(cookies: Cookies, user: User) {
export async function login(email: string, password: string): Promise<User> { export async function login(email: string, password: string): Promise<User> {
try { try {
const [user] = await sql` const [user]: User[] = await sql`
SELECT id, email, password_hash, perms, name SELECT * FROM users
WHERE email = ${email}; WHERE email = ${email};
`; `;
if (await bcrypt.compare(password, user.password_hash)) { if (await bcrypt.compare(password, user.password_hash!)) {
delete user.password_hash; delete user.password_hash;
// eslint-disable-next-line @typescript-eslint/no-unused-expressions // eslint-disable-next-line @typescript-eslint/no-unused-expressions
sql` sql`
UPDATE users UPDATE users
SET last_signin = NOW() SET last_sign_in = NOW()
WHERE id = ${user.id}; WHERE id = ${user.id};
`; `;
return <User>user; return user;
} }
} catch { } catch {
throw Error('Error signing in '); throw Error('Error signing in ');

View File

@ -0,0 +1,166 @@
<script lang="ts">
import ImagePlus from '@lucide/svelte/icons/image-plus';
import X from '@lucide/svelte/icons/x';
import { onDestroy } from 'svelte';
export let name = 'image';
export let required = false;
export let disabled = false;
export let onSelect: ((file: File) => void) | null = null;
let inputEl: HTMLInputElement | null = null;
let previewUrl: string | null = null;
let dragging = false;
function openFileDialog() {
if (!disabled) {
inputEl?.click();
}
}
function handleFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const selected = files[0];
if (!selected.type.startsWith('image/')) return;
cleanupPreview();
previewUrl = URL.createObjectURL(selected);
// Trigger callback
if (onSelect) onSelect(selected);
}
function onInputChange(e: Event) {
const target = e.target as HTMLInputElement;
handleFiles(target.files);
}
function onDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
if (disabled) return;
handleFiles(e.dataTransfer?.files ?? null);
}
function cleanupPreview() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
}
onDestroy(cleanupPreview);
</script>
<div>
<input
bind:this={inputEl}
type="file"
name={name}
accept="image/*"
required={required}
disabled={disabled}
capture="environment"
on:change={onInputChange}
hidden
/>
<button
class="dropzone"
class:has-image={!!previewUrl}
class:dragging={dragging}
on:click={openFileDialog}
on:dragover|preventDefault={() => !disabled && (dragging = true)}
on:dragleave={() => (dragging = false)}
on:drop={onDrop}
type="button"
>
{#if previewUrl}
<img src={previewUrl} alt="Selected preview" />
<div class="overlay">
<ImagePlus size={24} />
<span>Replace image</span>
</div>
{:else}
<div class="placeholder py-4">
<ImagePlus size={32} />
<p>Click or drag an image here <span class="text-error">{required ? '*' : ''}</span></p>
</div>
{/if}
</button>
{#if previewUrl}
<button class="hover:text-destructive p-2" on:click={cleanupPreview} type="button">
<X size={24} class="inline" />
<span class="inline align-middle">Remove image</span>
</button>
{/if}
</div>
<style>
.dropzone {
position: relative;
width: 100%;
max-height: 200px;
min-height: 80px;
/*height: 200px;*/
border-radius: 12px;
border: 2px dashed var(--border);
/*background: var(--bg, #fafafa);*/
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: border-color 0.2s, background 0.2s;
}
.dropzone.dragging {
border-color: var(--primary);
background: var(--muted);
}
.placeholder {
text-align: center;
color: var(--muted-foreground);
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
color: white;
display: flex;
flex-direction: column;
gap: 0.4rem;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
text-align: center;
font-size: 0.9rem;
}
.has-image:hover .overlay {
opacity: 1;
}
.has-image:hover img {
filter: brightness(0.8);
}
</style>

View File

@ -0,0 +1,45 @@
<script lang="ts">
import type { Item } from '$lib/types/item';
import { Badge } from '$lib/components/ui/badge';
import LocationIcon from '@lucide/svelte/icons/map-pinned';
export let item: Item = <Item>{
id: 2,
// title: 'Water Bottle',
foundDate: new Date(),
approvedDate: new Date(),
description: 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side.',
transferred: true,
foundLocation: 'By the tennis courts.'
};
export let admin = false;
</script>
<a href="items/{item.id}"
class="bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-2-3xs">
<img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg" alt="" class="object-cover max-h-48">
<div class="px-2 pb-2">
<div>
<!-- <div class="font-bold inline-block">{item.title}</div>-->
<!-- <div class="inline-block">-->
{#if item.transferred}
<Badge variant="secondary" class="float-right">In Lost & Found</Badge>
{:else}
<Badge variant="outline" class="float-right">With Finder</Badge>
{/if}
<div>{item.description}</div>
{#if item.foundLocation}
<div class="pt-2">
<LocationIcon class="float-left mr-1" size={24} />
<div>{item.foundLocation}</div>
</div>
{/if}
</div>
</div>
</a>

View File

@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from "bits-ui";
import CheckIcon from "@lucide/svelte/icons/check";
import MinusIcon from "@lucide/svelte/icons/minus";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>

View File

@ -0,0 +1,6 @@
import Root from "./checkbox.svelte";
export {
Root,
//
Root as Checkbox,
};

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import DialogPortal from './dialog-portal.svelte';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
"max-h-[calc(100%-2rem)] overflow-y-auto bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="ring-offset-background focus:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn("flex flex-col gap-2 text-center sm:text-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn("text-lg leading-none font-semibold", className)}
{...restProps}
/>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
import Title from "./dialog-title.svelte";
import Footer from "./dialog-footer.svelte";
import Header from "./dialog-header.svelte";
import Overlay from "./dialog-overlay.svelte";
import Content from "./dialog-content.svelte";
import Description from "./dialog-description.svelte";
import Trigger from "./dialog-trigger.svelte";
import Close from "./dialog-close.svelte";
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose,
};

View File

@ -0,0 +1,10 @@
import Root from "./radio-group.svelte";
import Item from "./radio-group-item.svelte";
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem,
};

View File

@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute start-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn("grid gap-3", className)}
{...restProps}
/>

View File

@ -0,0 +1,37 @@
import Root from "./select.svelte";
import Group from "./select-group.svelte";
import Label from "./select-label.svelte";
import Item from "./select-item.svelte";
import Content from "./select-content.svelte";
import Trigger from "./select-trigger.svelte";
import Separator from "./select-separator.svelte";
import ScrollDownButton from "./select-scroll-down-button.svelte";
import ScrollUpButton from "./select-scroll-up-button.svelte";
import GroupHeading from "./select-group-heading.svelte";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal,
};

View File

@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
"h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>

View File

@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />

View File

@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from "@lucide/svelte/icons/check";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />

View File

@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>

View File

@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />

View File

@ -0,0 +1,7 @@
import Root from "./textarea.svelte";
export {
Root,
//
Root as Textarea,
};

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLTextareaAttributes } from "svelte/elements";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
"data-slot": dataSlot = "textarea",
...restProps
}: WithoutChildren<WithElementRef<HTMLTextareaAttributes>> = $props();
</script>
<textarea
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
bind:value
{...restProps}
></textarea>

View File

@ -7,3 +7,22 @@ export const PERMISSIONS = {
}; };
export const EXPIRE_REMINDER_DAYS = 30; export const EXPIRE_REMINDER_DAYS = 30;
// const EMAIL_REGEX = new RegExp(
// // eslint-disable-next-line no-control-regex
// "([!#-'*+/-9=?A-Z^-~-]+(\.[!#-'*+/-9=?A-Z^-~-]+)*|\"\(\[\]!#-[^-~ \t]|(\\[\t -~]))+\")@([!#-'*+/-9=?A-Z^-~-]+(\.[!#-'*+/-9=?A-Z^-~-]+)*|\[[\t -Z^-~]*])"
// );
// const EMAIL_REGEX = new RegExp(
// /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
// );
// const EMAIL_REGEX =
// // eslint-disable-next-line no-control-regex
// /(?:[a-z0-9!#$%&'*+\/=?^`\{-\}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`\{-\}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/;
const EMAIL_REGEX =
/^(?!\.)(?!.*\.\.)([a-z0-9_'+\-.]*)[a-z0-9_'+-]@([a-z0-9][a-z0-9-]*\.)+[a-z]{2,}$/i;
// Replace single quote with HTML entity or remove it from the character class
export const EMAIL_REGEX_STRING = EMAIL_REGEX.source.replace(/'/g, '&#39;');

View File

@ -8,7 +8,8 @@ const sql = postgres({
port: parseInt(process.env.POSTGRES_PORT!), port: parseInt(process.env.POSTGRES_PORT!),
database: process.env.POSTGRES_DB, database: process.env.POSTGRES_DB,
username: process.env.POSTGRES_USER, username: process.env.POSTGRES_USER,
password: process.env.POSTGRES_PASSWORD password: process.env.POSTGRES_PASSWORD,
transform: postgres.camel
}); });
export default sql; export default sql;

View File

@ -1,67 +0,0 @@
import type { User } from '$lib/types';
import sql from '$lib/db/db.server';
import bcrypt from 'bcrypt';
// should require MANAGE_USERS permission
export async function getUsers(searchQuery: string | null = null): Promise<User[]> {
return sql`
SELECT id,
email,
perms,
name,
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn"
FROM users
WHERE (${!searchQuery ? sql`TRUE` : sql`email ILIKE ${'%' + searchQuery + '%'} OR name ILIKE ${'%' + searchQuery + '%'}`});
`;
}
export async function getUser(id: number): Promise<User> {
return <User>(
await sql`
SELECT id,
email,
perms,
name,
created_at AT TIME ZONE 'UTC' AS "createdAt",
last_signin AT TIME ZONE 'UTC' AS "lastSignIn"
FROM users
WHERE (id = ${id}) LIMIT 1;
`
)[0];
}
export async function createUser(user: User) {
const passwordHash = await bcrypt.hash(user.password!, 12);
await sql`
INSERT INTO users (email, password_hash, perms, name, created_at, last_signin)
VALUES (${user.email}, ${passwordHash}, ${user.perms}, ${user.name}, NOW(), NOW())
RETURNING id;
`;
}
export async function editUser(user: User) {
let passwordHash: string | undefined;
if (user.password) {
passwordHash = await bcrypt.hash(user.password, 12);
}
await sql`
UPDATE users
SET
email = ${user.email},
${passwordHash ? sql`password_hash = ${passwordHash},` : sql``}
perms = ${user.perms},
name = ${user.name}
WHERE id = ${user.id!}
RETURNING id;
`;
}
export async function deleteUser(id: number) {
await sql`
DELETE FROM users
WHERE id = ${id}
`;
}

View File

@ -0,0 +1,23 @@
import nodemailer from 'nodemailer';
// Create a transporter object using SMTP transport
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT),
secure: true, // true for 465, false for other ports
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS
}
});
export async function sendEmployerNotificationEmail() {
// Send mail with defined transport object
await transporter.sendMail({
from: `CareerConnect Notifications <${process.env.EMAIL_USER}>`,
// to: info.emails.join(', '), // EMAILING OF REAL COMPANIES DISABLED, UNCOMMENT TO ENABLE
to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING
subject: 'New Application Received!',
text: `A new application has been received for the posting ''!\n\nCheck it out at ${process.env.BASE_URL}/`
});
}

View File

@ -1,4 +1,5 @@
import type { User } from '$lib/types'; import type { User } from '$lib/types/user';
import { fail } from '@sveltejs/kit';
export const getCookieValue = (name: string): string => export const getCookieValue = (name: string): string =>
document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || ''; document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || '';
@ -21,15 +22,21 @@ export function getFormString(data: FormData, key: string): string | undefined {
} }
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw Error(`Incorrect input in field ${key}.`); throw fail(400, {
error: `Incorrect input in field ${key}.`,
success: false
});
} }
return value.trim(); return value.trim();
} }
export function getRequiredFormString(data: FormData, key: string) { export function getRequiredFormString(data: FormData, key: string) {
const value = data.get(key); const value = data.get(key);
if (typeof value !== 'string') { if (typeof value !== 'string' || value === '') {
throw Error(`Missing required field ${key}.`); throw fail(400, {
error: `Missing required field ${key}.`,
success: false
});
} }
return value.trim(); return value.trim();
} }

View File

@ -1,22 +0,0 @@
export interface User {
id: number;
email: string;
phone: string;
password?: string;
name: string;
createdAt: Date;
lastSignIn: Date;
}
export interface Item {
id: number;
ownerEmail: string;
ownerPhone: string;
foundDate: Date;
approvedDate?: Date;
claimedDate?: Date;
title: string;
description: string;
transferred: boolean; // to L&F location
keywords?: string[];
}

View File

@ -0,0 +1,13 @@
export enum Sender {
ADMIN = 'admin',
FINDER = 'finder',
INQUIRER = 'inquirer'
}
export interface message {
id: number;
threadId: number;
sender: Sender;
body: string;
createdAt: Date;
}

13
src/lib/types/item.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
export interface Item {
id: number;
emails?: string[];
// ownerPhone: string;
foundDate: Date;
approvedDate?: Date;
claimedDate?: Date;
// title: string;
description: string;
transferred: boolean; // to L&F location
keywords?: string[];
foundLocation: string;
}

30
src/lib/types/user.ts Normal file
View File

@ -0,0 +1,30 @@
export interface User {
id: number;
username: string;
email: string;
name: string;
password?: string;
password_hash?: string;
settings?: UserSettings;
createdAt: Date;
lastSignIn: Date;
}
export interface UserPayload {
id: number;
email: string;
username: string;
name: string;
}
export interface UserSettings {
staleItemDays: number;
notifyAllApprovedInquiries?: boolean;
notifyAllTurnedInInquiries?: boolean;
}
export const DefaultUserSettings: UserSettings = {
staleItemDays: 30,
notifyAllApprovedInquiries: false,
notifyAllTurnedInInquiries: false
};

34
src/routes/+error.svelte Normal file
View File

@ -0,0 +1,34 @@
<script>
import { page } from '$app/state';
</script>
<div class="text-center" style="padding-top: 32px">
<h1 class="text-9xl font-bold">
{page.status}
</h1>
<h1 class="text-5xl mb-6">That's an error</h1>
{#if page.status === 404}
<p>We cant seem to find the page you are looking for.</p>
<p>The address may be mistyped, or the page may have moved or been deleted.</p>
{/if}
{#if page.status === 403}
<p>You dont have access to this page!</p>
<p>Please contact your admin if you think this is a mistake</p>
{/if}
{#if page.status === 401}
<p>You must be signed-in to view this page!</p>
{/if}
{#if page.status === 500}
<p>This one is on our end...</p>
<p>We are working to resolve this as fast as possible.</p>
{/if}
{#if page.status === 400}
<p>Invalid request!</p>
<p>Make sure you have correctly inputted all information.</p>
{/if}
{#if page.status !== 404 && page.status !== 403 && page.status !== 401 && page.status !== 500 && page.status !== 400}
<p>An unexpected error has occurred.</p>
<p>Please try again later</p>
{/if}
<p class="mt-8">Error: {page.error?.message}</p>
</div>

View File

@ -1,10 +1,5 @@
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { getUserFromJWT } from '$lib/shared';
export const load: LayoutServerLoad = ({ cookies }) => { export const load: LayoutServerLoad = async ({ locals }) => {
const jwt = cookies.get('jwt'); return { user: locals.user };
if (jwt) {
return { selfUser: getUserFromJWT(jwt) };
}
return { selfUser: null };
}; };

View File

@ -1,29 +1,59 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import MdevTriangle from '$lib/components/custom/mdev-triangle.svelte';
import { ModeWatcher, toggleMode } from 'mode-watcher';
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
let { children } = $props(); let { children } = $props();
</script> </script>
<link <ModeWatcher />
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..40,400,0,0&display=block&icon_names=account_circle,arrow_drop_down,arrow_drop_up,calendar_today,call,check,close,cloud_upload,dark_mode,delete,description,edit,group,home,info,light_mode,login,mail,menu,open_in_new,person,search,sell,store,upload,visibility,visibility_off,work"
rel="stylesheet"
/>
<!--<header class="bottom-border flex justify-between">--> <div class="flex flex-col min-h-dvh">
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/">home</a>-->
<!-- {#if data.selfUser}-->
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/account"-->
<!-- >account_circle</a-->
<!-- >-->
<!-- {:else}-->
<!-- <a class="material-symbols-outlined p-2 !text-3xl leading-none" href="/signin">login</a>-->
<!-- {/if}-->
<!--</header>-->
<div style="max-width: 800px;" class="center"> <div class="flex justify-center">
<header class="flex justify-between items-center max-w-7xl w-screen p-4">
<a href="/" class="flex items-center gap-2 text-2xl font-bold">
<MdevTriangle size={48} class="text-primary" />
<span class="hidden sm:block">MarinoDev Lost & Found</span>
</a>
<div class="items-center flex gap-4">
<a href="/login" class="{buttonVariants({ variant: 'outline' })}">Admin Log in</a>
<div class="inline-block">
<Button onclick={toggleMode} variant="outline" size="icon">
<SunIcon
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</Button>
</div>
</div>
</header>
</div>
<div class="flex-1">
<div class="flex justify-center">
<main class="w-full">
{@render children?.()} {@render children?.()}
</main>
</div>
</div>
<footer class="gap-2 py-2 text-center text-sm text-muted-foreground">
<p>
{new Date().getFullYear()} MarinoDev. <a href="https://git.marinodev.com/drake/fbla26"
class="hover:underline">Git Repository</a>
</p>
<!-- <p>-->
<!-- <a href="/privacy" class="underline hover:text-gray-700">Privacy Policy</a> |-->
<!-- <a href="/terms" class="underline hover:text-gray-700">Terms of Service</a>-->
<!-- </p>-->
</footer>
</div> </div>
<footer>
</footer>

View File

@ -0,0 +1,89 @@
import type { Actions, PageServerLoad } from './$types';
import { getFormString, getRequiredFormString } from '$lib/shared';
import { fail } from '@sveltejs/kit';
import path from 'path';
import sql from '$lib/db/db.server';
import sharp from 'sharp';
import { writeFileSync } from 'node:fs';
import type { Item } from '$lib/types/item';
export const load: PageServerLoad = async () => {
const items: Item[] = await sql`SELECT * FROM items;`;
return {
items
};
};
export const actions = {
create: async ({ request }) => {
const data = await request.formData();
const description = getRequiredFormString(data, 'description');
const foundLocation = getFormString(data, 'foundLocation') || null;
const email = getFormString(data, 'email') || null;
const location = getFormString(data, 'location') || null;
const file = data.get('image')!;
if (!email && location !== 'turnedIn') {
fail(400, {
error: "Email is required if it is still in finder's possession",
success: false
});
}
if (!(file instanceof File)) {
return fail(400, { error: 'No file uploaded or file is invalid', success: false });
}
// Convert File → Buffer
const inputBuffer = Buffer.from(await file.arrayBuffer());
// Detect format (Sharp does this internally)
const image = sharp(inputBuffer);
const metadata = await image.metadata();
let outputBuffer: Buffer;
if (metadata.format === 'jpeg') {
// Already JPG → keep as-is
outputBuffer = inputBuffer;
} else {
// Convert to JPG
outputBuffer = await image
.jpeg({ quality: 90 }) // adjust if needed
.toBuffer();
}
let emailList = null;
if (email) {
emailList = [email];
}
const response = await sql`
INSERT INTO items (
email,
description,
transferred,
found_location
) VALUES (
${emailList},
${description},
${location === 'turnedIn'},
${foundLocation}
)
RETURNING id;
`;
try {
// It's a good idea to validate the filename to prevent path traversal attacks
const savePath = path.join('uploads', `${response[0]['id']}.jpg`);
writeFileSync(savePath, outputBuffer);
} catch (err) {
console.error('File upload failed:', err);
return fail(500, { error: 'Internal server error during file upload', success: false });
}
return { success: true };
}
} satisfies Actions;

View File

@ -1,6 +1,111 @@
<h1>Welcome to SvelteKit</h1> <script lang="ts">
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
import type { PageProps } from './$types';
import { EMAIL_REGEX_STRING } from '$lib/consts';
import { genDescription } from './gen-desc.remote';
import * as RadioGroup from '$lib/components/ui/radio-group';
import { Label } from '$lib/components/ui/label';
import ItemListing from '$lib/components/custom/item-listing.svelte';
let itemLocation: string | undefined = $state('');
let foundLocation: string | undefined = $state();
let description: string | undefined = $state();
let isGenerating = $state(false);
async function onSelect() {
isGenerating = true;
description = await genDescription();
isGenerating = false;
}
let { data }: PageProps = $props();
</script>
<div class="max-w-7xl mx-auto px-4">
<div class="justify-between flex">
<h1 class="font-semibold text-4xl mb-4 mt-2">Found Items</h1>
<div class="inline-block">
<Dialog.Root>
<Dialog.Trigger class={buttonVariants({ variant: 'default' })} type="button"
>Post an item
</Dialog.Trigger
>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Submit Found Item</Dialog.Title>
<Dialog.Description>
Your item will need to be approved before becoming public.
</Dialog.Description>
</Dialog.Header>
<form method="post" action="?/create" enctype="multipart/form-data">
<Field.Group>
<ImageUpload onSelect={onSelect} required={true} />
<Field.Field>
<Field.Label for="description">
Description<span class="text-error">*</span>
</Field.Label>
<Input id="description" name="description" bind:value={description}
placeholder="A red leather book bag..."
required />
</Field.Field>
<Field.Field>
<Field.Label for="foundLocation">
Where did you find it?
</Field.Label>
<Input id="foundLocation" name="foundLocation" bind:value={foundLocation}
placeholder="By the tennis courts." required />
</Field.Field>
<RadioGroup.Root name="location" bind:value={itemLocation}>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="finderPossession" id="finderPossession" />
<Label for="finderPossession">I still have the item.</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value="turnedIn" id="turnedIn" />
<Label for="turnedIn">I turned the item in to the school lost and found.</Label>
</div>
</RadioGroup.Root>
<Field.Field class={itemLocation !== 'finderPossession' ? 'disabled hidden' : ''}>
<Field.Label for="email">
Your Email
</Field.Label>
<Input id="email" name="email" placeholder="name@domain.com"
class={itemLocation !== 'finderPossession' ? 'disabled' : ''} pattern={EMAIL_REGEX_STRING}
required={itemLocation === 'finderPossesion'} />
<!-- <Field.Error>Enter a valid email address.</Field.Error>-->
</Field.Field>
</Field.Group>
<Dialog.Footer class="mt-4">
<Dialog.Close class={buttonVariants({ variant: "outline" })} type="button"
>Cancel
</Dialog.Close
>
<Button type="submit">Submit</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>
</div>
</div>
<div class="justify-start grid gap-4 grid-cols-[repeat(auto-fill,minmax(16rem,max-content))]">
{#each data.items as item (item.id)}
<ItemListing item={item} />
{/each}
</div>
</div>
{#if isGenerating}
<div class="fixed inset-0 bg-black/75 z-999999 w-screen h-screen justify-center items-center flex">
<p class="text-6xl text-primary">Loading...</p>
</div>
{/if}

View File

View File

@ -0,0 +1,143 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte';
let month = $state<string>();
let year = $state<string>();
</script>
<div class="w-full max-w-lg mt-8">
<form>
<Field.Group>
<Field.Set>
<Field.Legend>Submit Found Item</Field.Legend>
<Field.Description
>Your item will need to be approved before becoming public
</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label for="image-upload">
Image
</Field.Label>
<ImageUpload></ImageUpload>
</Field.Field>
<Field.Field>
<Field.Label for="checkout-7j9-card-name-43j"
>Name on Card
</Field.Label
>
<Input
id="checkout-7j9-card-name-43j"
placeholder="John Doe"
required
/>
</Field.Field>
<div class="grid grid-cols-3 gap-4">
<Field.Field class="col-span-2">
<Field.Label for="checkout-7j9-card-number-uw1">
Card Number
</Field.Label>
<Input
id="checkout-7j9-card-number-uw1"
placeholder="1234 5678 9012 3456"
required
/>
<Field.Description>Enter your 16-digit number.</Field.Description>
</Field.Field>
<Field.Field class="col-span-1">
<Field.Label for="checkout-7j9-cvv">CVV</Field.Label>
<Input id="checkout-7j9-cvv" placeholder="123" required />
</Field.Field>
</div>
<div class="grid grid-cols-2 gap-4">
<Field.Field>
<Field.Label for="checkout-7j9-exp-month-ts6">Month</Field.Label>
<Select.Root type="single" bind:value={month}>
<Select.Trigger id="checkout-7j9-exp-month-ts6">
<span>
{month || "MM"}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="01">01</Select.Item>
<Select.Item value="02">02</Select.Item>
<Select.Item value="03">03</Select.Item>
<Select.Item value="04">04</Select.Item>
<Select.Item value="05">05</Select.Item>
<Select.Item value="06">06</Select.Item>
<Select.Item value="07">07</Select.Item>
<Select.Item value="08">08</Select.Item>
<Select.Item value="09">09</Select.Item>
<Select.Item value="10">10</Select.Item>
<Select.Item value="11">11</Select.Item>
<Select.Item value="12">12</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label for="checkout-7j9-exp-year-f59">Year</Field.Label>
<Select.Root type="single" bind:value={year}>
<Select.Trigger id="checkout-7j9-exp-year-f59">
<span>
{year || "YYYY"}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="2024">2024</Select.Item>
<Select.Item value="2025">2025</Select.Item>
<Select.Item value="2026">2026</Select.Item>
<Select.Item value="2027">2027</Select.Item>
<Select.Item value="2028">2028</Select.Item>
<Select.Item value="2029">2029</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
</div>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>Billing Address</Field.Legend>
<Field.Description>
The billing address associated with your payment method
</Field.Description>
<Field.Group>
<Field.Field orientation="horizontal">
<Checkbox id="checkout-7j9-same-as-shipping-wgm" checked={true} />
<Field.Label
for="checkout-7j9-same-as-shipping-wgm"
class="font-normal"
>
Same as shipping address
</Field.Label>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Group>
<Field.Field>
<Field.Label for="checkout-7j9-optional-comments"
>Comments
</Field.Label
>
<Textarea
id="checkout-7j9-optional-comments"
placeholder="Add any additional comments"
class="resize-none"
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Field orientation="horizontal">
<Button type="submit">Submit</Button>
<Button variant="outline" type="button">Cancel</Button>
</Field.Field>
</Field.Group>
</form>
</div>

View File

@ -0,0 +1,7 @@
import { query } from '$app/server';
export const genDescription = query(async () => {
await new Promise((f) => setTimeout(f, 1000));
return 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side, resting on a tree trunk in a forest.';
});

View File

View File

@ -1,27 +1,16 @@
<script lang="ts"> <script lang="ts">
import LoginForm from '$lib/components/login-form.svelte'; import LoginForm from './login-form.svelte';
import MdevTriangle from '$lib/components/mdev-triangle.svelte';
</script> </script>
<div class="grid min-h-svh lg:grid-cols-2 w-screen"> <div class="grid min-h-svh lg:grid-cols-2">
<div class="flex flex-col gap-4 p-6 md:p-10"> <div class="flex flex-col gap-4 md:p-6">
<div class="flex justify-center gap-2 md:justify-start"> <div class="flex-1"></div>
<a href="/" class="flex items-center gap-2 font-medium"> <div class="justify-center flex">
<div
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md"
>
<MdevTriangle />
</div>
MarinoDev
</a>
</div>
<div class="flex flex-1 items-center justify-center">
<div class="w-full max-w-xs"> <div class="w-full max-w-xs">
<LoginForm /> <LoginForm />
</div> </div>
</div> </div>
<div class="flex-3"></div>
</div> </div>
<div class="bg-muted relative hidden lg:block"> <div class="bg-muted relative hidden lg:block">
<img <img

View File

@ -2,11 +2,10 @@
import { import {
FieldGroup, FieldGroup,
Field, Field,
FieldLabel, FieldLabel
FieldDescription } from '$lib/components/ui/field';
} from '$lib/components/ui/field/index.js'; import { Input } from '$lib/components/ui/input';
import { Input } from '$lib/components/ui/input/index.js'; import { Button } from '$lib/components/ui/button';
import { Button } from '$lib/components/ui/button/index.js';
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLFormAttributes } from 'svelte/elements'; import type { HTMLFormAttributes } from 'svelte/elements';
@ -21,7 +20,7 @@
<div class="flex flex-col items-center gap-1 text-center"> <div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-2xl font-bold">Login to your account</h1> <h1 class="text-2xl font-bold">Login to your account</h1>
<p class="text-muted-foreground text-sm text-balance"> <p class="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account Only admins need to log in.<br>Lost something? Go <a href="/" class="underline text-primary">home</a>.
</p> </p>
</div> </div>
<Field> <Field>
@ -40,11 +39,11 @@
<Field> <Field>
<Button type="submit">Login</Button> <Button type="submit">Login</Button>
</Field> </Field>
<Field> <!-- <Field>-->
<FieldDescription class="text-center"> <!-- <FieldDescription class="text-center">-->
Don't have an account? <!-- Don't have an account?-->
<a href="/signup" class="underline underline-offset-4">Sign up</a> <!-- <a href="/signup" class="underline underline-offset-4">Sign up</a>-->
</FieldDescription> <!-- </FieldDescription>-->
</Field> <!-- </Field>-->
</FieldGroup> </FieldGroup>
</form> </form>

View File

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import SignupForm from '$lib/components/signup-form.svelte'; import SignupForm from './signup-form.svelte';
import MdevTriangle from '$lib/components/mdev-triangle.svelte'; import MdevTriangle from '$lib/components/custom/mdev-triangle.svelte';
</script> </script>
<div class="grid min-h-svh lg:grid-cols-2 w-screen"> <div class="grid min-h-svh lg:grid-cols-2 w-screen">

View File

@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils.js'; import { cn } from '$lib/utils.js';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field/index.js'; import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
let { class: className, ...restProps }: HTMLAttributes<HTMLFormElement> = $props(); let { class: className, ...restProps }: HTMLAttributes<HTMLFormElement> = $props();

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -6,7 +6,15 @@ const config = {
preprocess: sveltePreprocess(), preprocess: sveltePreprocess(),
kit: { kit: {
adapter: adapter() adapter: adapter(),
experimental: {
remoteFunctions: true
}
},
compilerOptions: {
experimental: {
async: true
}
} }
}; };