Add Mode6Test: copy of Mode5Test running on port 3000
Build & Deploy / Test & Lint (push) Has been cancelled
Build & Deploy / Security Scan (push) Has been cancelled
Code Quality / Code Quality Checks (push) Has been cancelled
Code Quality / Dependency Vulnerabilities (push) Has been cancelled
Code Quality / Performance Testing (push) Has been cancelled
Build & Deploy / Build Docker Images (push) Has been cancelled
Build & Deploy / Deploy to Kubernetes (push) Has been cancelled

This commit is contained in:
2026-01-20 13:43:29 +00:00
parent 61dbbc457a
commit 42ff3e8f33
133 changed files with 353720 additions and 2 deletions
+396
View File
@@ -0,0 +1,396 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "job-info-frontend",
"dependencies": {
"idb": "^8.0.0",
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/node": "^20.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"svelte": "^4.2.0",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^5.0.0",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@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=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@3.1.2", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.10", "svelte-hmr": "^0.16.0", "vitefu": "^0.2.5" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@2.1.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="],
"@types/pug": ["@types/pug@2.0.10", "", {}, "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"code-red": ["code-red@1.0.4", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="],
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mdn-data": ["mdn-data@2.0.30", "", {}, "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"periscopic": ["periscopic@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w=="],
"rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"sander": ["sander@0.5.1", "", { "dependencies": { "es6-promise": "^3.1.2", "graceful-fs": "^4.1.3", "mkdirp": "^0.5.1", "rimraf": "^2.5.2" } }, "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA=="],
"sorcery": ["sorcery@0.11.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.14", "buffer-crc32": "^1.0.0", "minimist": "^1.2.0", "sander": "^0.5.0" }, "bin": { "sorcery": "bin/sorcery" } }, "sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@4.2.20", "", { "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", "is-reference": "^3.0.1", "locate-character": "^3.0.0", "magic-string": "^0.30.4", "periscopic": "^3.1.0" } }, "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q=="],
"svelte-check": ["svelte-check@3.8.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "chokidar": "^3.4.1", "picocolors": "^1.0.0", "sade": "^1.7.4", "svelte-preprocess": "^5.1.3", "typescript": "^5.0.3" }, "peerDependencies": { "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q=="],
"svelte-hmr": ["svelte-hmr@0.16.0", "", { "peerDependencies": { "svelte": "^3.19.0 || ^4.0.0" } }, "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA=="],
"svelte-preprocess": ["svelte-preprocess@5.1.4", "", { "dependencies": { "@types/pug": "^2.0.6", "detect-indent": "^6.1.0", "magic-string": "^0.30.5", "sorcery": "^0.11.0", "strip-indent": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.10.2", "coffeescript": "^2.5.1", "less": "^3.11.3 || ^4.0.0", "postcss": "^7 || ^8", "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "pug": "^3.0.0", "sass": "^1.26.8", "stylus": "^0.55.0", "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["@babel/core", "coffeescript", "less", "postcss", "postcss-load-config", "pug", "sass", "stylus", "sugarss", "typescript"] }, "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vitefu": ["vitefu@0.2.5", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["vite"] }, "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
}
}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Job Info - Job Search Application</title>
</head>
<body>
<!-- Root element for Svelte app -->
<div id="app"></div>
<!-- Main app script -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "job-info-frontend",
"version": "1.0.0",
"description": "Job Info Frontend - SvelteKit + Microsoft OAuth",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/node": "^20.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"svelte": "^4.2.0",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.0",
"vite": "^5.0.0"
},
"dependencies": {
"idb": "^8.0.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+208
View File
@@ -0,0 +1,208 @@
/**
* SERVICE WORKER: Token Refresh
*
* PURPOSE: Handle automatic token refresh in background
* FEATURES:
* - Periodically check if token needs refresh
* - Refresh token before expiry
* - Intercept requests to add auth headers
* - Handle token expiry gracefully
*
* INSTALLED: Before app loads
* ACTIVATED: On page load
* RUNS: Periodically in background
*/
// Token check interval (every 60 seconds)
const TOKEN_CHECK_INTERVAL = 60 * 1000;
// Token refresh buffer (refresh 5 minutes before expiry)
const REFRESH_BUFFER = 5 * 60 * 1000;
// ============================================================================
// INSTALLATION
// ============================================================================
/**
* EVENT: install
*
* Called when service worker is registered
* Used to cache critical assets
*/
self.addEventListener('install', (event: ExtendEvent) => {
console.log('[ServiceWorker] Installing...');
event.waitUntil(
Promise.resolve().then(() => {
console.log('[ServiceWorker] Installed');
// Skip waiting - activate immediately
self.skipWaiting();
})
);
});
// ============================================================================
// ACTIVATION
// ============================================================================
/**
* EVENT: activate
*
* Called when service worker takes control
* Used to clean up old caches
*/
self.addEventListener('activate', (event: ExtendEvent) => {
console.log('[ServiceWorker] Activating...');
event.waitUntil(
Promise.resolve().then(() => {
console.log('[ServiceWorker] Activated');
// Take control of all pages
return (self as any).clients.claim();
})
);
});
// ============================================================================
// TOKEN REFRESH LOOP
// ============================================================================
/**
* Periodically check and refresh token
*/
async function startTokenRefreshLoop() {
console.log('[ServiceWorker] Starting token refresh loop');
setInterval(async () => {
try {
// Get token expiry from session storage
// (accessed via message from client since SW can't access sessionStorage directly)
// This is handled via postMessage from client
console.log('[ServiceWorker] Checking token...');
// Token refresh is initiated from client via postMessage
// because ServiceWorker can't access sessionStorage directly
} catch (error) {
console.error('[ServiceWorker] Token check error:', error);
}
}, TOKEN_CHECK_INTERVAL);
}
// Start the loop when activated
startTokenRefreshLoop();
// ============================================================================
// MESSAGE HANDLING
// ============================================================================
/**
* EVENT: message
*
* Called when client (page) sends message to ServiceWorker
* Used to:
* - Check if token needs refresh
* - Perform token refresh
* - Get current token status
*/
self.addEventListener('message', async (event: ExtendMessageEvent) => {
const { type, payload } = event.data;
console.log('[ServiceWorker] Received message:', type);
try {
if (type === 'REFRESH_TOKEN') {
// Client asking us to refresh token
const { refreshToken, apiUrl } = payload;
console.log('[ServiceWorker] Refreshing token...');
// Call backend to refresh token
const response = await fetch(`${apiUrl}/api/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
const data = await response.json() as any;
console.log('[ServiceWorker] Token refreshed successfully');
// Send new token back to client
event.ports[0].postMessage({
success: true,
accessToken: data.accessToken,
expiresIn: data.expiresIn,
});
} else if (type === 'CHECK_TOKEN') {
// Client asking if token needs refresh
const { tokenExpiry } = payload;
const now = Date.now();
const timeUntilExpiry = tokenExpiry - now;
const needsRefresh = timeUntilExpiry < REFRESH_BUFFER;
console.log(`[ServiceWorker] Token expires in ${Math.round(timeUntilExpiry / 1000)}s`);
event.ports[0].postMessage({
needsRefresh,
timeUntilExpiry,
});
} else if (type === 'GET_STATUS') {
// Client asking for ServiceWorker status
event.ports[0].postMessage({
active: true,
version: '1.0.0',
});
}
} catch (error) {
console.error('[ServiceWorker] Message handling error:', error);
event.ports[0].postMessage({
error: (error as Error).message,
});
}
});
// ============================================================================
// REQUEST INTERCEPTION (future use)
// ============================================================================
/**
* EVENT: fetch
*
* Called for every network request
* Could be used to:
* - Intercept API requests
* - Add auth headers
* - Retry on auth failure
*
* Disabled for now since we handle auth in the app
* Enables when needed for request interception
*/
self.addEventListener('fetch', (event: ExtendFetchEvent) => {
// Currently, just pass through
// In future, could intercept API calls to add auth headers
// or retry on 401 by refreshing token
});
// ============================================================================
// TYPES
// ============================================================================
interface ExtendEvent extends Event {
waitUntil(promise: Promise<any>): void;
}
interface ExtendMessageEvent extends ExtendEvent {
data: {
type: string;
payload?: any;
};
ports: MessagePort[];
}
interface ExtendFetchEvent extends ExtendEvent {
request: Request;
respondWith(response: Response | Promise<Response>): void;
}
+166
View File
@@ -0,0 +1,166 @@
<script lang="ts">
/**
* COMPONENT: App
*
* PURPOSE: Root application component
* FEATURES:
* - Simple page routing (hash-based)
* - Auth guard
* - Initialize stores on mount
*/
import { onMount } from 'svelte';
import { isAuthenticated, restoreAuthFromStorage } from './stores/auth';
import { loadJobs } from './stores/jobs';
import { isLoading } from './stores/ui';
import { registerServiceWorker } from './utils/service-worker-manager';
import NotificationContainer from './components/NotificationContainer.svelte';
import HomePage from './pages/Home.svelte';
import SignInPage from './pages/SignIn.svelte';
import CallbackPage from './pages/Callback.svelte';
let currentPage = 'signin';
let authChecked = false;
let isInitializing = true;
/**
* Initialize app on mount
*/
onMount(async () => {
try {
console.log('[App] Initializing...');
// Register Service Worker for background token refresh
await registerServiceWorker();
// Restore auth from sessionStorage
restoreAuthFromStorage();
// Load initial data if authenticated
if ($isAuthenticated) {
console.log('[App] Authenticated, loading initial data');
try {
await loadJobs();
} catch (error) {
console.error('[App] Failed to load initial jobs:', error);
}
}
authChecked = true;
isInitializing = false;
updatePageFromHash();
} catch (error) {
console.error('[App] Initialization error:', error);
authChecked = true;
isInitializing = false;
}
});
/**
* Determine current page from URL hash
*/
function updatePageFromHash() {
const hash = window.location.hash.slice(1) || '/';
if (hash.startsWith('callback')) {
currentPage = 'callback';
} else if (hash === '/signin') {
currentPage = 'signin';
} else if (hash === '/' || hash === '') {
// Require auth for home page
if ($isAuthenticated) {
currentPage = 'home';
} else {
currentPage = 'signin';
}
} else {
currentPage = 'signin';
}
}
/**
* Watch for hash changes
*/
function handleHashChange() {
updatePageFromHash();
}
/**
* Watch authentication state changes
*/
function handleAuthChange() {
// If logged out while on home page, go to signin
if (!$isAuthenticated && currentPage === 'home') {
window.location.hash = '/signin';
currentPage = 'signin';
}
}
$: $isAuthenticated, handleAuthChange();
</script>
<!-- Set up hash-based routing -->
<svelte:window on:hashchange={handleHashChange} />
<!-- Root app container -->
<div class="min-h-screen bg-gray-50 flex flex-col">
<!-- Main content -->
<main class="flex-1">
{#if isInitializing}
<!-- Loading screen during initialization -->
<div class="flex items-center justify-center h-screen">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
<p class="mt-4 text-gray-600">Loading...</p>
</div>
</div>
{:else if authChecked}
<!-- Render current page -->
{#if currentPage === 'home'}
<HomePage />
{:else if currentPage === 'signin'}
<SignInPage />
{:else if currentPage === 'callback'}
<CallbackPage />
{/if}
{/if}
</main>
<!-- Notifications -->
<NotificationContainer />
<!-- Global loading indicator -->
{#if $isLoading}
<div class="fixed top-0 left-0 right-0 h-1 bg-blue-600 animate-pulse" />
{/if}
</div>
<style global>
/* Global animations */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #2563eb;
--color-secondary: #64748b;
--color-error: #dc2626;
--color-success: #16a34a;
}
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import '../app.css';
</script>
<svelte:head>
<title>Job Info</title>
</svelte:head>
<slot />
<style global>
:global(body) {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, sans-serif;
}
</style>
@@ -0,0 +1,93 @@
<script lang="ts">
/**
* COMPONENT: ErrorBoundary
*
* PURPOSE: Catch and display errors gracefully
* FEATURES:
* - Error catching with fallback UI
* - Recovery suggestions
* - Error details in development
* - Automatic error logging
*/
import { error as logError } from '../services/logger';
export let error: Error | null = null;
export let title = 'Something went wrong';
export let showDetails = false;
function handleRetry() {
window.location.reload();
}
function handleGoHome() {
window.location.hash = '/';
}
$: if (error) {
logError('ErrorBoundary', 'Uncaught error', error, {
title,
});
}
</script>
<!-- Error Display -->
<div class="min-h-screen bg-gradient-to-br from-red-50 to-red-100 flex items-center justify-center px-4">
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<!-- Error Icon -->
<div class="text-center mb-6">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 mb-4">
<span class="text-3xl">⚠️</span>
</div>
<h1 class="text-2xl font-bold text-gray-900">{title}</h1>
<p class="text-gray-600 text-sm mt-2">
An unexpected error occurred. Please try again.
</p>
</div>
<!-- Error Details (Development) -->
{#if error && showDetails}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-xs font-mono text-red-800 break-words">
{error.message}
</p>
{#if error.stack}
<pre class="text-xs text-red-700 mt-2 overflow-auto max-h-40">
{error.stack}
</pre>
{/if}
</div>
{/if}
<!-- Recovery Suggestions -->
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-sm font-medium text-blue-900 mb-2">Try these steps:</p>
<ul class="text-sm text-blue-800 space-y-1">
<li>✓ Check your internet connection</li>
<li>✓ Clear browser cache and refresh</li>
<li>✓ Log out and log back in</li>
<li>✓ Contact support if problem persists</li>
</ul>
</div>
<!-- Action Buttons -->
<div class="flex gap-3">
<button
on:click={handleRetry}
class="flex-1 py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
>
Retry
</button>
<button
on:click={handleGoHome}
class="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 text-gray-900 font-medium rounded-lg transition"
>
Go Home
</button>
</div>
</div>
</div>
<style>
/* Using TailwindCSS */
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
/**
* COMPONENT: JobCard
*
* PURPOSE: Display individual job information
* FEATURES:
* - Show job title, company, description
* - Display date posted
* - Click to view details
* - Responsive design
*/
import type { Job } from '../utils/types';
export let job: Job;
export let onClick: (() => void) | undefined = undefined;
/**
* Format date for display
* Shows "X days ago" for recent jobs
* Shows full date for older jobs
*/
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const daysOld = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (daysOld === 0) return 'Today';
if (daysOld === 1) return 'Yesterday';
if (daysOld < 7) return `${daysOld} days ago`;
return date.toLocaleDateString();
}
/**
* Truncate description to preview length
*/
function truncateDescription(text: string | undefined, maxLength: number = 150): string {
if (!text) return '';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
}
</script>
<!-- Job Card -->
<div
class="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow cursor-pointer"
on:click={onClick}
role="button"
tabindex="0"
on:keydown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
e.preventDefault();
onClick();
}
}}
>
<!-- Header: Title and Company -->
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900">{job.title}</h3>
<p class="text-sm text-gray-600">{job.company}</p>
</div>
<!-- Date Badge -->
{#if job.datePosted}
<div class="text-xs text-gray-500 whitespace-nowrap">
{formatDate(job.datePosted)}
</div>
{/if}
</div>
<!-- Description Preview -->
{#if job.description}
<p class="mt-3 text-sm text-gray-700 line-clamp-3">
{truncateDescription(job.description, 150)}
</p>
{/if}
<!-- Metadata Footer -->
<div class="mt-4 flex items-center justify-between text-xs text-gray-500">
<!-- Category/Location -->
<div class="flex items-center gap-2">
{#if job.category}
<span class="px-2 py-1 bg-gray-100 rounded text-gray-700">
{job.category}
</span>
{/if}
</div>
<!-- Arrow indicator -->
<div class="text-gray-400"></div>
</div>
</div>
<style>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<script lang="ts">
/**
* COMPONENT: JobList
*
* PURPOSE: Display list of jobs with pagination
* FEATURES:
* - Show jobs in grid/list view
* - Pagination support
* - Empty state
* - Loading state
* - Click handler for job details
*/
import JobCard from './JobCard.svelte';
import type { Job } from '../utils/types';
export let jobs: Job[] = [];
export let loading: boolean = false;
export let onJobClick: (job: Job) => void = () => {};
// Pagination
const jobsPerPage = 12;
let currentPage = 1;
/**
* Get paginated jobs for current page
*/
function getPaginatedJobs(): Job[] {
const start = (currentPage - 1) * jobsPerPage;
const end = start + jobsPerPage;
return jobs.slice(start, end);
}
/**
* Get total number of pages
*/
function getTotalPages(): number {
return Math.ceil(jobs.length / jobsPerPage);
}
/**
* Go to next page
*/
function nextPage() {
if (currentPage < getTotalPages()) {
currentPage++;
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
/**
* Go to previous page
*/
function prevPage() {
if (currentPage > 1) {
currentPage--;
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
/**
* Reset pagination when jobs change
*/
$: if (jobs) {
currentPage = 1;
}
</script>
<!-- Job List Container -->
<div class="space-y-4">
<!-- Loading State -->
{#if loading}
<div class="flex items-center justify-center py-12">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
<p class="mt-2 text-gray-600">Loading jobs...</p>
</div>
</div>
{:else if jobs.length === 0}
<!-- Empty State -->
<div class="flex items-center justify-center py-12">
<div class="text-center">
<p class="text-gray-600">No jobs found</p>
<p class="text-sm text-gray-500 mt-1">Try adjusting your search or filters</p>
</div>
</div>
{:else}
<!-- Job Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{#each getPaginatedJobs() as job (job.id)}
<JobCard {job} onClick={() => onJobClick(job)} />
{/each}
</div>
<!-- Pagination -->
{#if getTotalPages() > 1}
<div class="flex items-center justify-between mt-8 pt-4 border-t border-gray-200">
<!-- Info -->
<div class="text-sm text-gray-600">
Showing {(currentPage - 1) * jobsPerPage + 1} - {Math.min(
currentPage * jobsPerPage,
jobs.length
)}
of {jobs.length} jobs
</div>
<!-- Buttons -->
<div class="flex gap-2">
<button
on:click={prevPage}
disabled={currentPage === 1}
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
← Previous
</button>
<!-- Page Numbers -->
<div class="flex items-center gap-1">
{#each Array.from({ length: getTotalPages() }, (_, i) => i + 1) as page}
{#if page === currentPage}
<div class="w-8 h-8 flex items-center justify-center rounded bg-blue-600 text-white text-sm font-medium">
{page}
</div>
{:else if page <= 2 || page >= getTotalPages() - 1 || Math.abs(page - currentPage) <= 1}
<button
on:click={() => (currentPage = page)}
class="w-8 h-8 flex items-center justify-center rounded hover:bg-gray-100 text-sm transition-colors"
>
{page}
</button>
{:else if page === 3 && currentPage > 4}
<span class="px-2 text-gray-500">...</span>
{/if}
{/each}
</div>
<button
on:click={nextPage}
disabled={currentPage === getTotalPages()}
class="px-4 py-2 text-sm font-medium rounded-lg border border-gray-300 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next →
</button>
</div>
</div>
{/if}
{/if}
</div>
<style>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
/**
* COMPONENT: Navigation
*
* PURPOSE: Main navigation header
* FEATURES:
* - Display user name
* - Sign out button
* - Logo/branding
* - Navigation links
*/
import { user, clearAuth } from '../stores/auth';
import { clearNotifications } from '../stores/ui';
import { clearJobs } from '../stores/jobs';
/**
* Handle sign out
*/
function handleSignOut() {
try {
// Clear auth state
clearAuth();
// Clear other stores
clearNotifications();
clearJobs();
// Redirect to signin using hash-based routing
window.location.hash = '/signin';
} catch (error) {
console.error('[Navigation] Sign out error:', error);
}
}
/**
* Get user initials for avatar
*/
function getInitials(displayName: string | undefined): string {
if (!displayName) return '?';
return displayName
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
</script>
<!-- Navigation Header -->
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Left: Logo/Title -->
<div class="flex items-center">
<h1 class="text-2xl font-bold text-blue-600">Job Info</h1>
</div>
<!-- Center: Navigation Links (placeholder for future) -->
<div class="hidden md:flex items-center space-x-8">
<a href="/" class="text-gray-600 hover:text-gray-900 transition-colors">
Home
</a>
<a href="/#" class="text-gray-600 hover:text-gray-900 transition-colors">
Saved Jobs
</a>
</div>
<!-- Right: User Menu -->
<div class="flex items-center gap-4">
<!-- User Avatar and Name -->
<div class="flex items-center gap-3">
<!-- Avatar -->
<div class="w-8 h-8 rounded-full bg-blue-600 text-white flex items-center justify-center text-sm font-medium">
{getInitials($user?.displayName)}
</div>
<!-- User Name (hidden on mobile) -->
<div class="hidden sm:block">
<p class="text-sm font-medium text-gray-900">{$user?.displayName || 'User'}</p>
<p class="text-xs text-gray-500">{$user?.mail}</p>
</div>
</div>
<!-- Sign Out Button -->
<button
on:click={handleSignOut}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
Sign Out
</button>
</div>
</div>
</div>
</nav>
<style>
/* No additional styles needed - using TailwindCSS */
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
/**
* COMPONENT: NotificationContainer
*
* PURPOSE: Display notifications/toasts
* FEATURES:
* - Auto-dismiss timer visualization
* - Color-coded by type
* - Dismissible with button
* - Smooth animations
*/
import { uiState, removeNotification } from '../stores/ui';
import type { Notification } from '../stores/ui';
// Icon mapping for notification types
const icons: Record<string, string> = {
success: '✓',
error: '✕',
info: '',
warning: '⚠',
};
// Color classes for notification types
const colorClasses: Record<string, string> = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
};
const iconColorClasses: Record<string, string> = {
success: 'bg-green-100 text-green-600',
error: 'bg-red-100 text-red-600',
info: 'bg-blue-100 text-blue-600',
warning: 'bg-yellow-100 text-yellow-600',
};
</script>
<!-- Notification Container -->
<div class="fixed bottom-4 right-4 space-y-2 z-50 max-w-sm">
{#each $uiState.notifications as notification (notification.id)}
<div
class="animate-slide-in flex items-start gap-3 p-4 border rounded-lg shadow-lg {colorClasses[notification.type]}"
role="alert"
aria-live="polite"
aria-atomic="true"
>
<!-- Icon -->
<div
class="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full font-bold text-sm {iconColorClasses[notification.type]}"
>
{icons[notification.type]}
</div>
<!-- Message -->
<div class="flex-1 text-sm font-medium">
{notification.message}
</div>
<!-- Close Button -->
{#if notification.dismissible}
<button
on:click={() => removeNotification(notification.id)}
class="flex-shrink-0 text-lg font-bold opacity-50 hover:opacity-100 transition-opacity"
aria-label="Dismiss notification"
>
×
</button>
{/if}
</div>
{/each}
</div>
<style>
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<script lang="ts">
/**
* COMPONENT: SearchBar
*
* PURPOSE: Search jobs with debounce
* FEATURES:
* - Debounced search (300ms)
* - Clear button
* - Icon/button styling
* - Responsive
*/
import { searchJobs } from '../stores/jobs';
export let onSearch: (results: any[]) => void;
let searchInput = '';
let debounceTimer: NodeJS.Timeout | null = null;
/**
* Handle search input with debounce
* Prevents excessive searching while typing
*/
function handleSearch() {
// Clear previous timer
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Set new timer for 300ms debounce
debounceTimer = setTimeout(() => {
if (searchInput.trim()) {
const results = searchJobs(searchInput);
onSearch(results);
} else {
// Empty search - show all jobs
const results = searchJobs('');
onSearch(results);
}
}, 300);
}
/**
* Clear search
*/
function clearSearch() {
searchInput = '';
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Show all jobs
const results = searchJobs('');
onSearch(results);
}
</script>
<!-- Search Bar -->
<div class="relative">
<div class="relative flex items-center">
<!-- Search Icon -->
<div class="absolute left-3 text-gray-400 pointer-events-none">
🔍
</div>
<!-- Input Field -->
<input
type="text"
placeholder="Search jobs by title, company..."
bind:value={searchInput}
on:input={handleSearch}
class="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
aria-label="Search jobs"
/>
<!-- Clear Button -->
{#if searchInput}
<button
on:click={clearSearch}
class="absolute right-3 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Clear search"
>
</button>
{/if}
</div>
</div>
<style>
/* No additional styles needed - using TailwindCSS */
</style>
+365
View File
@@ -0,0 +1,365 @@
/**
* SERVICE: IndexedDB Database Layer
*
* PURPOSE: Handle all device storage operations
* DEPENDENCIES: idb library, types.ts
* PERSISTENCE: Data persisted across browser sessions
*
* DATABASE: jobinfo-app
* STORES:
* - jobs: { id (key), title, company, description, ... }
* - fileCache: { folderId (key), files[], timestamp }
* - userProfile: { "current" (key), user data }
*
* NOTES:
* - IndexedDB is asynchronous, all functions return Promises
* - Data is preserved until explicitly cleared (logout)
* - No size limit issues (50MB+ available)
*/
import { openDB, DBSchema, IDBPDatabase } from 'idb';
import type { Job, MicrosoftFile, User } from '../utils/types';
import { STORAGE_CONFIG } from '../utils/constants';
// ============================================================================
// DATABASE SCHEMA
// ============================================================================
/**
* Define IndexedDB schema
* Types for each store and their structure
*/
interface JobInfoDB extends DBSchema {
jobs: {
key: string;
value: Job;
indexes: { 'by-company': string; 'by-date': string };
};
fileCache: {
key: string;
value: {
folderId: string;
files: MicrosoftFile[];
cachedAt: number;
};
};
userProfile: {
key: string;
value: {
id: string;
user: User;
timestamp: number;
};
};
}
let db: IDBPDatabase<JobInfoDB> | null = null;
/**
* FUNCTION: Initialize database
*
* Called on app startup
* Creates database and stores if they don't exist
* Sets up indexes for efficient querying
*/
export async function initDatabase(): Promise<void> {
if (db) return; // Already initialized
try {
db = await openDB<JobInfoDB>(STORAGE_CONFIG.DB_NAME, STORAGE_CONFIG.DB_VERSION, {
upgrade(db) {
// Create jobs store
if (!db.objectStoreNames.contains(STORAGE_CONFIG.STORES.JOBS)) {
const jobsStore = db.createObjectStore(STORAGE_CONFIG.STORES.JOBS, {
keyPath: 'id',
});
jobsStore.createIndex('by-company', 'company');
jobsStore.createIndex('by-date', 'postedDate');
}
// Create file cache store
if (!db.objectStoreNames.contains(STORAGE_CONFIG.STORES.FILE_CACHE)) {
db.createObjectStore(STORAGE_CONFIG.STORES.FILE_CACHE, {
keyPath: 'folderId',
});
}
// Create user profile store
if (!db.objectStoreNames.contains(STORAGE_CONFIG.STORES.USER_PROFILE)) {
db.createObjectStore(STORAGE_CONFIG.STORES.USER_PROFILE);
}
},
});
console.log('[DB] Database initialized successfully');
} catch (error) {
console.error('[DB] Failed to initialize database:', error);
throw error;
}
}
/**
* FUNCTION: Ensure database is ready
*
* Helper to ensure db is initialized before operations
* Throws if database not available
*/
async function ensureDB(): Promise<IDBPDatabase<JobInfoDB>> {
if (!db) {
await initDatabase();
}
if (!db) {
throw new Error('Database failed to initialize');
}
return db;
}
// ============================================================================
// JOBS OPERATIONS
// ============================================================================
/**
* FUNCTION: Save jobs to database
*
* Replaces entire jobs collection
* Called when jobs loaded from backend or API
*/
export async function saveJobs(jobs: Job[]): Promise<void> {
const database = await ensureDB();
const tx = database.transaction(STORAGE_CONFIG.STORES.JOBS, 'readwrite');
try {
// Clear existing jobs
await tx.store.clear();
// Add new jobs
for (const job of jobs) {
await tx.store.add(job);
}
await tx.done;
console.log(`[DB] Saved ${jobs.length} jobs to database`);
} catch (error) {
console.error('[DB] Failed to save jobs:', error);
throw error;
}
}
/**
* FUNCTION: Get all jobs from database
*
* Returns all cached jobs
* Used on app startup to populate initial state
*/
export async function getJobs(): Promise<Job[]> {
const database = await ensureDB();
try {
const jobs = await database.getAll(STORAGE_CONFIG.STORES.JOBS);
console.log(`[DB] Retrieved ${jobs.length} jobs from database`);
return jobs;
} catch (error) {
console.error('[DB] Failed to get jobs:', error);
return [];
}
}
/**
* FUNCTION: Get single job by ID
*/
export async function getJobById(jobId: string): Promise<Job | undefined> {
const database = await ensureDB();
try {
return await database.get(STORAGE_CONFIG.STORES.JOBS, jobId);
} catch (error) {
console.error('[DB] Failed to get job:', error);
return undefined;
}
}
/**
* FUNCTION: Search jobs by company
*
* Uses index for efficient searching
*/
export async function searchJobsByCompany(company: string): Promise<Job[]> {
const database = await ensureDB();
try {
const index = database
.transaction(STORAGE_CONFIG.STORES.JOBS)
.store.index('by-company');
return await index.getAll(company);
} catch (error) {
console.error('[DB] Failed to search jobs:', error);
return [];
}
}
// ============================================================================
// FILE CACHE OPERATIONS
// ============================================================================
/**
* FUNCTION: Save files to cache for folder
*
* Stores file list with timestamp
* Used to determine cache validity
*/
export async function saveFileCache(
folderId: string,
files: MicrosoftFile[]
): Promise<void> {
const database = await ensureDB();
try {
await database.put(STORAGE_CONFIG.STORES.FILE_CACHE, {
folderId,
files,
cachedAt: Date.now(),
});
console.log(`[DB] Cached ${files.length} files for folder ${folderId}`);
} catch (error) {
console.error('[DB] Failed to cache files:', error);
throw error;
}
}
/**
* FUNCTION: Get cached files for folder
*
* Returns cached files if cache is fresh (< 1 hour old)
* Returns null if cache expired or not found
*/
export async function getFileCache(folderId: string): Promise<MicrosoftFile[] | null> {
const database = await ensureDB();
try {
const cache = await database.get(STORAGE_CONFIG.STORES.FILE_CACHE, folderId);
if (!cache) {
return null; // No cache
}
// Check if cache is expired
const age = Date.now() - cache.cachedAt;
if (age > STORAGE_CONFIG.CACHE_TTL) {
console.log(`[DB] File cache expired for folder ${folderId}`);
return null; // Cache expired
}
console.log(`[DB] Using cached files for folder ${folderId}`);
return cache.files;
} catch (error) {
console.error('[DB] Failed to get file cache:', error);
return null;
}
}
/**
* FUNCTION: Clear file cache for folder
*/
export async function clearFileCache(folderId: string): Promise<void> {
const database = await ensureDB();
try {
await database.delete(STORAGE_CONFIG.STORES.FILE_CACHE, folderId);
console.log(`[DB] Cleared file cache for folder ${folderId}`);
} catch (error) {
console.error('[DB] Failed to clear file cache:', error);
}
}
// ============================================================================
// USER PROFILE OPERATIONS
// ============================================================================
/**
* FUNCTION: Save user profile
*
* Stores logged-in user information
*/
export async function saveUserProfile(user: User): Promise<void> {
const database = await ensureDB();
try {
await database.put(STORAGE_CONFIG.STORES.USER_PROFILE, {
id: 'current',
user,
timestamp: Date.now(),
});
console.log(`[DB] Saved user profile: ${user.displayName}`);
} catch (error) {
console.error('[DB] Failed to save user profile:', error);
throw error;
}
}
/**
* FUNCTION: Get user profile
*/
export async function getUserProfile(): Promise<User | null> {
const database = await ensureDB();
try {
const data = await database.get(STORAGE_CONFIG.STORES.USER_PROFILE, 'current');
return data?.user || null;
} catch (error) {
console.error('[DB] Failed to get user profile:', error);
return null;
}
}
// ============================================================================
// BULK OPERATIONS
// ============================================================================
/**
* FUNCTION: Clear all data (on logout)
*
* Removes all jobs, cache, and user data
* Called on user logout
*/
export async function clearAllData(): Promise<void> {
const database = await ensureDB();
try {
const tx = database.transaction(
[
STORAGE_CONFIG.STORES.JOBS,
STORAGE_CONFIG.STORES.FILE_CACHE,
STORAGE_CONFIG.STORES.USER_PROFILE,
],
'readwrite'
);
await tx.objectStore(STORAGE_CONFIG.STORES.JOBS).clear();
await tx.objectStore(STORAGE_CONFIG.STORES.FILE_CACHE).clear();
await tx.objectStore(STORAGE_CONFIG.STORES.USER_PROFILE).clear();
await tx.done;
console.log('[DB] Cleared all data');
} catch (error) {
console.error('[DB] Failed to clear all data:', error);
throw error;
}
}
/**
* FUNCTION: Get database statistics
*
* Returns info about stored data (for debugging)
*/
export async function getDBStats(): Promise<{
jobsCount: number;
cachedFolders: number;
hasUserProfile: boolean;
}> {
const database = await ensureDB();
try {
const jobsCount = await database.count(STORAGE_CONFIG.STORES.JOBS);
const cachedFolders = await database.count(STORAGE_CONFIG.STORES.FILE_CACHE);
const userProfile = await database.get(STORAGE_CONFIG.STORES.USER_PROFILE, 'current');
return {
jobsCount,
cachedFolders,
hasUserProfile: !!userProfile,
};
} catch (error) {
console.error('[DB] Failed to get stats:', error);
return { jobsCount: 0, cachedFolders: 0, hasUserProfile: false };
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* ENTRY POINT: main.ts
*
* PURPOSE: Initialize and mount the Svelte app
* DEPENDENCIES: App component, app styles
*/
import './app.css';
import App from './App.svelte';
// Mount app to root element
const app = new App({
target: document.getElementById('app')!,
});
export default app;
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
/**
* PAGE: Callback
*
* PURPOSE: Handle OAuth2 authorization code callback
* FLOW:
* 1. Microsoft redirects here with ?code=xxx&state=xxx
* 2. Extract code and state from URL
* 3. Send to backend to exchange for token
* 4. Store in auth store
* 5. Redirect to home
*/
import { onMount } from 'svelte';
import { setAuthUser } from '../stores/auth';
import { handleOAuthCallback } from '../services/oauth';
import { info, error as logError } from '../services/logger';
let status: 'processing' | 'success' | 'error' = 'processing';
let error: string | null = null;
/**
* Process OAuth callback on mount
*/
onMount(async () => {
try {
status = 'processing';
info('Callback', 'Processing OAuth callback');
// Handle the callback
const { accessToken, expiresIn, user } = await handleOAuthCallback();
// Store in auth store
setAuthUser(user, accessToken, expiresIn);
status = 'success';
info('Callback', 'OAuth successful, redirecting to home', { userName: user.displayName });
// Redirect to home after short delay
setTimeout(() => {
window.location.hash = '/';
}, 500);
} catch (err) {
error = (err as Error).message;
status = 'error';
logError('Callback', 'OAuth callback failed', err as Error);
}
});
</script>
<!-- Callback Page -->
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
{#if status === 'processing'}
<h1 class="text-2xl font-bold text-center text-gray-900 mb-4">Processing Sign In</h1>
<p class="text-center text-gray-600 mb-8">
Please wait while we complete your authentication...
</p>
<div class="flex justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
</div>
{:else if status === 'success'}
<h1 class="text-2xl font-bold text-center text-gray-900 mb-4">Signed In</h1>
<p class="text-center text-gray-600">Redirecting to home page...</p>
{:else if status === 'error'}
<h1 class="text-2xl font-bold text-center text-red-900 mb-4">Sign In Failed</h1>
{#if error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-800 text-sm font-mono">{error}</p>
</div>
{/if}
<a
href="#/signin"
class="block w-full text-center py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition duration-200"
>
Try Again
</a>
{/if}
</div>
</div>
<style>
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script lang="ts">
/**
* PAGE: Home
*
* PURPOSE: Main dashboard showing jobs
* FEATURES:
* - List all jobs with search
* - Navigation header
* - Responsive layout
*/
import { jobs, jobsLoading, jobsError } from '../stores/jobs';
import Navigation from '../components/Navigation.svelte';
import SearchBar from '../components/SearchBar.svelte';
import JobList from '../components/JobList.svelte';
let displayJobs = $jobs;
let selectedJob: any = null;
/**
* Handle search results
*/
function handleSearch(results: any[]) {
displayJobs = results;
}
/**
* Handle job click
*/
function handleJobClick(job: any) {
selectedJob = job;
console.log('[Home] Selected job:', job.title);
}
/**
* Update display jobs when store changes
*/
$: displayJobs = $jobs;
</script>
<!-- Home Page -->
<div class="min-h-screen bg-gray-50">
<!-- Navigation Header -->
<Navigation />
<!-- Main Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header Section -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Jobs</h1>
<p class="mt-2 text-gray-600">
{#if $jobsLoading}
Loading jobs...
{:else if displayJobs.length === 1}
1 job available
{:else if displayJobs.length > 0}
{displayJobs.length} jobs available
{:else}
No jobs yet
{/if}
</p>
</div>
<!-- Error Alert -->
{#if $jobsError}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
<p class="font-medium">Error loading jobs</p>
<p class="text-sm mt-1">{$jobsError}</p>
</div>
{/if}
<!-- Search Bar -->
<div class="mb-6">
<SearchBar onSearch={handleSearch} />
</div>
<!-- Jobs List -->
<JobList jobs={displayJobs} loading={$jobsLoading} onJobClick={handleJobClick} />
</div>
</div>
<style>
/* Using TailwindCSS */
</style>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
/**
* PAGE: SignIn
*
* PURPOSE: OAuth login page
* FEATURES:
* - Microsoft OAuth sign-in button
* - Error handling
*/
import { onMount } from 'svelte';
import { isAuthenticated } from '../stores/auth';
import { initiateOAuthFlow } from '../services/oauth';
let isLoading = false;
let error: string | null = null;
/**
* On mount, check if already authenticated
*/
onMount(() => {
// If already authenticated, redirect to home
if ($isAuthenticated) {
console.log('[SignIn] Already authenticated, redirecting to home');
window.location.hash = '/';
}
});
/**
* Handle sign in button click
*/
async function handleSignIn() {
try {
isLoading = true;
error = null;
await initiateOAuthFlow();
} catch (err) {
error = (err as Error).message;
isLoading = false;
console.error('[SignIn] Error:', err);
}
}
</script>
<!-- SignIn Page -->
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<h1 class="text-3xl font-bold text-center text-gray-900 mb-2">Job Info</h1>
<p class="text-center text-gray-600 mb-8">Sign in to view job listings</p>
{#if error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-800 text-sm">{error}</p>
</div>
{/if}
<button
on:click={handleSignIn}
disabled={isLoading}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition duration-200"
>
{#if isLoading}
<span>Signing in...</span>
{:else}
<span>Sign in with Microsoft</span>
{/if}
</button>
</div>
</div>
<style>
/* Using TailwindCSS */
</style>
+168
View File
@@ -0,0 +1,168 @@
<script lang="ts">
/**
* COMPONENT: Root Layout
*
* PURPOSE: Main layout wrapper for all pages
* FEATURES:
* - Auth guard (redirect to signin if not logged in)
* - Load initial data on mount
* - Display navigation header
* - Display notifications/error messages
* - Display loading indicators
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { isAuthenticated, restoreAuthFromStorage } from '../stores/auth';
import { loadJobs } from '../stores/jobs';
import { isLoading, hasNotifications } from '../stores/ui';
import NotificationContainer from '../components/NotificationContainer.svelte';
// Public pages (no auth required)
const publicPages = ['/signin', '/auth/callback'];
let authChecked = false;
let isInitializing = true;
/**
* LIFECYCLE: onMount
*
* Called when component mounts
* Restores auth from storage
* Checks auth status
* Loads initial data
*/
onMount(async () => {
try {
console.log('[Layout] Initializing...');
// Restore auth from sessionStorage
restoreAuthFromStorage();
// Check if user is authenticated
if (!$isAuthenticated && !isPublicPage()) {
console.log('[Layout] Not authenticated, redirecting to signin');
authChecked = true;
isInitializing = false;
await goto('/signin');
return;
}
// If authenticated, load initial data
if ($isAuthenticated) {
console.log('[Layout] Authenticated, loading initial data');
try {
await loadJobs();
} catch (error) {
console.error('[Layout] Failed to load initial jobs:', error);
// Don't block - jobs will load on demand
}
}
authChecked = true;
isInitializing = false;
console.log('[Layout] Initialization complete');
} catch (error) {
console.error('[Layout] Initialization error:', error);
authChecked = true;
isInitializing = false;
}
});
/**
* FUNCTION: isPublicPage
*
* Check if current page is public (no auth required)
*
* RETURNS: boolean
*/
function isPublicPage(): boolean {
return publicPages.some((path) => $page.url.pathname === path);
}
</script>
<!-- Root layout structure -->
<div class="min-h-screen bg-gray-50 flex flex-col">
<!-- Navigation Header (shown when authenticated) -->
{#if $isAuthenticated}
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<!-- Logo/Title -->
<div class="flex items-center">
<h1 class="text-xl font-bold text-gray-900">Job Info</h1>
</div>
<!-- Navigation Items -->
<div class="flex items-center space-x-4">
<a href="/" class="text-gray-600 hover:text-gray-900">Home</a>
<button
on:click={() => {
// Sign out handler
// Will be connected to auth store
}}
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700"
>
Sign Out
</button>
</div>
</div>
</div>
</nav>
{/if}
<!-- Main Content -->
<main class="flex-1">
{#if isInitializing}
<!-- Loading screen during initialization -->
<div class="flex items-center justify-center h-full">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
<p class="mt-4 text-gray-600">Initializing...</p>
</div>
</div>
{:else if authChecked}
<!-- Page content -->
<slot />
{/if}
</main>
<!-- Notifications Container -->
<NotificationContainer />
<!-- Global Loading Indicator -->
{#if $isLoading}
<div class="fixed top-0 left-0 right-0 h-1 bg-blue-600 animate-pulse" />
{/if}
</div>
<style global>
/* Global animations */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>
+109
View File
@@ -0,0 +1,109 @@
<script lang="ts">
/**
* PAGE: Home
*
* PURPOSE: Main dashboard showing jobs
* FEATURES:
* - List all jobs with search
* - Filter by category
* - Click job for details
* - Responsive layout
*
* REQUIRES: User must be authenticated
*/
import { onMount } from 'svelte';
import { isAuthenticated } from '../stores/auth';
import { jobs, jobsLoading, jobsError } from '../stores/jobs';
import { goto } from '$app/navigation';
import Navigation from '../components/Navigation.svelte';
import SearchBar from '../components/SearchBar.svelte';
import JobList from '../components/JobList.svelte';
import NotificationContainer from '../components/NotificationContainer.svelte';
let displayJobs = $jobs;
let selectedJob: any = null;
/**
* Handle search results
*/
function handleSearch(results: any[]) {
displayJobs = results;
}
/**
* Handle job click
*/
function handleJobClick(job: any) {
selectedJob = job;
console.log('[Home] Selected job:', job.title);
// TODO: Navigate to job detail page or show modal
}
/**
* Update display jobs when store changes
*/
$: displayJobs = $jobs;
/**
* On page load, check authentication
*/
onMount(async () => {
if (!$isAuthenticated) {
await goto('/signin');
}
});
</script>
<!-- Home Page -->
<div class="min-h-screen bg-gray-50">
<!-- Navigation Header -->
<Navigation />
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header Section -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Jobs</h1>
<p class="mt-2 text-gray-600">
{#if $jobsLoading}
Loading jobs...
{:else if displayJobs.length === 1}
1 job available
{:else if displayJobs.length > 0}
{displayJobs.length} jobs available
{:else}
No jobs yet
{/if}
</p>
</div>
<!-- Error Alert -->
{#if $jobsError}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
<p class="font-medium">Error loading jobs</p>
<p class="text-sm mt-1">{$jobsError}</p>
</div>
{/if}
<!-- Search Bar -->
<div class="mb-6">
<SearchBar onSearch={handleSearch} />
</div>
<!-- Jobs List -->
<JobList
jobs={displayJobs}
loading={$jobsLoading}
onJobClick={handleJobClick}
/>
</main>
<!-- Notifications -->
<NotificationContainer />
</div>
<style>
/* No additional styles needed - using TailwindCSS */
</style>
@@ -0,0 +1,80 @@
<script lang="ts">
/**
* PAGE: OAuth Callback / auth/callback/+page.svelte
*
* PURPOSE: Handle OAuth2 authorization code callback
* FLOW:
* 1. Microsoft redirects here with ?code=xxx&state=xxx
* 2. Extract code and state from URL
* 3. Send to backend to exchange for token
* 4. Backend returns access token and user info
* 5. Store in auth store
* 6. Redirect to home
*
* ERROR HANDLING:
* - Missing code → show error
* - Invalid state → show error (CSRF attack)
* - Backend error → show error with retry
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { setAuthUser } from '$stores/auth';
import { handleOAuthCallback } from '$services/oauth';
let status = 'processing';
let error: string | null = null;
onMount(async () => {
try {
status = 'processing';
console.log('[Callback] Processing OAuth callback');
// Handle the callback
const { accessToken, expiresIn, user } = await handleOAuthCallback();
// Store in auth store
await setAuthUser(user, accessToken, expiresIn);
status = 'success';
console.log('[Callback] OAuth successful, redirecting to home');
// Redirect to home after short delay
setTimeout(() => {
goto('/');
}, 500);
} catch (err) {
error = (err as Error).message;
status = 'error';
console.error('[Callback] OAuth callback failed:', err);
}
});
</script>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
{#if status === 'processing'}
<h1 class="text-2xl font-bold text-center text-gray-900 mb-4">Processing Sign In</h1>
<p class="text-center text-gray-600 mb-8">Please wait while we complete your authentication...</p>
<div class="flex justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
{:else if status === 'success'}
<h1 class="text-2xl font-bold text-center text-gray-900 mb-4">Signed In</h1>
<p class="text-center text-gray-600">Redirecting to home page...</p>
{:else if status === 'error'}
<h1 class="text-2xl font-bold text-center text-red-900 mb-4">Sign In Failed</h1>
{#if error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-800 text-sm font-mono">{error}</p>
</div>
{/if}
<a
href="/signin"
class="block w-full text-center py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition duration-200"
>
Try Again
</a>
{/if}
</div>
</div>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
/**
* PAGE: Sign In / signin/+page.svelte
*
* PURPOSE: OAuth login page
* AUTH REQUIRED: No - anyone can access
*
* FEATURES:
* - "Sign in with Microsoft" button
* - Redirects to home if already authenticated
* - Calls initiateOAuthFlow() on button click
*/
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { isAuthenticated } from '$stores/auth';
import { initiateOAuthFlow } from '$services/oauth';
let isLoading = false;
let error: string | null = null;
onMount(async () => {
// If already authenticated, go to home
if ($isAuthenticated) {
console.log('[SignIn] Already authenticated, redirecting to home');
await goto('/');
}
});
async function handleSignIn() {
try {
isLoading = true;
error = null;
await initiateOAuthFlow();
} catch (err) {
error = (err as Error).message;
isLoading = false;
console.error('[SignIn] Error:', err);
}
}
</script>
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4">
<div class="max-w-md w-full bg-white rounded-lg shadow-lg p-8">
<h1 class="text-3xl font-bold text-center text-gray-900 mb-2">Job Info</h1>
<p class="text-center text-gray-600 mb-8">Sign in to view job listings</p>
{#if error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<p class="text-red-800 text-sm">{error}</p>
</div>
{/if}
<button
on:click={handleSignIn}
disabled={isLoading}
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-semibold rounded-lg transition duration-200"
>
{#if isLoading}
<span>Signing in...</span>
{:else}
<span>Sign in with Microsoft</span>
{/if}
</button>
</div>
</div>
+331
View File
@@ -0,0 +1,331 @@
/**
* SERVICE: Error Handler
*
* PURPOSE: Centralized error handling and user messaging
* DEPENDENCIES: UI state store, constants
*
* FEATURES:
* - Convert errors to user-friendly messages
* - Log errors with context
* - Handle different error types (network, auth, validation)
* - Retry logic for transient failures
* - Error recovery suggestions
*/
import type { ApiError } from '../utils/types';
import { ERROR_MESSAGES } from '../utils/constants';
// ============================================================================
// ERROR TYPES
// ============================================================================
export enum ErrorType {
NETWORK = 'network',
AUTH = 'auth',
VALIDATION = 'validation',
NOTFOUND = 'not_found',
PERMISSION = 'permission',
SERVER = 'server',
UNKNOWN = 'unknown',
}
export interface ErrorContext {
type: ErrorType;
message: string;
originalError?: Error;
statusCode?: number;
userMessage: string;
retryable: boolean;
recoverySteps?: string[];
}
// ============================================================================
// ERROR CLASSIFICATION
// ============================================================================
/**
* FUNCTION: classifyError
*
* Determine error type and user message
*
* PARAMS:
* - error: unknown - The error to classify
*
* RETURNS: ErrorContext with type, message, and user guidance
*/
export function classifyError(error: unknown): ErrorContext {
// Handle network errors
if (error instanceof TypeError && error.message.includes('fetch')) {
return {
type: ErrorType.NETWORK,
message: 'Network request failed',
originalError: error,
userMessage: ERROR_MESSAGES.NETWORK_ERROR,
retryable: true,
recoverySteps: [
'Check your internet connection',
'Try again in a few moments',
'If problem persists, refresh the page',
],
};
}
// Handle timeout errors
if (error instanceof Error && error.message.includes('timeout')) {
return {
type: ErrorType.NETWORK,
message: 'Request timeout',
originalError: error,
userMessage: 'Request took too long. Please try again.',
retryable: true,
recoverySteps: ['Check internet speed', 'Try again', 'Refresh page if needed'],
};
}
// Handle auth errors (401)
if (error instanceof Error && error.message.includes('401')) {
return {
type: ErrorType.AUTH,
message: 'Invalid or expired token',
originalError: error,
userMessage: ERROR_MESSAGES.AUTH_FAILED,
retryable: false,
recoverySteps: ['Please sign in again'],
};
}
// Handle permission errors (403)
if (error instanceof Error && error.message.includes('403')) {
return {
type: ErrorType.PERMISSION,
message: 'Access denied',
originalError: error,
userMessage: ERROR_MESSAGES.PERMISSION_DENIED,
retryable: false,
recoverySteps: [
'Check that you have permission to access this resource',
'Contact administrator if needed',
],
};
}
// Handle not found errors (404)
if (error instanceof Error && error.message.includes('404')) {
return {
type: ErrorType.NOTFOUND,
message: 'Resource not found',
originalError: error,
userMessage: 'The requested resource was not found.',
retryable: false,
recoverySteps: ['Go back and try again', 'Refresh the page'],
};
}
// Handle server errors (5xx)
if (error instanceof Error && error.message.includes('5')) {
return {
type: ErrorType.SERVER,
message: 'Server error',
originalError: error,
userMessage: ERROR_MESSAGES.SERVER_ERROR,
retryable: true,
recoverySteps: [
'The server is having issues',
'Please try again in a few moments',
],
};
}
// Handle validation errors
if (error instanceof Error && error.message.includes('validation')) {
return {
type: ErrorType.VALIDATION,
message: 'Validation failed',
originalError: error,
userMessage: 'Please check your input and try again.',
retryable: true,
recoverySteps: ['Review the form', 'Correct any errors', 'Submit again'],
};
}
// Default unknown error
return {
type: ErrorType.UNKNOWN,
message: error instanceof Error ? error.message : String(error),
originalError: error instanceof Error ? error : new Error(String(error)),
userMessage: ERROR_MESSAGES.UNKNOWN_ERROR,
retryable: true,
recoverySteps: ['Try again', 'Refresh the page if needed'],
};
}
// ============================================================================
// LOGGING
// ============================================================================
/**
* FUNCTION: logError
*
* Log error with context and timestamp
* Helps with debugging and error tracking
*
* PARAMS:
* - context: ErrorContext - Error details
* - additionalContext?: string - Extra info
*/
export function logError(context: ErrorContext, additionalContext?: string): void {
const timestamp = new Date().toISOString();
const prefix = `[ERROR ${timestamp}]`;
console.error(`${prefix} [${context.type.toUpperCase()}]`, {
message: context.message,
userMessage: context.userMessage,
statusCode: context.statusCode,
retryable: context.retryable,
additional: additionalContext,
stack: context.originalError?.stack,
});
}
/**
* FUNCTION: captureErrorMetrics
*
* Capture error metrics for analytics
* Could be sent to error tracking service
*
* PARAMS:
* - context: ErrorContext - Error details
*/
export function captureErrorMetrics(context: ErrorContext): void {
// Could integrate with error tracking service here
// (Sentry, LogRocket, etc.)
console.debug('[Metrics] Error captured:', {
type: context.type,
retryable: context.retryable,
timestamp: new Date().toISOString(),
});
}
// ============================================================================
// ERROR RECOVERY
// ============================================================================
/**
* FUNCTION: shouldRetry
*
* Determine if error should be retried
* Checks error type and attempt count
*
* PARAMS:
* - error: ErrorContext - Classified error
* - attempts: number - Number of retry attempts made
*
* RETURNS: boolean - true if should retry
*/
export function shouldRetry(error: ErrorContext, attempts: number): boolean {
// Don't retry too many times
if (attempts >= 3) return false;
// Only retry retryable errors
return error.retryable;
}
/**
* FUNCTION: getRetryDelay
*
* Calculate exponential backoff delay
* Reduces hammering server on repeated failures
*
* PARAMS:
* - attempt: number - Which attempt number (1, 2, 3...)
*
* RETURNS: number - Milliseconds to wait before retry
*/
export function getRetryDelay(attempt: number): number {
// Exponential backoff: 100ms, 200ms, 400ms, 800ms...
const baseDelay = 100;
const delay = baseDelay * Math.pow(2, attempt - 1);
// Add random jitter (±20%) to prevent thundering herd
const jitter = delay * 0.2 * (Math.random() - 0.5);
return Math.max(100, Math.min(delay + jitter, 5000));
}
/**
* FUNCTION: retryOperation
*
* Retry operation with exponential backoff
*
* PARAMS:
* - operation: () => Promise<T> - Function to retry
* - maxAttempts: number - Max number of attempts (default: 3)
*
* RETURNS: Promise<T> - Result of successful operation
* THROWS: Error if all attempts fail
*/
export async function retryOperation<T>(
operation: () => Promise<T>,
maxAttempts: number = 3
): Promise<T> {
let lastError: ErrorContext | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = classifyError(error);
if (!shouldRetry(lastError, attempt)) {
logError(lastError, `Attempt ${attempt} failed, not retrying`);
throw error;
}
const delay = getRetryDelay(attempt);
console.log(`[Retry] Attempt ${attempt} failed, waiting ${delay}ms before retry`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
// All attempts failed
if (lastError) {
logError(lastError, `All ${maxAttempts} attempts failed`);
}
throw new Error(`Operation failed after ${maxAttempts} attempts`);
}
// ============================================================================
// USER MESSAGES
// ============================================================================
/**
* FUNCTION: getUserMessage
*
* Get user-friendly error message
* Safe to display in UI
*
* PARAMS:
* - error: unknown - The error
*
* RETURNS: string - User-friendly message
*/
export function getUserMessage(error: unknown): string {
const context = classifyError(error);
return context.userMessage;
}
/**
* FUNCTION: getRecoverySteps
*
* Get suggested recovery steps
* Help user recover from error
*
* PARAMS:
* - error: unknown - The error
*
* RETURNS: string[] - List of suggested steps
*/
export function getRecoverySteps(error: unknown): string[] {
const context = classifyError(error);
return context.recoverySteps || [];
}
+324
View File
@@ -0,0 +1,324 @@
/**
* SERVICE: Microsoft Graph API
*
* PURPOSE: Handle all Microsoft Graph API calls
* DEPENDENCIES: accessToken store, error handling, constants
*
* ENDPOINTS:
* - /v1.0/me - Get user profile
* - /v1.0/me/drive/root/children - Get files in root
* - /v1.0/me/drive/items/{id}/children - Get files in folder
*
* ERROR HANDLING:
* - 401: Invalid token, refresh and retry
* - 403: Insufficient permissions
* - 404: Not found
* - 429: Rate limited, should retry
*
* SECURITY:
* - All requests include Bearer token in Authorization header
* - Tokens must be valid before API calls
* - 401 responses trigger automatic logout
*/
import { get } from 'svelte/store';
import { accessToken, refreshAccessToken, clearAuth } from '../stores/auth';
import { GRAPH_CONFIG } from '../utils/constants';
import type { MicrosoftFile } from '../utils/types';
import { apiRequest } from '../utils/api-request';
import { info, error as logError } from './logger';
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* FUNCTION: ensureValidToken
*
* Ensures access token is available and valid
* Throws error if not authenticated
*
* Used before every API call
*
* RETURNS: string (access token)
* THROWS: Error if no valid token
*/
function ensureValidToken(): string {
const token = get(accessToken);
if (!token) {
throw new Error('No access token available - user not authenticated');
}
return token;
}
/**
* FUNCTION: getGraphHeaders
*
* Build HTTP headers for Graph API request
* Includes Authorization header with Bearer token
*/
function getGraphHeaders(): HeadersInit {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${ensureValidToken()}`,
};
}
/**
* FUNCTION: handleGraphError
*
* Standard error handling for Graph API responses
* Handles 401 (invalid token) specially
*
* PARAMS:
* - response: Response - HTTP response
*
* THROWS: Error with detailed message
*/
async function handleGraphError(response: Response): Promise<never> {
// Special handling for 401 (invalid token)
if (response.status === 401) {
console.error('[Graph] Received 401 Unauthorized, clearing auth');
clearAuth();
throw new Error('Session expired - please log in again');
}
// Try to get error details from response
let errorDetails = '';
try {
const error = await response.json();
errorDetails = error.error?.message || error.message || response.statusText;
} catch (e) {
errorDetails = response.statusText;
}
throw new Error(`Graph API error ${response.status}: ${errorDetails}`);
}
// ============================================================================
// USER PROFILE
// ============================================================================
/**
* FUNCTION: getUserProfile
*
* Get current user profile from Microsoft Graph
*
* ENDPOINT: GET /v1.0/me
* SCOPES: User.Read
*
* RETURNS: {
* id: string,
* displayName: string,
* mail: string,
* jobTitle?: string,
* mobilePhone?: string
* }
*/
export async function getUserProfile(): Promise<any> {
info('Graph', 'Fetching user profile');
const result = await apiRequest<any>(
`${GRAPH_CONFIG.BASE_URL}${GRAPH_CONFIG.ENDPOINTS.USER_PROFILE}`,
{
method: 'GET',
requiresAuth: true,
retries: 2,
}
);
if (!result.success) {
logError('Graph', 'Failed to get user profile', new Error(result.error), {
endpoint: 'USER_PROFILE',
});
throw new Error(result.error || 'Failed to get user profile');
}
info('Graph', 'Retrieved user profile', { displayName: result.data?.displayName });
return result.data;
}
// ============================================================================
// FILE OPERATIONS
// ============================================================================
/**
* FUNCTION: listFilesInRoot
*
* Get files and folders in OneDrive root
*
* ENDPOINT: GET /v1.0/me/drive/root/children
* SCOPES: Files.Read.All
*
* RETURNS: MicrosoftFile[]
*/
export async function listFilesInRoot(): Promise<MicrosoftFile[]> {
info('Graph', 'Listing files in root directory');
const result = await apiRequest<{ value: MicrosoftFile[] }>(
`${GRAPH_CONFIG.BASE_URL}${GRAPH_CONFIG.ENDPOINTS.DRIVE_ROOT}`,
{
method: 'GET',
requiresAuth: true,
retries: 2,
}
);
if (!result.success) {
logError('Graph', 'Failed to list files in root', new Error(result.error), {
endpoint: 'DRIVE_ROOT',
});
throw new Error(result.error || 'Failed to list files');
}
const files = result.data?.value || [];
info('Graph', `Listed files in root`, { count: files.length });
return files;
}
/**
* FUNCTION: listFilesInFolder
*
* Get files and folders in specific folder
*
* ENDPOINT: GET /v1.0/me/drive/items/{id}/children
* PARAMS:
* - folderId: string - ID of folder
*
* RETURNS: MicrosoftFile[]
*/
export async function listFilesInFolder(folderId: string): Promise<MicrosoftFile[]> {
info('Graph', 'Listing files in folder', { folderId });
const endpoint = GRAPH_CONFIG.ENDPOINTS.DRIVE_ITEMS.replace('{id}', folderId);
const result = await apiRequest<{ value: MicrosoftFile[] }>(
`${GRAPH_CONFIG.BASE_URL}${endpoint}`,
{
method: 'GET',
requiresAuth: true,
retries: 2,
}
);
if (!result.success) {
logError('Graph', 'Failed to list files in folder', new Error(result.error), {
folderId,
});
throw new Error(result.error || 'Failed to list files');
}
const files = result.data?.value || [];
info('Graph', 'Listed files in folder', { folderId, count: files.length });
return files;
}
/**
* FUNCTION: getFile
*
* Get metadata for single file
*
* ENDPOINT: GET /v1.0/me/drive/items/{id}
* PARAMS:
* - fileId: string - File ID
*
* RETURNS: File metadata
*/
export async function getFile(fileId: string): Promise<MicrosoftFile> {
info('Graph', 'Getting file metadata', { fileId });
const result = await apiRequest<MicrosoftFile>(
`${GRAPH_CONFIG.BASE_URL}/me/drive/items/${fileId}`,
{
method: 'GET',
requiresAuth: true,
retries: 2,
}
);
if (!result.success) {
logError('Graph', 'Failed to get file metadata', new Error(result.error), {
fileId,
});
throw new Error(result.error || 'Failed to get file');
}
info('Graph', 'Retrieved file metadata', { fileName: result.data?.name });
return result.data!;
}
/**
* FUNCTION: downloadFile
*
* Get download URL for file
* Used for downloading or previewing files
*
* ENDPOINT: GET /v1.0/me/drive/items/{id}/content
* PARAMS:
* - fileId: string - File ID
*
* Note: Returns redirect to temporary download URL
* Download URLs expire after 15 minutes
*/
export function getFileDownloadUrl(fileId: string): string {
ensureValidToken(); // Ensure auth before returning URL
return `${GRAPH_CONFIG.BASE_URL}/me/drive/items/${fileId}/content`;
}
// ============================================================================
// SEARCH
// ============================================================================
/**
* FUNCTION: searchFiles
*
* Search for files in OneDrive
* Uses Graph Search API (beta)
*
* ENDPOINT: POST /v1.0/search/query (beta)
* PARAMS:
* - query: string - Search term
*
* RETURNS: Search results
*
* Note: Requires beta endpoint, not in general availability
* For now, use client-side filtering
*/
export async function searchFiles(query: string): Promise<MicrosoftFile[]> {
// Placeholder - implement if Graph Search API needed
// For now, use client-side search on cached files
console.log('[Graph] Client-side search would be used for:', query);
return [];
}
// ============================================================================
// ERROR HANDLING & RETRY
// ============================================================================
/**
* FUNCTION: retryWithTokenRefresh
*
* Helper to retry API call after refreshing token
* Used when token expires mid-request
*
* PARAMS:
* - fn: () => Promise<T> - Function to retry
*
* RETURNS: Promise<T>
*/
export async function retryWithTokenRefresh<T>(
fn: () => Promise<T>
): Promise<T> {
try {
return await fn();
} catch (error) {
// Check if error is likely due to expired token
const message = (error as Error).message;
if (message.includes('401') || message.includes('Unauthorized')) {
console.log('[Graph] Token likely expired, attempting refresh');
await refreshAccessToken();
// Retry the operation
return await fn();
}
throw error;
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* SERVICE: Logging & Analytics
*
* PURPOSE: Centralized logging for debugging and monitoring
* FEATURES:
* - Structured logging with timestamps
* - Log levels (DEBUG, INFO, WARN, ERROR)
* - Context tracking (user, component, action)
* - Local storage of logs (last 100 entries)
* - Export logs for debugging
*/
import { IS_DEVELOPMENT } from '../utils/constants';
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARN = 'WARN',
ERROR = 'ERROR',
}
export interface LogEntry {
timestamp: string;
level: LogLevel;
module: string;
message: string;
context?: Record<string, any>;
stack?: string;
}
// In-memory log buffer (last 100 entries)
const logBuffer: LogEntry[] = [];
const MAX_LOGS = 100;
/**
* FUNCTION: log
*
* Log a message with context
* Stores in memory and console (dev only)
*/
export function log(
module: string,
message: string,
level: LogLevel = LogLevel.INFO,
context?: Record<string, any>
): void {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
module,
message,
context,
};
// Add to buffer
logBuffer.push(entry);
if (logBuffer.length > MAX_LOGS) {
logBuffer.shift();
}
// Console output in development
if (IS_DEVELOPMENT) {
const color =
level === LogLevel.ERROR
? 'color: red'
: level === LogLevel.WARN
? 'color: orange'
: level === LogLevel.DEBUG
? 'color: gray'
: 'color: blue';
console.log(
`%c[${module}] ${message}`,
color,
context ? context : ''
);
}
}
/**
* FUNCTION: debug
* Convenience function for DEBUG level
*/
export function debug(module: string, message: string, context?: Record<string, any>): void {
log(module, message, LogLevel.DEBUG, context);
}
/**
* FUNCTION: info
* Convenience function for INFO level
*/
export function info(module: string, message: string, context?: Record<string, any>): void {
log(module, message, LogLevel.INFO, context);
}
/**
* FUNCTION: warn
* Convenience function for WARN level
*/
export function warn(module: string, message: string, context?: Record<string, any>): void {
log(module, message, LogLevel.WARN, context);
}
/**
* FUNCTION: error
* Convenience function for ERROR level with stack trace
*/
export function error(module: string, message: string, err?: Error, context?: Record<string, any>): void {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level: LogLevel.ERROR,
module,
message,
context,
stack: err?.stack,
};
logBuffer.push(entry);
if (logBuffer.length > MAX_LOGS) {
logBuffer.shift();
}
if (IS_DEVELOPMENT) {
console.error(`[${module}] ${message}`, err, context);
}
}
/**
* FUNCTION: getLogs
*
* Retrieve all logs in buffer
* Useful for debugging and exporting
*/
export function getLogs(): LogEntry[] {
return [...logBuffer];
}
/**
* FUNCTION: clearLogs
*
* Clear log buffer
*/
export function clearLogs(): void {
logBuffer.length = 0;
}
/**
* FUNCTION: exportLogs
*
* Export logs as JSON file for analysis
*/
export function exportLogs(): void {
const logsJson = JSON.stringify(logBuffer, null, 2);
const blob = new Blob([logsJson], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `job-info-logs-${new Date().toISOString()}.json`;
a.click();
URL.revokeObjectURL(url);
}
/**
* FUNCTION: trackPageView
*
* Track page navigation for analytics
*/
export function trackPageView(page: string): void {
info('Analytics', `Page view: ${page}`, { page });
}
/**
* FUNCTION: trackEvent
*
* Track custom event
*/
export function trackEvent(event: string, context?: Record<string, any>): void {
info('Analytics', `Event: ${event}`, context);
}
/**
* FUNCTION: trackError
*
* Track error for monitoring
*/
export function trackError(module: string, errorType: string, err?: Error): void {
error(module, `Error tracked: ${errorType}`, err, {
errorType,
message: err?.message,
});
}
+289
View File
@@ -0,0 +1,289 @@
/**
* SERVICE: OAuth2 with PKCE
*
* PURPOSE: Handle OAuth2 authorization code flow with PKCE
* DEPENDENCIES: Window APIs (crypto), constants
*
* FLOW:
* 1. generatePKCE() - Create code verifier and challenge
* 2. initiateOAuthFlow() - Redirect user to Microsoft login
* 3. handleOAuthCallback() - Exchange code for token (in callback route)
*
* SECURITY:
* - PKCE protects against authorization code interception
* - State parameter prevents CSRF attacks
* - Verification stored in sessionStorage (lost on close)
* - Never expose client secret (backend only)
*/
import { OAUTH_CONFIG, APP_CONFIG } from '../utils/constants';
// ============================================================================
// TYPES
// ============================================================================
interface PKCEPair {
verifier: string;
challenge: string;
}
interface OAuthState {
state: string;
codeVerifier: string;
timestamp: number;
}
// ============================================================================
// PKCE IMPLEMENTATION
// ============================================================================
/**
* FUNCTION: generatePKCE
*
* Generates PKCE code verifier and challenge
*
* PKCE Flow:
* 1. Generate random string (43 chars, base64url encoded)
* 2. Create SHA256 hash of verifier
* 3. Base64url encode hash to create challenge
* 4. Send challenge to Microsoft
* 5. Send verifier when exchanging code (proves we initiated the request)
*
* Returns: { verifier: string, challenge: string }
*
* NOTE: Verifier must be 43-128 chars. We use 43 (minimum safe).
*/
export async function generatePKCE(): Promise<PKCEPair> {
// Step 1: Generate random bytes
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
// Step 2: Convert to base64url string
const verifier = btoa(String.fromCharCode(...randomBytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// Step 3: Create SHA256 hash of verifier
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
// Step 4: Convert hash to base64url
const hashArray = Array.from(new Uint8Array(hashBuffer));
const challenge = btoa(String.fromCharCode(...hashArray))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return { verifier, challenge };
}
/**
* FUNCTION: generateState
*
* Generates random state string for CSRF protection
* Returns: Random hex string
*/
function generateState(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
// ============================================================================
// OAUTH FLOW
// ============================================================================
/**
* FUNCTION: initiateOAuthFlow
*
* Called: When user clicks "Sign in with Microsoft"
*
* STEPS:
* 1. Generate PKCE verifier and challenge
* 2. Generate state for CSRF protection
* 3. Store both in sessionStorage
* 4. Build authorization URL
* 5. Redirect to Microsoft login
*
* Redirect URL includes:
* - client_id: App ID from Azure
* - scopes: What data we can access
* - code_challenge: For PKCE verification
* - state: For CSRF protection
* - redirect_uri: Where to return after login
*/
export async function initiateOAuthFlow(): Promise<void> {
try {
// Step 1: Generate PKCE
const { verifier, challenge } = await generatePKCE();
// Step 2: Generate state
const state = generateState();
// Step 3: Store in sessionStorage (lost on browser close)
const oauthState: OAuthState = {
state,
codeVerifier: verifier,
timestamp: Date.now(),
};
sessionStorage.setItem('oauth_state', JSON.stringify(oauthState));
// Step 4: Build authorization URL
const clientId = APP_CONFIG.MICROSOFT_CLIENT_ID;
const redirectUri = APP_CONFIG.REDIRECT_URI;
if (!clientId || !redirectUri) {
throw new Error('Missing OAuth configuration (CLIENT_ID or REDIRECT_URI)');
}
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
scope: OAUTH_CONFIG.SCOPES.join(' '),
redirect_uri: redirectUri,
response_mode: 'query',
code_challenge: challenge,
code_challenge_method: OAUTH_CONFIG.PKCE_CHALLENGE_METHOD,
state: state,
prompt: 'select_account', // Let user choose account
});
const authorizeUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`;
console.log('[OAuth] Redirecting to Microsoft login');
// Step 5: Redirect
window.location.href = authorizeUrl;
} catch (error) {
console.error('[OAuth] Failed to initiate OAuth flow:', error);
throw error;
}
}
/**
* FUNCTION: handleOAuthCallback
*
* Called: In /auth/callback route after user logs in
* Called from: src/routes/auth/callback/+page.svelte
*
* STEPS:
* 1. Extract code and state from URL
* 2. Retrieve saved state and verifier from sessionStorage
* 3. Validate state (CSRF protection)
* 4. Send to backend with code and verifier
* 5. Backend exchanges code for token
* 6. Return token and user info
*
* Returns: { accessToken, expiresIn, user }
*
* Throws: If code missing, state invalid, or exchange fails
*/
export async function handleOAuthCallback(): Promise<{
accessToken: string;
expiresIn: number;
user: any;
}> {
try {
// Step 1: Extract from URL
// With hash-based routing, params could be in hash or search
let params = new URLSearchParams(window.location.search);
// If params empty, check hash (hash-based routing)
if (!params.has('code')) {
const hash = window.location.hash.split('?')[1];
if (hash) {
params = new URLSearchParams(hash);
}
}
const code = params.get('code');
const returnedState = params.get('state');
const error = params.get('error');
const errorDescription = params.get('error_description');
// Handle error responses from Microsoft
if (error) {
throw new Error(`OAuth error: ${error} - ${errorDescription || 'Unknown error'}`);
}
if (!code) {
throw new Error('Authorization code not received from Microsoft');
}
if (!returnedState) {
throw new Error('State parameter missing from callback');
}
// Step 2: Retrieve saved OAuth state
const savedOAuthState = sessionStorage.getItem('oauth_state');
if (!savedOAuthState) {
throw new Error('OAuth state not found in session');
}
const oauthState: OAuthState = JSON.parse(savedOAuthState);
// Step 3: Validate state (CSRF protection)
if (returnedState !== oauthState.state) {
throw new Error('State mismatch - possible CSRF attack detected');
}
// Step 4: Send to backend
console.log('[OAuth] Exchanging code for token');
const response = await fetch('/api/auth/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
state: returnedState,
codeVerifier: oauthState.codeVerifier,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Token exchange failed: ${error.message}`);
}
const result = await response.json();
// Step 5: Clear OAuth state
sessionStorage.removeItem('oauth_state');
console.log('[OAuth] Successfully exchanged code for token');
return result;
} catch (error) {
console.error('[OAuth] Callback handling failed:', error);
throw error;
}
}
/**
* FUNCTION: getOAuthState
*
* Helper to get current OAuth state from sessionStorage
* Used for debugging and state inspection
*/
export function getOAuthState(): OAuthState | null {
try {
const saved = sessionStorage.getItem('oauth_state');
return saved ? JSON.parse(saved) : null;
} catch (error) {
console.error('[OAuth] Failed to get OAuth state:', error);
return null;
}
}
/**
* FUNCTION: clearOAuthState
*
* Helper to manually clear OAuth state
* Called on logout or error recovery
*/
export function clearOAuthState(): void {
sessionStorage.removeItem('oauth_state');
console.log('[OAuth] Cleared OAuth state');
}
+335
View File
@@ -0,0 +1,335 @@
/**
* STORE: auth
*
* PURPOSE: Centralized authentication state management
* DEPENDENCIES: Svelte stores, localStorage for short-term storage
* PERSISTENCE: Tokens in sessionStorage, user in IndexedDB
*
* EXPORTS:
* - Stores: user, accessToken, tokenExpiry, isAuthenticated
* - Functions: setAuthUser, refreshAccessToken, clearAuth
*
* RULES:
* - User store: Holds Microsoft user info
* - accessToken: NEVER in localStorage, only sessionStorage (lost on close)
* - tokenExpiry: Timestamp in milliseconds
* - isAuthenticated: Derived (true if user + valid token)
*
* FLOW:
* 1. User logs in → /auth/callback gets code
* 2. Send to backend → get accessToken + user
* 3. Call setAuthUser() to populate stores
* 4. Service Worker automatically refreshes before expiry
* 5. Frontend checks token on each API call
* 6. On logout → clearAuth() wipes everything
*/
import { writable, derived, get } from 'svelte/store';
import type { User } from '../utils/types';
import { TOKEN_CONFIG } from '../utils/constants';
// ============================================================================
// WRITABLE STORES
// ============================================================================
/**
* STORE: user
* Current logged-in user from Microsoft
* Set on successful OAuth callback
* Cleared on logout
*/
export const user = writable<User | null>(null);
/**
* STORE: accessToken
* Current access token for Microsoft Graph API
* Expires every 1 hour
* NEVER stored in localStorage (XSS vulnerability)
* Lost on browser refresh (intentional for security)
*/
export const accessToken = writable<string | null>(null);
/**
* STORE: tokenExpiry
* Millisecond timestamp when current token expires
* Used to determine if token needs refresh
* Refresh triggered 5 minutes before expiry
*/
export const tokenExpiry = writable<number | null>(null);
// ============================================================================
// DERIVED STORES
// ============================================================================
/**
* DERIVED STORE: isAuthenticated
*
* Returns true if:
* - User exists (logged in)
* - Token exists
* - Token not expired
*
* Automatically updates when user, accessToken, or tokenExpiry changes
*/
export const isAuthenticated = derived(
[user, accessToken, tokenExpiry],
([$user, $token, $expiry]) => {
// Must have user and token
if (!$user || !$token) return false;
// Token must not be expired
if (!$expiry || Date.now() >= $expiry) return false;
return true;
}
);
// ============================================================================
// UPDATE FUNCTIONS
// ============================================================================
/**
* FUNCTION: setAuthUser
*
* Called: After successful OAuth code exchange (in /auth/callback)
* Updates: user, accessToken, tokenExpiry stores
* Side effects: Stores token in sessionStorage, sets refresh timer
*
* FLOW:
* 1. Receive user + token + expiry from backend
* 2. Update all three stores
* 3. Store token in sessionStorage (safe, lost on close)
* 4. Set timer to refresh token before it expires
*/
export async function setAuthUser(
newUser: User,
token: string,
expiresIn: number
): Promise<void> {
// Calculate expiry time in milliseconds
const expiryTime = Date.now() + expiresIn * 1000;
// Update stores
user.set(newUser);
accessToken.set(token);
tokenExpiry.set(expiryTime);
// Store token in sessionStorage for recovery on page reload
sessionStorage.setItem('ms_access_token', token);
sessionStorage.setItem('ms_token_expiry', expiryTime.toString());
console.log(`[Auth] User authenticated: ${newUser.displayName}`);
console.log(`[Auth] Token expires in ${expiresIn} seconds`);
// Set up automatic refresh timer
// Refresh 5 minutes before token actually expires
const refreshIn = expiresIn * 1000 - (TOKEN_CONFIG.REFRESH_BUFFER_SECONDS * 1000);
if (refreshIn > 0) {
setTimeout(() => {
console.log('[Auth] Automatic token refresh triggered');
refreshAccessToken();
}, refreshIn);
}
}
/**
* FUNCTION: refreshAccessToken
*
* Called: Automatically by timer 5 min before expiry
* Called: Before API calls if token might be expiring
*
* FLOW:
* 1. Get current user ID
* 2. Call POST /api/auth/refresh with user ID
* 3. Receive new accessToken + expiresIn
* 4. Update stores and sessionStorage
* 5. Reset refresh timer
*
* Error handling:
* - If refresh fails → force logout (user must re-authenticate)
* - Errors logged to console
*/
export async function refreshAccessToken(): Promise<void> {
try {
const currentUser = get(user);
if (!currentUser) {
console.error('[Auth] Cannot refresh: no user logged in');
return;
}
// Call backend to refresh token
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${currentUser.id}`,
},
});
if (response.status === 401) {
// Token or user invalid, force re-login
console.error('[Auth] Token refresh failed (401), clearing auth');
clearAuth();
throw new Error('Session expired - please log in again');
}
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.statusText}`);
}
const { accessToken: newToken, expiresIn } = await response.json();
// Update stores with new token
accessToken.set(newToken);
const expiryTime = Date.now() + expiresIn * 1000;
tokenExpiry.set(expiryTime);
// Update sessionStorage
sessionStorage.setItem('ms_access_token', newToken);
sessionStorage.setItem('ms_token_expiry', expiryTime.toString());
console.log(`[Auth] Token refreshed, expires in ${expiresIn} seconds`);
// Reset refresh timer
const refreshIn = expiresIn * 1000 - (TOKEN_CONFIG.REFRESH_BUFFER_SECONDS * 1000);
if (refreshIn > 0) {
setTimeout(() => {
console.log('[Auth] Automatic token refresh triggered');
refreshAccessToken();
}, refreshIn);
}
} catch (error) {
console.error('[Auth] Failed to refresh token:', error);
clearAuth();
throw error;
}
}
/**
* FUNCTION: clearAuth
*
* Called: On logout button click
* Called: On token refresh failure
* Called: On auth errors
*
* Clears:
* - All stores (user, token, expiry)
* - SessionStorage tokens
* - All IndexedDB data (jobs, cache)
*
* Side effects:
* - Calls backend /api/auth/logout
* - Clears IndexedDB
* - Redirects to /signin
*/
export async function clearAuth(): Promise<void> {
try {
// Clear stores
user.set(null);
accessToken.set(null);
tokenExpiry.set(null);
// Clear sessionStorage
sessionStorage.removeItem('ms_access_token');
sessionStorage.removeItem('ms_token_expiry');
// Call backend logout (clears refresh token from PocketBase)
try {
await fetch('/api/auth/logout', { method: 'POST' });
} catch (e) {
console.error('[Auth] Logout API call failed:', e);
// Continue clearing even if backend fails
}
console.log('[Auth] Auth cleared, user logged out');
} catch (error) {
console.error('[Auth] Error during logout:', error);
throw error;
}
}
/**
* FUNCTION: restoreAuthFromStorage
*
* Called: On app startup/reload
* Attempts to restore user session from sessionStorage
*
* Only restores token (not user data)
* User info will be refetched from /api/auth/status if needed
*/
export function restoreAuthFromStorage(): void {
try {
const savedToken = sessionStorage.getItem('ms_access_token');
const savedExpiry = sessionStorage.getItem('ms_token_expiry');
if (!savedToken || !savedExpiry) {
return; // No saved auth
}
const expiry = parseInt(savedExpiry, 10);
// Only restore if token not expired
if (Date.now() < expiry) {
accessToken.set(savedToken);
tokenExpiry.set(expiry);
console.log('[Auth] Restored token from sessionStorage');
// Set up refresh timer for restored token
const timeUntilExpiry = expiry - Date.now();
const refreshIn = timeUntilExpiry - (TOKEN_CONFIG.REFRESH_BUFFER_SECONDS * 1000);
if (refreshIn > 0) {
setTimeout(() => {
console.log('[Auth] Automatic token refresh triggered (restored)');
refreshAccessToken();
}, refreshIn);
}
} else {
// Token expired, clear it
sessionStorage.removeItem('ms_access_token');
sessionStorage.removeItem('ms_token_expiry');
console.log('[Auth] Stored token expired, cleared');
}
} catch (error) {
console.error('[Auth] Failed to restore auth from storage:', error);
}
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* FUNCTION: getValidToken
*
* Helper to get current access token if valid
* Throws error if not authenticated
*
* Used by: API services before making requests
*/
export function getValidToken(): string {
const token = get(accessToken);
const expiry = get(tokenExpiry);
if (!token || !expiry || Date.now() >= expiry) {
throw new Error('No valid access token available');
}
return token;
}
/**
* FUNCTION: getAuthHeaders
*
* Helper to get HTTP headers for API requests
* Includes Authorization header with Bearer token
*
* Usage:
* const headers = getAuthHeaders();
* fetch(url, { headers });
*/
export function getAuthHeaders(): HeadersInit {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${getValidToken()}`,
};
}
+281
View File
@@ -0,0 +1,281 @@
/**
* STORE: jobs
*
* PURPOSE: Centralized job data management
* DEPENDENCIES: IndexedDB (db.ts), Svelte stores
* PERSISTENCE: Jobs stored in IndexedDB, loaded on app startup
*
* EXPORTS:
* - Stores: jobs, jobsLoading, jobsError
* - Functions: loadJobs, searchJobs, saveJobs, clearJobs
*
* FLOW:
* 1. App starts → loadJobs() loads from IndexedDB
* 2. Jobs display in JobList component
* 3. Search filters jobs locally (no API call needed)
* 4. New jobs from API → saveJobs() updates IndexedDB
* 5. Logout → clearJobs() empties cache
*
* DATA PERSISTENCE:
* - All jobs stored in IndexedDB
* - Survives browser refresh
* - Cleared on logout
* - Indexed by: id, company, date
*/
import { writable, derived, get } from 'svelte/store';
import type { Job } from '../utils/types';
import * as db from '../lib/db';
// ============================================================================
// WRITABLE STORES
// ============================================================================
/**
* STORE: jobs
* Array of all cached jobs
* Populated from IndexedDB on app load
* Updated when new jobs fetched from backend
*/
export const jobs = writable<Job[]>([]);
/**
* STORE: jobsLoading
* True while jobs being loaded from IndexedDB or API
* Used to show loading spinner
*/
export const jobsLoading = writable(false);
/**
* STORE: jobsError
* Error message if jobs failed to load
* Null if no error
*/
export const jobsError = writable<string | null>(null);
// ============================================================================
// DERIVED STORES
// ============================================================================
/**
* DERIVED STORE: jobsCount
* Number of jobs in current cache
* Updates automatically when jobs change
*/
export const jobsCount = derived(jobs, ($jobs) => $jobs.length);
/**
* DERIVED STORE: jobsGroupedByCompany
* Jobs grouped by company name
* Useful for company-based filtering
*/
export const jobsGroupedByCompany = derived(jobs, ($jobs) => {
const grouped: Record<string, Job[]> = {};
for (const job of $jobs) {
if (!grouped[job.company]) {
grouped[job.company] = [];
}
grouped[job.company].push(job);
}
return grouped;
});
// ============================================================================
// UPDATE FUNCTIONS
// ============================================================================
/**
* FUNCTION: loadJobs
*
* Called: On app startup
* Loads all cached jobs from IndexedDB
* Updates jobs store
*
* ERROR HANDLING:
* - If load fails, sets jobsError
* - Continues (doesn't throw)
*/
export async function loadJobs(): Promise<void> {
jobsLoading.set(true);
jobsError.set(null);
try {
console.log('[Jobs] Loading jobs from IndexedDB');
const cachedJobs = await db.getJobs();
jobs.set(cachedJobs);
console.log(`[Jobs] Loaded ${cachedJobs.length} jobs`);
} catch (error) {
const message = (error as Error).message;
console.error('[Jobs] Failed to load jobs:', message);
jobsError.set(message);
} finally {
jobsLoading.set(false);
}
}
/**
* FUNCTION: saveJobs
*
* Called: When new jobs fetched from backend/API
* Stores jobs in IndexedDB
* Updates jobs store
*
* PARAMS:
* - newJobs: Job[] - Array of jobs to save
*
* ERROR HANDLING:
* - If save fails, sets jobsError
* - Continues (doesn't throw)
*/
export async function saveJobs(newJobs: Job[]): Promise<void> {
jobsLoading.set(true);
jobsError.set(null);
try {
console.log(`[Jobs] Saving ${newJobs.length} jobs to IndexedDB`);
await db.saveJobs(newJobs);
jobs.set(newJobs);
console.log('[Jobs] Jobs saved successfully');
} catch (error) {
const message = (error as Error).message;
console.error('[Jobs] Failed to save jobs:', message);
jobsError.set(message);
} finally {
jobsLoading.set(false);
}
}
/**
* FUNCTION: searchJobs
*
* Called: When user types in search box
* Filters jobs locally (no API call)
* Searches: title, company, description
*
* PARAMS:
* - query: string - Search text
*
* RETURNS: Job[] - Matching jobs
*/
export function searchJobs(query: string): Job[] {
if (!query.trim()) {
return get(jobs); // No filter
}
const allJobs = get(jobs);
const lowerQuery = query.toLowerCase();
return allJobs.filter(
(job) =>
job.title.toLowerCase().includes(lowerQuery) ||
job.company.toLowerCase().includes(lowerQuery) ||
(job.description?.toLowerCase().includes(lowerQuery) ?? false)
);
}
/**
* FUNCTION: filterJobsByCategory
*
* Called: When category filter changed
* Filters jobs by category field
*
* PARAMS:
* - category: string - Category to filter by
*
* RETURNS: Job[] - Jobs matching category
*/
export function filterJobsByCategory(category: string): Job[] {
if (!category) {
return get(jobs);
}
return get(jobs).filter((job) => job.category === category);
}
/**
* FUNCTION: getJobById
*
* Called: When viewing single job detail
* Retrieves job from IndexedDB by ID
*
* PARAMS:
* - jobId: string - Job ID
*
* RETURNS: Job | undefined
*/
export async function getJobById(jobId: string): Promise<Job | undefined> {
try {
return await db.getJobById(jobId);
} catch (error) {
console.error('[Jobs] Failed to get job:', error);
return undefined;
}
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* FUNCTION: getUniqueCompanies
*
* Returns list of unique company names from jobs
* Useful for building filter dropdown
*/
export function getUniqueCompanies(): string[] {
const grouped = get(jobsGroupedByCompany);
return Object.keys(grouped).sort();
}
/**
* FUNCTION: getJobsByCompany
*
* Returns all jobs from specific company
*
* PARAMS:
* - company: string - Company name
*
* RETURNS: Job[]
*/
export function getJobsByCompany(company: string): Job[] {
return get(jobs).filter((job) => job.company === company);
}
/**
* FUNCTION: getRecentJobs
*
* Returns jobs posted in last N days
* Sorts by date descending (newest first)
*
* PARAMS:
* - days: number - How many days back
*
* RETURNS: Job[]
*/
export function getRecentJobs(days: number = 7): Job[] {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return get(jobs)
.filter((job) => new Date(job.postedDate) >= cutoff)
.sort((a, b) => new Date(b.postedDate).getTime() - new Date(a.postedDate).getTime());
}
/**
* FUNCTION: clearJobs
*
* Clear all jobs from store and IndexedDB
* Called on logout
*/
export function clearJobs(): void {
jobs.set([]);
jobsLoading.set(false);
jobsError.set(null);
// Also clear from IndexedDB
db.clearAllData().catch((error) => {
console.error('[Jobs] Failed to clear IndexedDB:', error);
});
}
+327
View File
@@ -0,0 +1,327 @@
/**
* STORE: UI State
*
* PURPOSE: Manage global UI state
* CONTENTS: Notifications, modals, loading states
*
* PROVIDES:
* - Notification system (toast-style messages)
* - Modal/dialog state
* - Global loading indicators
* - Error display
*/
import { writable, derived } from 'svelte/store';
// ============================================================================
// TYPES
// ============================================================================
export interface Notification {
id: string;
type: 'success' | 'error' | 'info' | 'warning';
message: string;
duration?: number; // ms, null for permanent
dismissible?: boolean; // default: true
}
export interface UIState {
notifications: Notification[];
modals: {
isOpen: boolean;
type?: string;
data?: unknown;
};
loading: {
global: boolean;
auth: boolean;
jobs: boolean;
files: boolean;
};
}
// ============================================================================
// STORES
// ============================================================================
/**
* Main UI state store
* Contains all UI-related state
*/
export const uiState = writable<UIState>({
notifications: [],
modals: {
isOpen: false,
},
loading: {
global: false,
auth: false,
jobs: false,
files: false,
},
});
/**
* Derived store: Has active notifications
* Useful for showing notification container
*/
export const hasNotifications = derived(
uiState,
($ui) => $ui.notifications.length > 0
);
/**
* Derived store: Is any operation loading
* Useful for global loading spinner
*/
export const isLoading = derived(
uiState,
($ui) => Object.values($ui.loading).some((v) => v)
);
// ============================================================================
// NOTIFICATION FUNCTIONS
// ============================================================================
/**
* FUNCTION: addNotification
*
* Add notification to queue
* Auto-dismisses after duration (default: 5000ms)
*
* PARAMS:
* - message: string - Notification message
* - type: string - Type (success, error, info, warning)
* - duration: number - Auto-dismiss ms (null = permanent)
*
* RETURNS: string - Notification ID
*/
export function addNotification(
message: string,
type: 'success' | 'error' | 'info' | 'warning' = 'info',
duration: number | null = 5000
): string {
const id = `notification-${Date.now()}-${Math.random()}`;
const notification: Notification = {
id,
type,
message,
duration: duration ?? undefined,
dismissible: true,
};
uiState.update((state) => ({
...state,
notifications: [...state.notifications, notification],
}));
// Auto-dismiss if duration specified
if (duration !== null) {
setTimeout(() => {
removeNotification(id);
}, duration);
}
return id;
}
/**
* FUNCTION: removeNotification
*
* Remove notification by ID
*
* PARAMS:
* - id: string - Notification ID
*/
export function removeNotification(id: string): void {
uiState.update((state) => ({
...state,
notifications: state.notifications.filter((n) => n.id !== id),
}));
}
/**
* FUNCTION: clearNotifications
*
* Remove all notifications
*/
export function clearNotifications(): void {
uiState.update((state) => ({
...state,
notifications: [],
}));
}
/**
* FUNCTION: showSuccess
*
* Show success notification
* Auto-dismisses after 5 seconds
*
* PARAMS:
* - message: string - Success message
*
* RETURNS: string - Notification ID
*/
export function showSuccess(message: string): string {
return addNotification(message, 'success', 5000);
}
/**
* FUNCTION: showError
*
* Show error notification
* Persistent (no auto-dismiss) so user can read
*
* PARAMS:
* - message: string - Error message
*
* RETURNS: string - Notification ID
*/
export function showError(message: string): string {
return addNotification(message, 'error', null);
}
/**
* FUNCTION: showInfo
*
* Show info notification
* Auto-dismisses after 5 seconds
*
* PARAMS:
* - message: string - Info message
*
* RETURNS: string - Notification ID
*/
export function showInfo(message: string): string {
return addNotification(message, 'info', 5000);
}
/**
* FUNCTION: showWarning
*
* Show warning notification
* Auto-dismisses after 7 seconds (longer than success)
*
* PARAMS:
* - message: string - Warning message
*
* RETURNS: string - Notification ID
*/
export function showWarning(message: string): string {
return addNotification(message, 'warning', 7000);
}
// ============================================================================
// MODAL FUNCTIONS
// ============================================================================
/**
* FUNCTION: openModal
*
* Open modal/dialog
*
* PARAMS:
* - type: string - Modal type identifier
* - data?: unknown - Data to pass to modal
*/
export function openModal(type: string, data?: unknown): void {
uiState.update((state) => ({
...state,
modals: {
isOpen: true,
type,
data,
},
}));
}
/**
* FUNCTION: closeModal
*
* Close current modal/dialog
*/
export function closeModal(): void {
uiState.update((state) => ({
...state,
modals: {
isOpen: false,
},
}));
}
// ============================================================================
// LOADING STATE FUNCTIONS
// ============================================================================
/**
* FUNCTION: setLoading
*
* Set loading state for specific operation
*
* PARAMS:
* - type: string - Loading type (auth, jobs, files, global)
* - isLoading: boolean - Loading state
*/
export function setLoading(
type: 'global' | 'auth' | 'jobs' | 'files',
isLoading: boolean
): void {
uiState.update((state) => ({
...state,
loading: {
...state.loading,
[type]: isLoading,
},
}));
}
/**
* FUNCTION: startLoading
*
* Convenient helper to start loading
*
* PARAMS:
* - type: string - Loading type
*/
export function startLoading(type: 'global' | 'auth' | 'jobs' | 'files'): void {
setLoading(type, true);
}
/**
* FUNCTION: stopLoading
*
* Convenient helper to stop loading
*
* PARAMS:
* - type: string - Loading type
*/
export function stopLoading(type: 'global' | 'auth' | 'jobs' | 'files'): void {
setLoading(type, false);
}
/**
* FUNCTION: withLoading
*
* Execute async operation with loading state
* Automatically manages loading start/stop
*
* PARAMS:
* - type: string - Loading type
* - operation: () => Promise<T> - Async function
*
* RETURNS: Promise<T>
* THROWS: Original error if operation fails
*/
export async function withLoading<T>(
type: 'global' | 'auth' | 'jobs' | 'files',
operation: () => Promise<T>
): Promise<T> {
try {
startLoading(type);
const result = await operation();
return result;
} finally {
stopLoading(type);
}
}
+217
View File
@@ -0,0 +1,217 @@
/**
* UTILITY: API Request Wrapper
*
* PURPOSE: Centralized API calls with error handling
* FEATURES:
* - Automatic error handling
* - Request/response logging
* - Token injection
* - Retry logic for transient failures
* - Request timeout protection
*/
import { get } from 'svelte/store';
import { accessToken } from '../stores/auth';
import { showError, showInfo } from '../stores/ui';
import { info, error as logError, warn } from './logger';
import { classifyError, shouldRetry, getRetryDelay } from './errorHandler';
const DEFAULT_TIMEOUT = 30000; // 30 seconds
const MAX_RETRIES = 3;
export interface ApiRequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: Record<string, string>;
body?: any;
timeout?: number;
retries?: number;
requiresAuth?: boolean;
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
statusCode?: number;
}
/**
* FUNCTION: apiRequest
*
* Make API request with automatic error handling
*
* PARAMS:
* - url: string - API endpoint
* - options: ApiRequestOptions - Request options
*
* RETURNS: ApiResponse<T>
*/
export async function apiRequest<T>(
url: string,
options: ApiRequestOptions = {}
): Promise<ApiResponse<T>> {
const {
method = 'GET',
headers = {},
body = null,
timeout = DEFAULT_TIMEOUT,
retries = 1,
requiresAuth = false,
} = options;
let attempt = 0;
while (attempt < retries) {
attempt++;
try {
info('API', `${method} ${url}`, { attempt, retries });
// Add auth header if required and available
const finalHeaders = { ...headers };
if (requiresAuth) {
const token = get(accessToken);
if (!token) {
throw new Error('Authentication required but no token available');
}
finalHeaders['Authorization'] = `Bearer ${token}`;
}
// Add content type for POST/PUT/PATCH
if (body && !finalHeaders['Content-Type']) {
finalHeaders['Content-Type'] = 'application/json';
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
// Make request
const response = await fetch(url, {
method,
headers: finalHeaders,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
// Log response
info('API', `${method} ${url} - ${response.status}`, {
statusCode: response.status,
});
// Handle non-OK responses
if (!response.ok) {
let errorData: any;
try {
errorData = await response.json();
} catch {
errorData = { message: response.statusText };
}
const errorMessage = errorData.error || errorData.message || response.statusText;
// Special handling for 401 (auth failure)
if (response.status === 401) {
logError('API', 'Unauthorized (401)', undefined, {
url,
statusCode: 401,
});
showError('Session expired - please log in again');
throw new Error('Unauthorized');
}
throw new Error(`API Error ${response.status}: ${errorMessage}`);
}
// Parse response
const data = await response.json() as T;
info('API', `${method} ${url} - Success`, { dataSize: JSON.stringify(data).length });
return {
success: true,
data,
statusCode: response.status,
};
} catch (err) {
const errorContext = classifyError(err);
// Check if should retry
if (attempt < retries && shouldRetry(errorContext, attempt)) {
const delay = getRetryDelay(attempt);
warn('API', `Retrying ${url} after ${delay}ms`, {
attempt,
maxRetries: retries,
delay,
});
// Wait before retry
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
// Give up after all retries
logError('API', `${method} ${url} - Failed`, err as Error, {
attempt,
maxRetries: retries,
message: errorContext.message,
});
showError(errorContext.userMessage);
return {
success: false,
error: errorContext.message,
statusCode: errorContext.statusCode,
};
}
}
// Should not reach here
return {
success: false,
error: 'Unknown error',
};
}
/**
* FUNCTION: apiGet
* Convenience wrapper for GET requests
*/
export async function apiGet<T>(url: string, options?: ApiRequestOptions): Promise<ApiResponse<T>> {
return apiRequest<T>(url, { ...options, method: 'GET' });
}
/**
* FUNCTION: apiPost
* Convenience wrapper for POST requests
*/
export async function apiPost<T>(
url: string,
body?: any,
options?: ApiRequestOptions
): Promise<ApiResponse<T>> {
return apiRequest<T>(url, { ...options, method: 'POST', body });
}
/**
* FUNCTION: apiPut
* Convenience wrapper for PUT requests
*/
export async function apiPut<T>(
url: string,
body?: any,
options?: ApiRequestOptions
): Promise<ApiResponse<T>> {
return apiRequest<T>(url, { ...options, method: 'PUT', body });
}
/**
* FUNCTION: apiDelete
* Convenience wrapper for DELETE requests
*/
export async function apiDelete<T>(url: string, options?: ApiRequestOptions): Promise<ApiResponse<T>> {
return apiRequest<T>(url, { ...options, method: 'DELETE' });
}
+163
View File
@@ -0,0 +1,163 @@
/**
* CONSTANTS: Application-wide constants
*
* PURPOSE: Centralized configuration values
* USAGE: Import and use in services, components, stores
*
* Rules:
* - No magic numbers or strings in code
* - All constants defined here
* - Change only in one place
*/
// ============================================================================
// APP CONFIGURATION (from environment variables)
// ============================================================================
export const APP_CONFIG = {
// Microsoft OAuth IDs
MICROSOFT_CLIENT_ID: import.meta.env.VITE_PUBLIC_MICROSOFT_CLIENT_ID || 'YOUR_CLIENT_ID',
MICROSOFT_TENANT_ID: import.meta.env.VITE_PUBLIC_MICROSOFT_TENANT_ID || 'common',
REDIRECT_URI: import.meta.env.VITE_PUBLIC_MICROSOFT_REDIRECT_URI || 'http://localhost:5173/#/callback',
// Backend API URL
API_URL: import.meta.env.VITE_PUBLIC_API_URL || 'http://localhost:3005',
};
// ============================================================================
// OAUTH CONFIGURATION
// ============================================================================
export const OAUTH_CONFIG = {
// Microsoft OAuth endpoints
AUTHORIZE_URL: `https://login.microsoftonline.com/${APP_CONFIG.MICROSOFT_TENANT_ID}/oauth2/v2.0/authorize`,
TOKEN_URL: `https://login.microsoftonline.com/${APP_CONFIG.MICROSOFT_TENANT_ID}/oauth2/v2.0/token`,
// Client and redirect
CLIENT_ID: APP_CONFIG.MICROSOFT_CLIENT_ID,
REDIRECT_URI: APP_CONFIG.REDIRECT_URI,
// Scopes required
SCOPES: [
'https://graph.microsoft.com/User.Read',
'https://graph.microsoft.com/Files.Read.All',
'offline_access'
],
// PKCE settings
PKCE_CODE_LENGTH: 43,
PKCE_CHALLENGE_METHOD: 'S256',
};
// ============================================================================
// MICROSOFT GRAPH CONFIGURATION
// ============================================================================
export const GRAPH_CONFIG = {
BASE_URL: 'https://graph.microsoft.com/v1.0',
ENDPOINTS: {
USER_PROFILE: '/me',
DRIVE_ROOT: '/me/drive/root/children',
DRIVE_ITEMS: '/me/drive/items/{id}/children',
},
};
// ============================================================================
// STORAGE CONFIGURATION
// ============================================================================
export const STORAGE_CONFIG = {
DB_NAME: 'jobinfo-app',
DB_VERSION: 1,
STORES: {
JOBS: 'jobs',
FILE_CACHE: 'fileCache',
USER_PROFILE: 'userProfile',
},
CACHE_TTL: 60 * 60 * 1000, // 1 hour
};
// ============================================================================
// TOKEN CONFIGURATION
// ============================================================================
export const TOKEN_CONFIG = {
// How many seconds before expiry to refresh
REFRESH_BUFFER_SECONDS: 5 * 60, // 5 minutes
// Check interval for service worker
REFRESH_CHECK_INTERVAL: 60 * 1000, // 60 seconds
// Max token lifetime before forcing re-auth
MAX_TOKEN_AGE: 24 * 60 * 60 * 1000, // 24 hours (SPA limit)
};
// ============================================================================
// UI CONFIGURATION
// ============================================================================
export const UI_CONFIG = {
// Notification auto-dismiss time
NOTIFICATION_TIMEOUT: 5000,
// Search debounce delay
SEARCH_DEBOUNCE: 300,
// Pagination
ITEMS_PER_PAGE: 20,
};
// ============================================================================
// ERROR MESSAGES
// ============================================================================
export const ERROR_MESSAGES = {
// Auth errors
AUTH_MISSING_CODE: 'Authorization code not provided. Please try signing in again.',
AUTH_INVALID_STATE: 'Invalid state parameter. Possible CSRF attack detected.',
AUTH_EXCHANGE_FAILED: 'Failed to exchange authorization code for token.',
AUTH_USER_NOT_FOUND: 'Could not retrieve user information.',
AUTH_TOKEN_EXPIRED: 'Your session has expired. Please sign in again.',
AUTH_REFRESH_FAILED: 'Failed to refresh access token. Please sign in again.',
// Network errors
NETWORK_ERROR: 'Network request failed. Please check your connection.',
NETWORK_TIMEOUT: 'Request timed out. Please try again.',
// Graph API errors
GRAPH_NOT_FOUND: 'The requested resource was not found.',
GRAPH_FORBIDDEN: 'You do not have permission to access this resource.',
GRAPH_RATE_LIMITED: 'Too many requests. Please wait and try again.',
// Storage errors
STORAGE_FAILED: 'Failed to access local storage.',
STORAGE_QUOTA_EXCEEDED: 'Local storage quota exceeded.',
// General errors
UNEXPECTED_ERROR: 'An unexpected error occurred. Please try again.',
};
// ============================================================================
// SUCCESS MESSAGES
// ============================================================================
export const SUCCESS_MESSAGES = {
LOGIN_SUCCESS: 'Successfully signed in!',
LOGOUT_SUCCESS: 'Signed out successfully.',
TOKEN_REFRESHED: 'Session token refreshed.',
DATA_LOADED: 'Data loaded successfully.',
};
// ============================================================================
// LOGGING CONFIGURATION
// ============================================================================
export const LOG_LEVELS = {
DEBUG: 'DEBUG',
INFO: 'INFO',
WARN: 'WARN',
ERROR: 'ERROR',
};
export const IS_DEVELOPMENT = import.meta.env.MODE === 'development';
export const IS_PRODUCTION = import.meta.env.MODE === 'production';
@@ -0,0 +1,194 @@
/**
* UTILITY: Service Worker Manager
*
* PURPOSE: Register and communicate with Service Worker
* FEATURES:
* - Register Service Worker on app load
* - Send messages to SW
* - Receive messages from SW
* - Handle token refresh via SW
*/
export interface TokenCheckResult {
needsRefresh: boolean;
timeUntilExpiry: number;
}
export interface TokenRefreshResult {
success: boolean;
accessToken?: string;
expiresIn?: number;
error?: string;
}
/**
* FUNCTION: registerServiceWorker
*
* Register the service worker on app load
* Called once from App.svelte onMount
*/
export async function registerServiceWorker(): Promise<ServiceWorkerContainer | null> {
// Service Workers only available in production (HTTPS) or localhost
if (!('serviceWorker' in navigator)) {
console.log('[SW Manager] Service Workers not supported');
return null;
}
try {
console.log('[SW Manager] Registering Service Worker...');
const registration = await navigator.serviceWorker.register('/service-worker.js', {
scope: '/',
});
console.log('[SW Manager] Service Worker registered:', registration);
// Listen for messages from Service Worker
navigator.serviceWorker.addEventListener('message', (event) => {
console.log('[SW Manager] Message from Service Worker:', event.data);
});
return registration;
} catch (error) {
console.error('[SW Manager] Failed to register Service Worker:', error);
return null;
}
}
/**
* FUNCTION: checkTokenExpiry
*
* Ask Service Worker if token needs refresh
*
* PARAMS:
* - tokenExpiry: number - Token expiry timestamp (ms since epoch)
*
* RETURNS: TokenCheckResult
*/
export async function checkTokenExpiry(tokenExpiry: number): Promise<TokenCheckResult> {
if (!navigator.serviceWorker.controller) {
console.log('[SW Manager] Service Worker not active');
return { needsRefresh: false, timeUntilExpiry: tokenExpiry - Date.now() };
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data);
}
};
navigator.serviceWorker.controller?.postMessage(
{
type: 'CHECK_TOKEN',
payload: { tokenExpiry },
},
[channel.port2]
);
// Timeout after 5 seconds
setTimeout(() => reject(new Error('Token check timeout')), 5000);
});
}
/**
* FUNCTION: refreshTokenViaServiceWorker
*
* Ask Service Worker to refresh access token
*
* PARAMS:
* - refreshToken: string - Refresh token
* - apiUrl: string - Backend API URL
*
* RETURNS: TokenRefreshResult
*/
export async function refreshTokenViaServiceWorker(
refreshToken: string,
apiUrl: string
): Promise<TokenRefreshResult> {
if (!navigator.serviceWorker.controller) {
console.log('[SW Manager] Service Worker not active');
return { success: false, error: 'Service Worker not active' };
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data.error) {
resolve({ success: false, error: event.data.error });
} else {
resolve(event.data);
}
};
navigator.serviceWorker.controller?.postMessage(
{
type: 'REFRESH_TOKEN',
payload: { refreshToken, apiUrl },
},
[channel.port2]
);
// Timeout after 10 seconds
setTimeout(() => reject(new Error('Token refresh timeout')), 10000);
});
}
/**
* FUNCTION: getServiceWorkerStatus
*
* Get Service Worker status and version
*
* RETURNS: { active: boolean, version?: string }
*/
export async function getServiceWorkerStatus(): Promise<{ active: boolean; version?: string }> {
if (!navigator.serviceWorker.controller) {
return { active: false };
}
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data);
}
};
navigator.serviceWorker.controller?.postMessage(
{ type: 'GET_STATUS' },
[channel.port2]
);
// Timeout after 5 seconds
setTimeout(() => reject(new Error('Status check timeout')), 5000);
});
}
/**
* FUNCTION: unregisterServiceWorker
*
* Unregister the service worker (for development)
*/
export async function unregisterServiceWorker(): Promise<void> {
if (!('serviceWorker' in navigator)) {
return;
}
try {
const registrations = await navigator.serviceWorker.getRegistrations();
for (const registration of registrations) {
await registration.unregister();
}
console.log('[SW Manager] Service Worker unregistered');
} catch (error) {
console.error('[SW Manager] Failed to unregister Service Worker:', error);
}
}
+155
View File
@@ -0,0 +1,155 @@
/**
* TYPES: TypeScript interfaces and types for the application
*
* PURPOSE: Define all data structures used throughout the app
* LOCATION: Referenced by stores, services, components
*
* STRUCTURE:
* - User types (from Microsoft)
* - Job types (from IndexedDB)
* - File types (from Microsoft Graph)
* - API request/response types
* - Store types
*/
// ============================================================================
// USER TYPES (Microsoft/OAuth)
// ============================================================================
/**
* User object returned from Microsoft Graph /me endpoint
* Fields: id, displayName, mail, preferredLanguage, jobTitle
*/
export interface User {
id: string;
displayName: string;
mail: string;
jobTitle?: string;
mobilePhone?: string;
}
/**
* Auth state stored in store
* Represents current authentication status
*/
export interface AuthState {
user: User | null;
accessToken: string | null;
tokenExpiry: number | null;
isAuthenticated: boolean;
}
// ============================================================================
// JOB TYPES (Device Storage)
// ============================================================================
/**
* Job object stored in IndexedDB
* Fields: id, title, company, location, postedDate, description, url
*/
export interface Job {
id: string;
title: string;
company: string;
location: string;
postedDate: string;
description: string;
url?: string;
category?: string;
salary?: string;
}
// ============================================================================
// FILE TYPES (Microsoft Graph)
// ============================================================================
/**
* File object from Microsoft Graph API
* Fields: id, name, size, modified, webUrl, parentReference
*/
export interface MicrosoftFile {
id: string;
name: string;
size: number;
lastModifiedDateTime: string;
webUrl: string;
folder?: {
childCount: number;
};
}
// ============================================================================
// API REQUEST/RESPONSE TYPES
// ============================================================================
/**
* POST /api/auth/authorize request
* Sent from frontend after OAuth code received
*/
export interface AuthorizeRequest {
code: string;
state: string;
codeVerifier: string;
}
/**
* POST /api/auth/authorize response
* Contains access token and user info
*/
export interface AuthorizeResponse {
accessToken: string;
expiresIn: number;
user: User;
}
/**
* POST /api/auth/refresh response
* New access token from refresh
*/
export interface RefreshTokenResponse {
accessToken: string;
expiresIn: number;
}
// ============================================================================
// ERROR TYPES
// ============================================================================
/**
* Standardized error object
* Used for API errors and application errors
*/
export interface ApiError {
error: string;
message: string;
statusCode?: number;
details?: Record<string, unknown>;
}
// ============================================================================
// STORE TYPES
// ============================================================================
/**
* Jobs store state
* Holds all cached jobs from device storage
*/
export interface JobsState {
items: Job[];
loading: boolean;
error: string | null;
lastUpdated: number | null;
}
/**
* UI store state
* Holds notifications, modals, loading states
*/
export interface UIState {
notification: {
type: 'success' | 'error' | 'info' | 'warning';
message: string;
} | null;
isLoading: boolean;
error: string | null;
}
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{svelte,ts,js}'],
theme: {
extend: {
colors: {
primary: '#2563eb',
secondary: '#64748b'
}
}
},
plugins: []
};
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"alwaysStrict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"$components/*": ["src/components/*"],
"$stores/*": ["src/stores/*"],
"$services/*": ["src/services/*"],
"$lib/*": ["src/lib/*"],
"$utils/*": ["src/utils/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.svelte"],
"exclude": ["node_modules", "dist", "build"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3005',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api')
}
}
},
build: {
target: 'esnext'
// esbuild is the default minifier in Vite 4+
}
});