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
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:
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-primary: #2563eb;
|
||||
--color-secondary: #64748b;
|
||||
--color-error: #dc2626;
|
||||
--color-success: #16a34a;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 || [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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()}`,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{svelte,ts,js}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#2563eb',
|
||||
secondary: '#64748b'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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+
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user