feat: added product page

This commit is contained in:
Tobias Klemp
2025-11-29 22:27:37 +01:00
parent 90280fd436
commit 6d03984e21
85 changed files with 1869 additions and 436 deletions

3
.gitignore vendored
View File

@@ -22,8 +22,5 @@ Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Paraglide
src/lib/paraglide
# SQLite
*.db

View File

@@ -8,6 +8,7 @@
"@effect/platform-bun": "^0.81.1",
"@lucide/svelte": "^0.544.0",
"@node-rs/argon2": "^2.0.2",
"@tolgee/svelte": "^6.2.7",
"clsx": "^2.1.1",
"effect": "^3.18.4",
"puppeteer": "^24.26.1",
@@ -18,7 +19,6 @@
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@inlang/paraglide-js": "^2.3.2",
"@internationalized/date": "^3.8.1",
"@libsql/client": "^0.15.15",
"@oslojs/crypto": "^1.0.1",
@@ -175,12 +175,6 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@inlang/paraglide-js": ["@inlang/paraglide-js@2.4.0", "", { "dependencies": { "@inlang/recommend-sherlock": "0.2.1", "@inlang/sdk": "2.4.9", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", "unplugin": "^2.1.2", "urlpattern-polyfill": "^10.0.0" }, "bin": { "paraglide-js": "bin/run.js" } }, "sha512-T/m9uoev574/1JrhCnPcgK1xnAwkVMgaDev4LFthnmID8ubX2xjboSGO3IztwXWwO0aJoT1UJr89JCwjbwgnJQ=="],
"@inlang/recommend-sherlock": ["@inlang/recommend-sherlock@0.2.1", "", { "dependencies": { "comment-json": "^4.2.3" } }, "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg=="],
"@inlang/sdk": ["@inlang/sdk@2.4.9", "", { "dependencies": { "@lix-js/sdk": "0.4.7", "@sinclair/typebox": "^0.31.17", "kysely": "^0.27.4", "sqlite-wasm-kysely": "0.3.0", "uuid": "^10.0.0" } }, "sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ=="],
"@internationalized/date": ["@internationalized/date@3.10.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -221,10 +215,6 @@
"@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.22", "", { "os": "win32", "cpu": "x64" }, "sha512-Fj0j8RnBpo43tVZUVoNK6BV/9AtDUM5S7DF3LB4qTYg1LMSZqi3yeCneUTLJD6XomQJlZzbI4mst89yspVSAnA=="],
"@lix-js/sdk": ["@lix-js/sdk@0.4.7", "", { "dependencies": { "@lix-js/server-protocol-schema": "0.1.1", "dedent": "1.5.1", "human-id": "^4.1.1", "js-sha256": "^0.11.0", "kysely": "^0.27.4", "sqlite-wasm-kysely": "0.3.0", "uuid": "^10.0.0" } }, "sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ=="],
"@lix-js/server-protocol-schema": ["@lix-js/server-protocol-schema@0.1.1", "", {}, "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ=="],
"@lucide/svelte": ["@lucide/svelte@0.544.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
@@ -379,10 +369,6 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="],
"@sinclair/typebox": ["@sinclair/typebox@0.31.28", "", {}, "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ=="],
"@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.48.0-build4", "", { "bin": { "sqlite-wasm": "bin/index.js" } }, "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.6", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ=="],
@@ -431,6 +417,12 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.16", "", { "dependencies": { "@tailwindcss/node": "4.1.16", "@tailwindcss/oxide": "4.1.16", "tailwindcss": "4.1.16" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg=="],
"@tolgee/core": ["@tolgee/core@6.2.7", "", {}, "sha512-0Au+m9R23/gmeaLJY0X6lKcR2LSy9dW7hEFrpFvPdJoGvxc8XCNZpKlgnm1N534CwmtIBJ4rzOK6vOrSKbl45w=="],
"@tolgee/svelte": ["@tolgee/svelte@6.2.7", "", { "dependencies": { "@tolgee/web": "6.2.7" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0" } }, "sha512-R7J3XO3g5BtUnXwvzljajRAnJeFIPB0qmisEl896L4IJUMHfsddDytaFRP90hlQzEgwRICMtbr43T6XFgSNJrQ=="],
"@tolgee/web": ["@tolgee/web@6.2.7", "", { "dependencies": { "@tolgee/core": "6.2.7" } }, "sha512-MAgHGkL5RYREwAjUansJt98fCuQFV4uDfP11NLOCHan2Hx9rpuszv88eXX7enq9CE8eS/Ed2pELiSwb5rlYiKg=="],
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -487,8 +479,6 @@
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"array-timsort": ["array-timsort@1.0.3", "", {}, "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="],
"ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
@@ -539,18 +529,10 @@
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"comment-json": ["comment-json@4.4.1", "", { "dependencies": { "array-timsort": "^1.0.3", "core-util-is": "^1.0.3", "esprima": "^4.0.1" } }, "sha512-r1To31BQD5060QdkC+Iheai7gHwoSZobzunqkf2/kQ6xIAfJyrKNAFUwdKvkK7Qgu7pVTKQEa7ok7Ed3ycAJgg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"consola": ["consola@3.4.0", "", {}, "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -563,8 +545,6 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
@@ -699,8 +679,6 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-id": ["human-id@4.1.2", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-v/J+4Z/1eIJovEBdlV5TYj1IR+ZiohcYGRY+qN/oC9dAfKzVT023N/Bgw37hrKCoVRBvk3bqyzpr2PP5YeTMSg=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -729,8 +707,6 @@
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
"js-sha256": ["js-sha256@0.11.1", "", {}, "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -743,8 +719,6 @@
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@@ -939,8 +913,6 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"sqlite-wasm-kysely": ["sqlite-wasm-kysely@0.3.0", "", { "dependencies": { "@sqlite.org/sqlite-wasm": "^3.48.0-build2" }, "peerDependencies": { "kysely": "*" } }, "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg=="],
"streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -999,12 +971,8 @@
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unplugin": ["unplugin@2.3.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
@@ -1019,8 +987,6 @@
"webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.3.8", "", {}, "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
@@ -1053,10 +1019,6 @@
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@inlang/sdk/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"@lix-js/sdk/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.6.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="],

View File

@@ -1,24 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from de-de!",
"color_variation": {
"title": "Farbe"
},
"capacity_variation": {
"title": "Speicherkapazität"
},
"condition_title": "Zustand",
"condition_variation_excellent": "Hervorragend",
"condition_variation_very_good": "Sehr gut",
"condition_variation_good": "Gut",
"condition_variation_fair": "Fair",
"condition_variation_description_excellent": "Wie neu, keine sichtbaren Gebrauchsspuren",
"condition_variation_description_very_good": "Minimale Gebrauchsspuren, voll funktionsfähig",
"condition_variation_description_good": "Leichte Gebrauchsspuren, voll funktionsfähig",
"condition_variation_description_fair": "Deutlich sichtbare Gebrauchspuren, voll funktionsfähig",
"specification_title": "Technische Daten"
}

View File

@@ -1,4 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!"
}

View File

@@ -1,4 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from es!"
}

View File

@@ -23,7 +23,6 @@
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.36.0",
"@inlang/paraglide-js": "^2.3.2",
"@internationalized/date": "^3.8.1",
"@libsql/client": "^0.15.15",
"@oslojs/crypto": "^1.0.1",
@@ -61,6 +60,7 @@
"@effect/platform-bun": "^0.81.1",
"@lucide/svelte": "^0.544.0",
"@node-rs/argon2": "^2.0.2",
"@tolgee/svelte": "^6.2.7",
"clsx": "^2.1.1",
"effect": "^3.18.4",
"puppeteer": "^24.26.1",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
zde6McVKGu95Y42pgx

View File

@@ -1,12 +0,0 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
},
"baseLocale": "en",
"locales": ["en", "es", "de-de"]
}

View File

@@ -1,6 +1,6 @@
@import "tailwindcss";
@import 'tailwindcss';
@import "tw-animate-css";
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@@ -116,6 +116,6 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground box-content;
}
}
}

View File

@@ -1,11 +1,11 @@
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,50 +1,39 @@
import { sequence } from '@sveltejs/kit/hooks'
import * as auth from '$lib/server/auth'
import type { Handle } from '@sveltejs/kit'
import { paraglideMiddleware } from '$lib/paraglide/server'
import { val, type SessionData } from '$lib/server/auth'
import { Effect, Logger, Tracer } from 'effect'
import { BunRuntime } from '@effect/platform-bun'
const handleParaglide: Handle = ({ event, resolve }) =>
paraglideMiddleware(event.request, ({ request, locale }) => {
event.request = request
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale),
})
})
import { Effect, Logger } from 'effect'
const handleAuth: Handle = async ({ event, resolve }) => {
const sessionToken = event.cookies.get(auth.sessionCookieName)
const sessionToken = event.cookies.get(auth.sessionCookieName)
if (!sessionToken) {
event.locals.user = null
event.locals.session = null
return resolve(event)
}
if (!sessionToken) {
event.locals.user = null
event.locals.session = null
return resolve(event)
}
const recoveredVal = val(sessionToken).pipe(
Effect.catchTag('DbError', (_) =>
Effect.succeed({ session: null, user: null } satisfies SessionData),
),
Effect.provide(Logger.pretty),
Effect.annotateLogs('Operation', 'ValidateToken'),
)
const recoveredVal = val(sessionToken).pipe(
Effect.catchTag('DbError', (_) =>
Effect.succeed({ session: null, user: null } satisfies SessionData),
),
Effect.provide(Logger.pretty),
Effect.annotateLogs('Operation', 'ValidateToken'),
)
const { session, user } = await Effect.runPromise(recoveredVal)
const { session, user } = await Effect.runPromise(recoveredVal)
// const { session, user } = await auth.validateSessionToken(sessionToken)
// const { session, user } = await auth.validateSessionToken(sessionToken)
if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt)
} else {
auth.deleteSessionTokenCookie(event)
}
if (session) {
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt)
} else {
auth.deleteSessionTokenCookie(event)
}
event.locals.user = user
event.locals.session = session
return resolve(event)
event.locals.user = user
event.locals.session = session
return resolve(event)
}
export const handle: Handle = sequence(handleParaglide, handleAuth)
export const handle: Handle = sequence(handleAuth)

View File

@@ -1,3 +0,0 @@
import { deLocalizeUrl } from '$lib/paraglide/runtime'
export const reroute = (request) => deLocalizeUrl(request.url).pathname

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { icons } from '@lucide/svelte'
import type { IconKeys } from '.'
let {
name,
class: className,
...props
}: { name: IconKeys; class?: string } = $props()
const Icon = $derived.by(() => {
if (name in icons) {
// @ts-ignore
return icons[name]
}
})
</script>
{#if Icon}
<Icon class={className} {...props} />
{/if}

View File

@@ -0,0 +1,5 @@
import Icon from './Icon.svelte'
import { icons } from '@lucide/svelte'
type IconKeys = keyof typeof icons
export { Icon, type IconKeys }

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import type { ImageCarouselItem } from '.'
import * as Carousel from '../carousel'
import ImgCarouselItem from './ImageCarouselItem.svelte'
import type { CarouselAPI } from '$lib/components/ui/carousel/context.js'
import { Button } from '../button'
import { ChevronLeftIcon, ChevronRightIcon } from '@lucide/svelte'
const {
items,
preview = false,
buttonPosition = 'default',
}: {
items: ImageCarouselItem[]
preview?: boolean
buttonPosition?: 'contained' | 'default' | 'hidden'
} = $props()
let api = $state<CarouselAPI>()
let currentImageIndex = $state(0)
$effect(() => {
if (!api) return
api.on('select', () => {
if (!api) return
currentImageIndex = api.selectedScrollSnap()
})
})
function onPreviewImageClick(toIndex: number) {
if (!api) return
api.scrollTo(toIndex, false)
}
function prevImage() {
if (!api) return
api.scrollPrev()
}
function nextImage() {
if (!api) return
api.scrollNext()
}
</script>
<div>
<Carousel.Root setApi={(emblaApi) => (api = emblaApi)}>
<Carousel.Content>
{#each items as item}
<ImgCarouselItem {item} />
{/each}
</Carousel.Content>
{#if buttonPosition === 'default'}
<Carousel.Next />
{:else if buttonPosition === 'contained'}
<Button
onclick={prevImage}
variant="outline"
size="icon"
class="absolute top-1/2 -translate-y-1/2 left-4 rounded-full"
><ChevronLeftIcon /></Button
>
{/if}
{#if buttonPosition === 'default'}
<Carousel.Previous />
{:else if buttonPosition === 'contained'}
<Button
onclick={nextImage}
variant="outline"
size="icon"
class="absolute top-1/2 -translate-y-1/2 right-4 rounded-full"
><ChevronRightIcon /></Button
>
{/if}
</Carousel.Root>
{#if preview}
<Carousel.Root
opts={{
align: 'start',
}}
class="hidden md:block"
>
<Carousel.Content>
{#each items as item, i}
<ImgCarouselItem
onclick={() => onPreviewImageClick(i)}
{item}
class="md:basis-1/3 lg:basis-1/4 mt-4"
isSelected={i === currentImageIndex}
/>
{/each}
</Carousel.Content>
</Carousel.Root>
{/if}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { cn } from '$lib/utils'
import type { ImageCarouselItem } from '.'
import * as Carousel from '../carousel'
const {
item,
class: className,
isSelected,
onclick,
}: {
item: ImageCarouselItem
class?: string
isSelected?: boolean
onclick?: () => void
} = $props()
const itemClass = $derived(
cn(
'object-cover rounded-lg',
isSelected
? 'border-2 transition-all border-blue-600 dark:border-blue-500'
: 'border-2 transition-all border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600',
!item.aspect || item.aspect === '1:1' ? 'aspect-square' : '',
item.aspect === '16:9' ? 'aspect-video' : '',
item.aspect === '9:16' ? 'aspect-9/16' : '',
item.aspect === '4:3' ? 'aspect-4/3' : '',
),
)
</script>
<Carousel.Item {onclick} class={className}>
<img src={item.image} alt={item.alt} class={itemClass} />
</Carousel.Item>

View File

@@ -0,0 +1,9 @@
import ImageCarousel from './ImageCarousel.svelte'
type ImageCarouselItem = {
image: string
alt: string
aspect?: '1:1' | '16:9' | '9:16' | '4:3'
}
export { ImageCarousel, type ImageCarouselItem }

View File

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

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
type Props = WithElementRef<
Omit<HTMLInputAttributes, "type"> &
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{type}
bind:value
{...restProps}
/>
{/if}

View File

@@ -0,0 +1,36 @@
import { Dialog as SheetPrimitive } from "bits-ui";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte";
import Footer from "./sheet-footer.svelte";
import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};

View File

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

View File

@@ -0,0 +1,58 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-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="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", 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="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-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,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

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

View File

@@ -0,0 +1,4 @@
<script lang="ts">
</script>
rating

View File

@@ -0,0 +1,4 @@
<script lang="ts">
</script>
shipping cost

View File

@@ -0,0 +1,4 @@
<script lang="ts">
</script>
shipping time

View File

@@ -0,0 +1,4 @@
<script lang="ts">
</script>
warranty

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { T } from '@tolgee/svelte'
import * as Card from '../card'
import type { TShopListItem } from '.'
import ShopListItem from './ShopListItem.svelte'
const { items }: { items: TShopListItem[] } = $props()
</script>
<Card.Root>
<Card.Header><T keyName="shopList.title" /></Card.Header>
<Card.Content class="flex flex-col gap-4">
{#each items as item (item)}
<ShopListItem {item} />
{/each}
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { T } from '@tolgee/svelte'
import * as Card from '../card'
import type { TShopListItem } from '.'
import { Badge } from '../badge'
import { Icon } from '../icon'
import ShopItemRating from './ShopItemRating.svelte'
import ShopItemWarranty from './ShopItemWarranty.svelte'
import ShopItemShippingTime from './ShopItemShippingTime.svelte'
import ShopItemShippingCost from './ShopItemShippingCost.svelte'
import { Button } from '../button'
import { ExternalLinkIcon } from '@lucide/svelte'
const { item }: { item: TShopListItem } = $props()
</script>
<Card.Root class="py-4">
<Card.Content>
<div class="flex md:flex-row flex-col justify-between">
<div class="flex flex-col gap-2">
<div class="flex flex-row">
<div class="dark:text-white">
{item.name}
</div>
<div class="flex flex-row gap-2 ml-4">
{#each item.badges as badge (badge.name)}
<Badge variant={badge.variant}>
{#if badge.icon}
<Icon class="mr-1" name={badge.icon} />
{/if}
<T keyName={badge.name} />
</Badge>
{/each}
</div>
</div>
<div class="flex flex-row">
<ShopItemRating />
<ShopItemShippingTime />
<ShopItemWarranty />
</div>
<div class="flex flex-row">
<ShopItemShippingCost />
</div>
</div>
<div class="flex flex-col justify-between">
<div class="text-2xl dark:text-white text-right">
{item.price}
{item.currency}
</div>
<Button
><T keyName="ShopList.item.toShop" /> <ExternalLinkIcon /></Button
>
</div>
</div>
</Card.Content>
</Card.Root>

View File

@@ -0,0 +1,28 @@
import type { BadgeVariant } from '../badge'
import type { IconKeys } from '../icon'
import ShopList from './ShopList.svelte'
type ShopListItemBadge = {
name: string
icon?: IconKeys
variant: BadgeVariant
}
type TShopListItem = {
name: string
badges: ShopListItemBadge[]
price: number
currency: string
rating: {
score: number
amount: number
}
shipping: {
shippingTime: string
shippingCost: number
}
warrantyInMonths: number
linkUrl: string
}
export { ShopList, type TShopListItem }

View File

@@ -0,0 +1,6 @@
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";

View File

@@ -0,0 +1,81 @@
import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
import { getContext, setContext } from "svelte";
import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
type Getter<T> = () => T;
export type SidebarStateProps = {
/**
* A getter function that returns the current open state of the sidebar.
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
* component.
*/
open: Getter<boolean>;
/**
* A function that sets the open state of the sidebar. To support `bind:open`, we need
* a source of truth for changing the open state to ensure it will be synced throughout
* the sub-components and any `bind:` references.
*/
setOpen: (open: boolean) => void;
};
class SidebarState {
readonly props: SidebarStateProps;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
setOpen: SidebarStateProps["setOpen"];
#isMobile: IsMobile;
state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
constructor(props: SidebarStateProps) {
this.setOpen = props.setOpen;
this.#isMobile = new IsMobile();
this.props = props;
}
// Convenience getter for checking if the sidebar is mobile
// without this, we would need to use `sidebar.isMobile.current` everywhere
get isMobile() {
return this.#isMobile.current;
}
// Event handler to apply to the `<svelte:window>`
handleShortcutKeydown = (e: KeyboardEvent) => {
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
}
};
setOpenMobile = (value: boolean) => {
this.openMobile = value;
};
toggle = () => {
return this.#isMobile.current
? (this.openMobile = !this.openMobile)
: this.setOpen(!this.open);
};
}
const SYMBOL_KEY = "scn-sidebar";
/**
* Instantiates a new `SidebarState` instance and sets it in the context.
*
* @param props The constructor props for the `SidebarState` class.
* @returns The `SidebarState` instance.
*/
export function setSidebar(props: SidebarStateProps): SidebarState {
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
}
/**
* Retrieves the `SidebarState` instance from the context. This is a class instance,
* so you cannot destructure it.
* @returns The `SidebarState` instance.
*/
export function useSidebar(): SidebarState {
return getContext(Symbol.for(SYMBOL_KEY));
}

View File

@@ -0,0 +1,75 @@
import { useSidebar } from "./context.svelte.js";
import Content from "./sidebar-content.svelte";
import Footer from "./sidebar-footer.svelte";
import GroupAction from "./sidebar-group-action.svelte";
import GroupContent from "./sidebar-group-content.svelte";
import GroupLabel from "./sidebar-group-label.svelte";
import Group from "./sidebar-group.svelte";
import Header from "./sidebar-header.svelte";
import Input from "./sidebar-input.svelte";
import Inset from "./sidebar-inset.svelte";
import MenuAction from "./sidebar-menu-action.svelte";
import MenuBadge from "./sidebar-menu-badge.svelte";
import MenuButton from "./sidebar-menu-button.svelte";
import MenuItem from "./sidebar-menu-item.svelte";
import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
import MenuSubButton from "./sidebar-menu-sub-button.svelte";
import MenuSubItem from "./sidebar-menu-sub-item.svelte";
import MenuSub from "./sidebar-menu-sub.svelte";
import Menu from "./sidebar-menu.svelte";
import Provider from "./sidebar-provider.svelte";
import Rail from "./sidebar-rail.svelte";
import Separator from "./sidebar-separator.svelte";
import Trigger from "./sidebar-trigger.svelte";
import Root from "./sidebar.svelte";
export {
Content,
Footer,
Group,
GroupAction,
GroupContent,
GroupLabel,
Header,
Input,
Inset,
Menu,
MenuAction,
MenuBadge,
MenuButton,
MenuItem,
MenuSkeleton,
MenuSub,
MenuSubButton,
MenuSubItem,
Provider,
Rail,
Root,
Separator,
//
Root as Sidebar,
Content as SidebarContent,
Footer as SidebarFooter,
Group as SidebarGroup,
GroupAction as SidebarGroupAction,
GroupContent as SidebarGroupContent,
GroupLabel as SidebarGroupLabel,
Header as SidebarHeader,
Input as SidebarInput,
Inset as SidebarInset,
Menu as SidebarMenu,
MenuAction as SidebarMenuAction,
MenuBadge as SidebarMenuBadge,
MenuButton as SidebarMenuButton,
MenuItem as SidebarMenuItem,
MenuSkeleton as SidebarMenuSkeleton,
MenuSub as SidebarMenuSub,
MenuSubButton as SidebarMenuSubButton,
MenuSubItem as SidebarMenuSubItem,
Provider as SidebarProvider,
Rail as SidebarRail,
Separator as SidebarSeparator,
Trigger as SidebarTrigger,
Trigger,
useSidebar,
};

View File

@@ -0,0 +1,24 @@
<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<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-content"
data-sidebar="content"
class={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<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<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-footer"
data-sidebar="footer"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
),
"data-slot": "sidebar-group-action",
"data-sidebar": "group-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,21 @@
<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="sidebar-group-content"
data-sidebar="group-content"
class={cn("w-full text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
),
"data-slot": "sidebar-group-label",
"data-sidebar": "group-label",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render children?.()}
</div>
{/if}

View File

@@ -0,0 +1,21 @@
<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<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group"
data-sidebar="group"
class={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<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<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-header"
data-sidebar="header"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import { Input } from "$lib/components/ui/input/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: ComponentProps<typeof Input> = $props();
</script>
<Input
bind:ref
bind:value
data-slot="sidebar-input"
data-sidebar="input"
class={cn("bg-background h-8 w-full shadow-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<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<HTMLElement>> = $props();
</script>
<main
bind:this={ref}
data-slot="sidebar-inset"
class={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ms-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ms-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</main>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
showOnHover = false,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
showOnHover?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground outline-hidden absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
),
"data-slot": "sidebar-menu-action",
"data-sidebar": "menu-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,29 @@
<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<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
class={cn(
"text-sidebar-foreground pointer-events-none absolute end-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,103 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sidebarMenuButtonVariants = tv({
base: "peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pe-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "group-data-[collapsible=icon]:p-0! h-12 text-sm",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type SidebarMenuButtonVariant = VariantProps<
typeof sidebarMenuButtonVariants
>["variant"];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>["size"];
</script>
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn, type WithElementRef, type WithoutChildrenOrChild } from "$lib/utils.js";
import { mergeProps } from "bits-ui";
import type { ComponentProps, Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
children,
child,
variant = "default",
size = "default",
isActive = false,
tooltipContent,
tooltipContentProps,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
isActive?: boolean;
variant?: SidebarMenuButtonVariant;
size?: SidebarMenuButtonSize;
tooltipContent?: Snippet | string;
tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const sidebar = useSidebar();
const buttonProps = $derived({
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
"data-slot": "sidebar-menu-button",
"data-sidebar": "menu-button",
"data-size": size,
"data-active": isActive,
...restProps,
});
</script>
{#snippet Button({ props }: { props?: Record<string, unknown> })}
{@const mergedProps = mergeProps(buttonProps, props)}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
{/snippet}
{#if !tooltipContent}
{@render Button({})}
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render Button({ props })}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
side="right"
align="center"
hidden={sidebar.state !== "collapsed" || sidebar.isMobile}
{...tooltipContentProps}
>
{#if typeof tooltipContent === "string"}
{tooltipContent}
{:else if tooltipContent}
{@render tooltipContent()}
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/if}

View File

@@ -0,0 +1,21 @@
<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<HTMLLIElement>, HTMLLIElement> = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
class={cn("group/menu-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
showIcon = false,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
showIcon?: boolean;
} = $props();
// Random width between 50% and 90%
const width = `${Math.floor(Math.random() * 40) + 50}%`;
</script>
<div
bind:this={ref}
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
class={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...restProps}
>
{#if showIcon}
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
{/if}
<Skeleton
class="max-w-(--skeleton-width) h-4 flex-1"
data-sidebar="menu-skeleton-text"
style="--skeleton-width: {width};"
/>
{@render children?.()}
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAnchorAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
size = "md",
isActive = false,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
size?: "sm" | "md";
isActive?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
),
"data-slot": "sidebar-menu-sub-button",
"data-sidebar": "menu-sub-button",
"data-size": size,
"data-active": isActive,
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a bind:this={ref} {...mergedProps}>
{@render children?.()}
</a>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
class={cn("group/menu-sub-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>

View File

@@ -0,0 +1,25 @@
<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<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
class={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-s px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...restProps}
>
{@render children?.()}
</ul>

View File

@@ -0,0 +1,21 @@
<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<HTMLUListElement>, HTMLUListElement> = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu"
data-sidebar="menu"
class={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...restProps}
>
{@render children?.()}
</ul>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from "./constants.js";
import { setSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
open = $bindable(true),
onOpenChange = () => {},
class: className,
style,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
} = $props();
const sidebar = setSidebar({
open: () => open,
setOpen: (value: boolean) => {
open = value;
onOpenChange(value);
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
});
</script>
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
<Tooltip.Provider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
</Tooltip.Provider>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onclick={sidebar.toggle}
title="Toggle Sidebar"
class={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:start-[calc(1/2*100%-1px)] after:w-[2px] group-data-[side=left]:-end-4 group-data-[side=right]:start-0 sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:start-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-end-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-start-2",
className
)}
{...restProps}
>
{@render children?.()}
</button>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="sidebar-separator"
data-sidebar="separator"
class={cn("bg-sidebar-border", className)}
{...restProps}
/>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import PanelLeftIcon from "@lucide/svelte/icons/panel-left";
import type { ComponentProps } from "svelte";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
onclick,
...restProps
}: ComponentProps<typeof Button> & {
onclick?: (e: MouseEvent) => void;
} = $props();
const sidebar = useSidebar();
</script>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
class={cn("size-7", className)}
type="button"
onclick={(e) => {
onclick?.(e);
sidebar.toggle();
}}
{...restProps}
>
<PanelLeftIcon />
<span class="sr-only">Toggle Sidebar</span>
</Button>

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import * as Sheet from "$lib/components/ui/sheet/index.js";
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { SIDEBAR_WIDTH_MOBILE } from "./constants.js";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
} = $props();
const sidebar = useSidebar();
</script>
{#if collapsible === "none"}
<div
class={cn(
"bg-sidebar text-sidebar-foreground w-(--sidebar-width) flex h-full flex-col",
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
{:else if sidebar.isMobile}
<Sheet.Root
bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)}
{...restProps}
>
<Sheet.Content
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
class="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
</Sheet.Header>
<div class="flex h-full w-full flex-col">
{@render children?.()}
</div>
</Sheet.Content>
</Sheet.Root>
{:else}
<div
bind:this={ref}
class="text-sidebar-foreground group peer hidden md:block"
data-state={sidebar.state}
data-collapsible={sidebar.state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
data-slot="sidebar-gap"
class={cn(
"w-(--sidebar-width) relative bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
></div>
<div
data-slot="sidebar-container"
class={cn(
"w-(--sidebar-width) fixed inset-y-0 z-10 hidden h-svh transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "start-0 group-data-[collapsible=offcanvas]:start-[calc(var(--sidebar-width)*-1)]"
: "end-0 group-data-[collapsible=offcanvas]:end-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-e group-data-[side=right]:border-s",
className
)}
{...restProps}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{@render children?.()}
</div>
</div>
</div>
{/if}

View File

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

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
data-slot="skeleton"
class={cn("bg-accent animate-pulse rounded-md", className)}
{...restProps}
></div>

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import type { Specification } from '$lib'
import * as Card from '../card'
import { m } from '$lib/paraglide/messages'
import * as Tabs from '../tabs'
import SpecTable from './SpecTable.svelte'
import { T } from '@tolgee/svelte'
const { specifications }: { specifications: Specification[] } = $props()
@@ -12,13 +12,15 @@
<Card.Root>
<Card.Header>
{m.specification_title()}
<T keyName="specs.title" />
</Card.Header>
<Card.Content>
<Tabs.Root bind:value={selectedSpec}>
<Tabs.List class="w-full">
{#each specifications as spec (spec.title)}
<Tabs.Trigger value={spec.title}>{spec.title}</Tabs.Trigger>
<Tabs.Trigger value={spec.title}>
<T keyName={`specs.tabs.${spec.title}`} />
</Tabs.Trigger>
{/each}
</Tabs.List>
{#each specifications as spec (spec.title)}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { SpecificationAttribute } from '$lib'
import { T } from '@tolgee/svelte'
const { attribute }: { attribute: SpecificationAttribute } = $props()
</script>
@@ -7,6 +8,8 @@
<div
class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700"
>
<dt class="text-gray-600 dark:text-gray-400">{attribute.key}</dt>
<dt class="text-gray-600 dark:text-gray-400">
<T keyName={`specs.${attribute.key}`} />
</dt>
<dd class="dark:text-gray-200">{attribute.value}</dd>
</div>

View File

@@ -0,0 +1,21 @@
import { Tooltip as TooltipPrimitive } from "bits-ui";
import Trigger from "./tooltip-trigger.svelte";
import Content from "./tooltip-content.svelte";
const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider;
const Portal = TooltipPrimitive.Portal;
export {
Root,
Trigger,
Content,
Provider,
Portal,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal,
};

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
children,
arrowClasses,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
} = $props();
</script>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs",
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>

View File

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

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { BarcodeIcon } from '@lucide/svelte'
</script>
<div class="sticky top-0 w-full h-20 bg-blue-950 flex justify-center z-50">
<div class="max-w-6xl w-full flex flex-row items-center gap-4">
<BarcodeIcon size="48" class="text-white" />
<span class="text-white text-3xl">ReBuyWise</span>
</div>
</div>

View File

@@ -0,0 +1,3 @@
import TopBar from './TopBar.svelte'
export { TopBar }

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { CapacityProductVariation } from '$lib'
import * as m from '$lib/paraglide/messages'
import { T } from '@tolgee/svelte'
import { ToggleButton } from '../button'
import { Label } from '../label'
@@ -10,7 +10,9 @@
</script>
<div class="mb-6">
<Label class="mb-3">{m['capacity_variation.title']()}</Label>
<Label class="mb-3">
<T keyName="capacity.title" />
</Label>
<div class="flex flex-row gap-2">
{#each variations as variation (variation.numericValue)}
<ToggleButton

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { ColorProductVariation } from '$lib'
import * as m from '$lib/paraglide/messages'
import { T } from '@tolgee/svelte'
import { Label } from '../label'
import ColorButton from './color/ColorButton.svelte'
@@ -10,7 +10,9 @@
</script>
<div class="mb-6">
<Label class="mb-3">{m['color_variation.title']()}</Label>
<Label class="mb-3">
<T keyName="color.title" />
</Label>
<div class="flex flex-row gap-2">
{#each variations as variation (variation.hex)}
<ColorButton

View File

@@ -1,33 +1,21 @@
<script lang="ts">
import type { ConditionEnum, ConditionProductVariation } from '$lib'
import type { ConditionProductVariation } from '$lib'
import * as RadioGroup from '$lib/components/ui/radio-group'
import { Label } from '$lib/components/ui/label'
import * as Card from '../card'
import { cn } from '$lib/utils'
import Badge from '../badge/badge.svelte'
import { m } from '$lib/paraglide/messages'
import { T } from '@tolgee/svelte'
const { variations }: { variations: ConditionProductVariation[] } = $props()
const titleMap: Record<ConditionEnum, () => string> = {
excellent: m.condition_variation_excellent,
very_good: m.condition_variation_very_good,
good: m.condition_variation_good,
fair: m.condition_variation_fair,
}
const descriptionMap: Record<ConditionEnum, () => string> = {
excellent: m.condition_variation_description_excellent,
very_good: m.condition_variation_description_very_good,
good: m.condition_variation_description_good,
fair: m.condition_variation_description_fair,
}
let selectedCondition = $state('')
</script>
<div class="mb-6">
<Label class="mb-3">{m.condition_title()}</Label>
<Label class="mb-3">
<T keyName="condition.title" />
</Label>
<RadioGroup.Root bind:value={selectedCondition}>
{#each variations as variation, i (variation.name)}
@@ -44,12 +32,12 @@
<div>
<div class="flex flex-row gap-4">
<Label class="mb-1" for={`r${i}`}
>{titleMap[variation.name]()}</Label
><T keyName={`condition.option.${variation.name}.title`} /></Label
>
<Badge variant="secondary">{variation.numericValue}%</Badge>
</div>
<div class="text-sm text-gray-600 dark:text-gray-400">
{descriptionMap[variation.name]()}
<T keyName={`condition.option.${variation.name}.description`} />
</div>
</div>
</Card.Root>

View File

@@ -0,0 +1,9 @@
import { MediaQuery } from "svelte/reactivity";
const DEFAULT_MOBILE_BREAKPOINT = 768;
export class IsMobile extends MediaQuery {
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
super(`max-width: ${breakpoint - 1}px`);
}
}

View File

@@ -1,9 +1,27 @@
<script lang="ts">
import '../app.css'
import favicon from '$lib/assets/favicon.svg'
import * as r from '$lib/paraglide/runtime'
import {
TolgeeProvider,
Tolgee,
DevTools,
FormatSimple,
} from '@tolgee/svelte'
import { TopBar } from '$lib/components/ui/topBar'
r.setLocale('de-de')
const tolgee = Tolgee()
.use(DevTools())
.use(FormatSimple())
.init({
language: 'de',
// for development
apiUrl: import.meta.env.VITE_TOLGEE_API_URL,
apiKey: import.meta.env.VITE_TOLGEE_API_KEY,
// for production
staticData: {},
})
let { children } = $props()
</script>
@@ -12,8 +30,15 @@
<link rel="icon" href={favicon} />
</svelte:head>
<div class="flex justify-center items-center">
<div class="max-w-full lg:max-w-5xl pt-4">
{@render children?.()}
<TolgeeProvider {tolgee}>
<TopBar />
<div class="box-content">
<div
class="bg-gray-50 dark:bg-gray-950 transition-colors flex justify-center min-h-screen"
>
<div class="max-w-full px-4 lg:max-w-6xl box-content">
{@render children?.()}
</div>
</div>
</div>
</div>
</TolgeeProvider>

View File

@@ -13,6 +13,9 @@
import CapacityVarations from '$lib/components/ui/variations/CapacityVarations.svelte'
import ConditionVariations from '$lib/components/ui/variations/ConditionVariations.svelte'
import { SpecCard } from '$lib/components/ui/specCard'
import ImageCarousel from '$lib/components/ui/image-carousel/ImageCarousel.svelte'
import type { ImageCarouselItem } from '$lib/components/ui/image-carousel'
import { ShopList, type TShopListItem } from '$lib/components/ui/shopList'
async function crawlClevertronik() {
const response = await fetch('/crawl', { method: 'POST' })
@@ -30,11 +33,28 @@
description:
'Das iPhone 15 bietet modernste Technologie zu einem nachhaltigen Preis. Professionell aufbereitet und vollständig getestet.',
images: [
'https://images.unsplash.com/photo-1702184117235-56002cb13663?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMGJsYWNrfGVufDF8fHx8MTc2MzI5NDI5OHww&ixlib=rb-4.1.0&q=80&w=1080',
'https://images.unsplash.com/photo-1710023038502-ba80a70a9f53?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMHByb3xlbnwxfHx8fDE3NjMzMjEyMTF8MA&ixlib=rb-4.1.0&q=80&w=1080',
'https://images.unsplash.com/photo-1761907174062-c8baf8b7edb3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbWFydHBob25lJTIwbW9kZXJufGVufDF8fHx8MTc2MzM2OTEyOXww&ixlib=rb-4.1.0&q=80&w=1080',
'https://images.unsplash.com/photo-1567985944845-cb4d1e598822?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjBjYW1lcmF8ZW58MXx8fHwxNzYzMzkxOTUyfDA&ixlib=rb-4.1.0&q=80&w=1080',
],
{
image:
'https://images.unsplash.com/photo-1702184117235-56002cb13663?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMGJsYWNrfGVufDF8fHx8MTc2MzI5NDI5OHww&ixlib=rb-4.1.0&q=80&w=1080',
alt: 'iphone',
},
{
image:
'https://images.unsplash.com/photo-1710023038502-ba80a70a9f53?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMHByb3xlbnwxfHx8fDE3NjMzMjEyMTF8MA&ixlib=rb-4.1.0&q=80&w=1080',
alt: 'iphone',
},
{
image:
'https://images.unsplash.com/photo-1761907174062-c8baf8b7edb3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbWFydHBob25lJTIwbW9kZXJufGVufDF8fHx8MTc2MzM2OTEyOXww&ixlib=rb-4.1.0&q=80&w=1080',
alt: 'iphone',
},
{
image:
'https://images.unsplash.com/photo-1567985944845-cb4d1e598822?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjBjYW1lcmF8ZW58MXx8fHwxNzYzMzkxOTUyfDA&ixlib=rb-4.1.0&q=80&w=1080',
alt: 'iphone',
},
] satisfies ImageCarouselItem[],
badges: [
{
text: 'Refurbished',
@@ -159,23 +179,98 @@
},
] satisfies ConditionProductVariation[],
},
shops: [
{
name: 'Refurbed',
badges: [
{
name: 'top_shop',
variant: 'default',
},
{
name: 'best_price',
icon: 'TrendingDown',
variant: 'default',
},
],
currency: '€',
price: 585,
rating: {
score: 4.8,
amount: 1247,
},
shipping: {
shippingCost: 0,
shippingTime: '1-2',
},
warrantyInMonths: 24,
linkUrl: 'https://refurbed.com',
},
{
name: 'GreenPhone',
badges: [],
currency: '€',
price: 600,
rating: {
score: 4.6,
amount: 856,
},
shipping: {
shippingCost: 4.99,
shippingTime: '2-3',
},
warrantyInMonths: 12,
linkUrl: 'https://google.com',
},
{
name: 'CleverTronik',
badges: [
{
name: 'verified',
variant: 'secondary',
},
],
currency: '€',
price: 610,
rating: {
score: 4.7,
amount: 2103,
},
shipping: {
shippingCost: 0,
shippingTime: '1-3',
},
warrantyInMonths: 18,
linkUrl: 'https://clevertronik.de',
},
{
name: 'Swappie',
badges: [
{
name: 'goofie',
variant: 'secondary',
},
],
currency: '€',
price: 710,
rating: {
score: 4.5,
amount: 634,
},
shipping: {
shippingCost: 5.99,
shippingTime: '3-5',
},
warrantyInMonths: 12,
linkUrl: 'https://clevertronik.de',
},
] satisfies TShopListItem[],
}
</script>
<div>
<div class="flex lg:flex-row flex-col gap-6">
<Carousel.Root class="max-w-xs mx-12">
<Carousel.Previous />
<Carousel.Content>
{#each product.images as image}
<Carousel.Item>
<img class="p-1 rounded-lg" src={image} alt="" />
</Carousel.Item>
{/each}
</Carousel.Content>
<Carousel.Next />
</Carousel.Root>
<div class="grid gap-8">
<div class="grid md:grid-cols-2 gap-8">
<ImageCarousel buttonPosition="contained" items={product.images} preview />
<div>
<div class="flex flex-col gap-y-1">
<div class="flex flex-row gap-2">
@@ -192,8 +287,13 @@
<CapacityVarations variations={product.variations.capacity} />
<ConditionVariations variations={product.variations.condition} />
</div>
</div>
</div>
<div class="flex gap-8 md:flex-row flex-col">
<div class="md:basis-2/3"><ShopList items={product.shops} /></div>
<div class="md:basis-1/3">
<SpecCard specifications={product.specifications} />
</div>
</div>
<button onclick={crawlClevertronik}>lets do it</button>
</div>

View File

@@ -1,2 +0,0 @@
<a href="/demo/paraglide">paraglide</a>
<a href="/demo/lucia">lucia</a>

View File

@@ -1,31 +0,0 @@
import * as auth from '$lib/server/auth'
import { fail, redirect } from '@sveltejs/kit'
import { getRequestEvent } from '$app/server'
import type { Actions, PageServerLoad } from './$types'
export const load: PageServerLoad = async () => {
const user = requireLogin()
return { user }
}
export const actions: Actions = {
logout: async (event) => {
if (!event.locals.session) {
return fail(401)
}
await auth.invalidateSession(event.locals.session.id)
auth.deleteSessionTokenCookie(event)
return redirect(302, '/demo/lucia/login')
}
}
function requireLogin() {
const { locals } = getRequestEvent()
if (!locals.user) {
return redirect(302, '/demo/lucia/login')
}
return locals.user
}

View File

@@ -1,12 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms'
import type { PageServerData } from './$types'
let { data }: { data: PageServerData } = $props()
</script>
<h1>Hi, {data.user.username}!</h1>
<p>Your user ID is {data.user.id}.</p>
<form method="post" action="?/logout" use:enhance>
<button>Sign out</button>
</form>

View File

@@ -1,107 +0,0 @@
import { hash, verify } from '@node-rs/argon2'
import { encodeBase32LowerCase } from '@oslojs/encoding'
import { fail, redirect } from '@sveltejs/kit'
import { eq } from 'drizzle-orm'
import * as auth from '$lib/server/auth'
import { db } from '$lib/server/db'
import { user } from '$lib/server/db/user'
import type { Actions, PageServerLoad } from './$types'
export const load: PageServerLoad = async (event) => {
if (event.locals.user) {
return redirect(302, '/demo/lucia')
}
return {}
}
export const actions: Actions = {
login: async (event) => {
const formData = await event.request.formData()
const username = formData.get('username')
const password = formData.get('password')
if (!validateUsername(username)) {
return fail(400, {
message: 'Invalid username (min 3, max 31 characters, alphanumeric only)'
})
}
if (!validatePassword(password)) {
return fail(400, { message: 'Invalid password (min 6, max 255 characters)' })
}
const results = await db.select().from(user).where(eq(user.username, username))
const existingUser = results.at(0)
if (!existingUser) {
return fail(400, { message: 'Incorrect username or password' })
}
const validPassword = await verify(existingUser.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
})
if (!validPassword) {
return fail(400, { message: 'Incorrect username or password' })
}
const sessionToken = auth.generateSessionToken()
const session = await auth.createSession(sessionToken, existingUser.id)
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt)
return redirect(302, '/demo/lucia')
},
register: async (event) => {
const formData = await event.request.formData()
const username = formData.get('username')
const password = formData.get('password')
if (!validateUsername(username)) {
return fail(400, { message: 'Invalid username' })
}
if (!validatePassword(password)) {
return fail(400, { message: 'Invalid password' })
}
const userId = generateUserId()
const passwordHash = await hash(password, {
// recommended minimum parameters
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
})
try {
await db.insert(user).values({ id: userId, username, passwordHash })
const sessionToken = auth.generateSessionToken()
const session = await auth.createSession(sessionToken, userId)
auth.setSessionTokenCookie(event, sessionToken, session.expiresAt)
} catch {
return fail(500, { message: 'An error has occurred' })
}
return redirect(302, '/demo/lucia')
}
}
function generateUserId() {
// ID with 120 bits of entropy, or about the same as UUID v4.
const bytes = crypto.getRandomValues(new Uint8Array(15))
const id = encodeBase32LowerCase(bytes)
return id
}
function validateUsername(username: unknown): username is string {
return (
typeof username === 'string' &&
username.length >= 3 &&
username.length <= 31 &&
/^[a-z0-9_-]+$/.test(username)
)
}
function validatePassword(password: unknown): password is string {
return typeof password === 'string' && password.length >= 6 && password.length <= 255
}

View File

@@ -1,34 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms'
import type { ActionData } from './$types'
let { form }: { form: ActionData } = $props()
</script>
<h1>Login/Register</h1>
<form method="post" action="?/login" use:enhance>
<label>
Username
<input
name="username"
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
<label>
Password
<input
type="password"
name="password"
class="mt-1 rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
</label>
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
>Login</button
>
<button
formaction="?/register"
class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
>Register</button
>
</form>
<p style="color: red">{form?.message ?? ''}</p>

View File

@@ -1,17 +0,0 @@
<script lang="ts">
import { setLocale } from '$lib/paraglide/runtime'
import { m } from '$lib/paraglide/messages.js'
</script>
<h1>{m.hello_world({ name: 'SvelteKit User' })}</h1>
<div>
<button onclick={() => setLocale('en')}>en</button>
<button onclick={() => setLocale('es')}>es</button>
<button onclick={() => setLocale('de-de')}>de-de</button>
</div>
<p>
If you use VSCode, install the <a
href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension"
target="_blank">Sherlock i18n extension</a
> for a better i18n experience.
</p>

View File

@@ -1,23 +1,14 @@
import { paraglideVitePlugin } from "@inlang/paraglide-js";
import devtoolsJson from "vite-plugin-devtools-json";
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import path from "path";
import devtoolsJson from 'vite-plugin-devtools-json'
import tailwindcss from '@tailwindcss/vite'
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
import path from 'path'
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
devtoolsJson(),
paraglideVitePlugin({
project: "./project.inlang",
outdir: "./src/lib/paraglide",
}),
],
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
resolve: {
alias: {
$lib: path.resolve("./src/lib"),
$lib: path.resolve('./src/lib'),
},
},
});
})