Merge branch 'webui'
@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
@ -0,0 +1,345 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "web",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
"@sveltejs/adapter-node": "^5.3.3",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@types/bun": "^1.3.0",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
||||
|
||||
"@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/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.6", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-XSQB1K7FUU5QP+3lOQmVCE3I0FcbbNvmNT4VJSj93iUjayaARrTQeoRdiYQoftAJBLrR9t2agwAd3ekaTgHNlw=="],
|
||||
|
||||
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
|
||||
|
||||
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.2", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-tCtHJ2BlhSoK4cCs25NMXfV7EALKr0jyasmqVCq3y9cBrKdmJhtsy1iTz36Xhk/O+pDJbzawxF4K6ZblqCnITQ=="],
|
||||
|
||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.6", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ=="],
|
||||
|
||||
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@6.1.1", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ=="],
|
||||
|
||||
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.3.3", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-SRDVuFBkmpKGsA9b0wYaCrrSChq2Yv5Dv8g7WiZcs8E69vdQNRamN0DzQV9/rEixvuRkojATLADNeQ+6FeyVNQ=="],
|
||||
|
||||
"@sveltejs/kit": ["@sveltejs/kit@2.46.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-J1fd80WokLzIm6EAV7z7C2+/C02qVAX645LZomARARTRJkbbJSY1Jln3wtBZYibUB8c9/5Z6xqLAV39VdbtWCQ=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="],
|
||||
|
||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="],
|
||||
|
||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||
|
||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"better-sqlite3": ["better-sqlite3@12.4.1", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ=="],
|
||||
|
||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||
|
||||
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="],
|
||||
|
||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["esrap@2.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||
|
||||
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"node-abi": ["node-abi@3.78.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="],
|
||||
|
||||
"rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||
|
||||
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||
|
||||
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"svelte": ["svelte@5.39.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-8MxWVm2+3YwrFbPaxOlT1bbMi6OTenrAgks6soZfiaS8Fptk4EVyRIFhJc3RpO264EeSNwgjWAdki0ufg4zkGw=="],
|
||||
|
||||
"svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="],
|
||||
|
||||
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||
|
||||
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="],
|
||||
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "bun ./build/index.js",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.1.0",
|
||||
"@sveltejs/adapter-node": "^5.3.3",
|
||||
"@sveltejs/kit": "^2.43.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.0",
|
||||
"@types/bun": "^1.3.0",
|
||||
"svelte": "^5.39.5",
|
||||
"svelte-check": "^4.3.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.1.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.4.1"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
/* See https://svelte.dev/docs/kit/types#app.d.ts */
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
userId?: string | null;
|
||||
}
|
||||
// interface Error {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
export {};
|
||||
@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,124 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { sha256Hex } from '$lib/server/crypto';
|
||||
import { isProd, sessionIdleTtlMs, isDev, DEV_BYPASS_AUTH, DEV_DEFAULT_USER } from '$lib/server/env';
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Bypass de auth en desarrollo (siempre activo en dev para entorno de pruebas)
|
||||
const bypass = isDev();
|
||||
if (bypass) {
|
||||
const qp = event.url.searchParams.get('__as')?.trim();
|
||||
const current = event.cookies.get('dev_as') || '';
|
||||
const user = qp && qp.length ? qp : (current || DEV_DEFAULT_USER);
|
||||
if (qp && qp.length && qp !== current) {
|
||||
event.cookies.set('dev_as', user, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd(),
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 días
|
||||
});
|
||||
}
|
||||
event.locals.userId = user;
|
||||
}
|
||||
// Sesión por cookie 'sid'
|
||||
const isLogout = event.url.pathname === '/api/logout' || event.url.pathname.startsWith('/api/logout/');
|
||||
const sid = event.cookies.get('sid');
|
||||
if (!bypass && sid) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const hash = await sha256Hex(sid);
|
||||
|
||||
// Validar sesión vigente
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT user_id FROM web_sessions
|
||||
WHERE session_hash = ?
|
||||
AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(hash) as { user_id: string } | undefined;
|
||||
|
||||
if (row?.user_id) {
|
||||
event.locals.userId = row.user_id;
|
||||
|
||||
// Renovar expiración por inactividad y last_seen_at
|
||||
const newExpIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
|
||||
try {
|
||||
db.prepare(
|
||||
`UPDATE web_sessions
|
||||
SET last_seen_at = strftime('%Y-%m-%d %H:%M:%f','now'),
|
||||
expires_at = ?
|
||||
WHERE session_hash = ?`
|
||||
).run(newExpIso, hash);
|
||||
} catch {
|
||||
// Si no existe last_seen_at en el esquema, al menos renovar expires_at
|
||||
try {
|
||||
db.prepare(
|
||||
`UPDATE web_sessions
|
||||
SET expires_at = ?
|
||||
WHERE session_hash = ?`
|
||||
).run(newExpIso, hash);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Refrescar cookie (idle) excepto durante /api/logout
|
||||
if (!isLogout) {
|
||||
event.cookies.set('sid', sid, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd(),
|
||||
maxAge: Math.floor(sessionIdleTtlMs / 1000)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Sesión inválida/expirada
|
||||
event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() });
|
||||
}
|
||||
} catch {
|
||||
// En caso de error de DB, no romper la request; continuar sin sesión
|
||||
}
|
||||
}
|
||||
|
||||
const response = await resolve(event);
|
||||
|
||||
// Cabeceras de seguridad y caché: solo para HTML
|
||||
try {
|
||||
const ct = response.headers.get('content-type') || '';
|
||||
if (ct.includes('text/html')) {
|
||||
response.headers.set('cache-control', 'no-store');
|
||||
response.headers.set('X-Frame-Options', 'DENY');
|
||||
response.headers.set('Referrer-Policy', 'no-referrer');
|
||||
response.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Mitigar aviso de “preload no usado” en CSS:
|
||||
// Filtrar del header Link los preloads con as=style (dejamos modulepreload para JS).
|
||||
const link = response.headers.get('Link') || response.headers.get('link');
|
||||
if (link) {
|
||||
const filtered = link
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((seg) => !/;\s*as=style\b/i.test(seg));
|
||||
if (filtered.length > 0) {
|
||||
response.headers.set('Link', filtered.join(', '));
|
||||
} else {
|
||||
response.headers.delete('Link');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignorar si la implementación de Response no permite set()
|
||||
}
|
||||
// Indicador de bypass en respuestas (útil en dev)
|
||||
try {
|
||||
if (bypass) {
|
||||
response.headers.set('X-Dev-Auth', 'bypass');
|
||||
}
|
||||
} catch {}
|
||||
return response;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 100.6" style="enable-background:new 0 0 122.88 100.6" xml:space="preserve"><style type="text/css">.st0{fill:#272727;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#D8453E;}</style><g><path class="st0" d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"/><path class="st1" d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 106.86 122.88" style="enable-background:new 0 0 106.86 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M39.62,64.58c-1.46,0-2.64-1.41-2.64-3.14c0-1.74,1.18-3.14,2.64-3.14h34.89c1.46,0,2.64,1.41,2.64,3.14 c0,1.74-1.18,3.14-2.64,3.14H39.62L39.62,64.58z M46.77,116.58c1.74,0,3.15,1.41,3.15,3.15c0,1.74-1.41,3.15-3.15,3.15H7.33 c-2.02,0-3.85-0.82-5.18-2.15C0.82,119.4,0,117.57,0,115.55V7.33c0-2.02,0.82-3.85,2.15-5.18C3.48,0.82,5.31,0,7.33,0h90.02 c2.02,0,3.85,0.83,5.18,2.15c1.33,1.33,2.15,3.16,2.15,5.18v50.14c0,1.74-1.41,3.15-3.15,3.15c-1.74,0-3.15-1.41-3.15-3.15V7.33 c0-0.28-0.12-0.54-0.31-0.72c-0.19-0.19-0.44-0.31-0.72-0.31H7.33c-0.28,0-0.54,0.12-0.73,0.3C6.42,6.8,6.3,7.05,6.3,7.33v108.21 c0,0.28,0.12,0.54,0.3,0.72c0.19,0.19,0.45,0.31,0.73,0.31H46.77L46.77,116.58z M98.7,74.34c-0.51-0.49-1.1-0.72-1.78-0.71 c-0.68,0.01-1.26,0.27-1.74,0.78l-3.91,4.07l10.97,10.59l3.95-4.11c0.47-0.48,0.67-1.1,0.66-1.78c-0.01-0.67-0.25-1.28-0.73-1.74 L98.7,74.34L98.7,74.34z M78.21,114.01c-1.45,0.46-2.89,0.94-4.33,1.41c-1.45,0.48-2.89,0.97-4.33,1.45 c-3.41,1.12-5.32,1.74-5.72,1.85c-0.39,0.12-0.16-1.48,0.7-4.81l2.71-10.45l0,0l20.55-21.38l10.96,10.55L78.21,114.01L78.21,114.01 z M39.62,86.95c-1.46,0-2.65-1.43-2.65-3.19c0-1.76,1.19-3.19,2.65-3.19h17.19c1.46,0,2.65,1.43,2.65,3.19 c0,1.76-1.19,3.19-2.65,3.19H39.62L39.62,86.95z M39.62,42.26c-1.46,0-2.64-1.41-2.64-3.14c0-1.74,1.18-3.14,2.64-3.14h34.89 c1.46,0,2.64,1.41,2.64,3.14c0,1.74-1.18,3.14-2.64,3.14H39.62L39.62,42.26z M24.48,79.46c2.06,0,3.72,1.67,3.72,3.72 c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72C20.76,81.13,22.43,79.46,24.48,79.46L24.48,79.46z M24.48,57.44 c2.06,0,3.72,1.67,3.72,3.72c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72C20.76,59.11,22.43,57.44,24.48,57.44 L24.48,57.44z M24.48,35.42c2.06,0,3.72,1.67,3.72,3.72c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72 C20.76,37.08,22.43,35.42,24.48,35.42L24.48,35.42z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.01 122.88"><defs><style>.cls-1{fill-rule:evenodd;}</style></defs><title>emergency-exit</title><path class="cls-1" d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 91.99" style="enable-background:new 0 0 122.88 91.99" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve"><g><path fill-rule="evenodd" clip-rule="evenodd" fill="#6BBE66" d="M48,0c26.51,0,48,21.49,48,48S74.51,96,48,96S0,74.51,0,48 S21.49,0,48,0L48,0z M26.764,49.277c0.644-3.734,4.906-5.813,8.269-3.79c0.305,0.182,0.596,0.398,0.867,0.646l0.026,0.025 c1.509,1.446,3.2,2.951,4.876,4.443l1.438,1.291l17.063-17.898c1.019-1.067,1.764-1.757,3.293-2.101 c5.235-1.155,8.916,5.244,5.206,9.155L46.536,63.366c-2.003,2.137-5.583,2.332-7.736,0.291c-1.234-1.146-2.576-2.312-3.933-3.489 c-2.35-2.042-4.747-4.125-6.701-6.187C26.993,52.809,26.487,50.89,26.764,49.277L26.764,49.277z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 835 B |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 121.2 122.88" style="enable-background:new 0 0 121.2 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 99.56" style="enable-background:new 0 0 122.88 99.56" xml:space="preserve"><style type="text/css">.st0{fill:#393939;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#38AE48;}</style><g><path class="st0" d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"/><path class="st1" d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 506.47"><path fill-rule="nonzero" d="M294.23 17.11c0-9.42 9.3-17.11 20.88-17.11 11.57 0 20.86 7.64 20.86 17.11v74.84c0 9.42-9.29 17.11-20.86 17.11-11.58 0-20.88-7.64-20.88-17.11V17.11zm119.98 304.68 14.12 14.1c3.3 3.32 3.3 8.86 0 12.18l-24.21 24.21 24.21 24.21c3.32 3.32 3.32 8.86 0 12.18l-14.11 14.1c-3.31 3.32-8.82 3.35-12.17 0l-24.21-24.21-24.24 24.23c-3.3 3.3-8.82 3.34-12.16 0l-14.11-14.11c-3.35-3.35-3.35-8.83 0-12.17l24.23-24.23-24.21-24.23c-3.35-3.34-3.35-8.81 0-12.16l14.1-14.1c3.35-3.35 8.86-3.32 12.16 0L377.84 346l24.21-24.21c3.35-3.35 8.89-3.29 12.16 0zm-36.4-83.69c37.02 0 70.6 15.04 94.88 39.32C496.96 301.69 512 335.24 512 372.3c0 36.97-15.04 70.56-39.34 94.83-24.25 24.3-57.8 39.34-94.85 39.34-37.03 0-70.56-15.04-94.84-39.3-24.32-24.27-39.34-57.85-39.34-94.87 0-37.06 15.04-70.61 39.31-94.88l.69-.64c24.24-23.9 57.53-38.68 94.18-38.68zm78.75 55.44c-20.15-20.14-48-32.61-78.75-32.61-30.5 0-58.14 12.25-78.19 32.02l-.55.59c-20.14 20.15-32.62 48.01-32.62 78.76 0 30.74 12.46 58.6 32.61 78.75 20.1 20.13 47.98 32.6 78.75 32.6 30.75 0 58.6-12.47 78.75-32.62 20.14-20.08 32.61-47.95 32.61-78.73 0-30.75-12.47-58.61-32.61-78.76zM56.81 242.28c-1.18 0-2.24-5.2-2.24-11.57 0-6.39.93-11.54 2.24-11.54h56.94c1.18 0 2.24 5.2 2.24 11.54 0 6.38-.93 11.57-2.24 11.57H56.81zm90.77 0c-1.18 0-2.24-5.2-2.24-11.57 0-6.39.93-11.54 2.24-11.54h56.94c1.18 0 2.24 5.2 2.24 11.54 0 6.38-.93 11.57-2.24 11.57h-56.94zm90.77 0c-1.18 0-2.24-5.2-2.24-11.57 0-6.39.93-11.54 2.24-11.54h56.94c1.18 0 2.24 5.15 2.24 11.49-5.7 3.55-11.19 7.44-16.42 11.62h-42.76zM56.94 308.51c-1.19 0-2.23-5.2-2.23-11.57 0-6.38.92-11.58 2.23-11.58h56.93c1.19 0 2.25 5.2 2.25 11.58 0 6.37-.94 11.57-2.25 11.57H56.94zm90.77 0c-1.19 0-2.23-5.2-2.23-11.57 0-6.38.92-11.58 2.23-11.58h56.93c1.19 0 2.25 5.2 2.25 11.58 0 6.37-.94 11.57-2.25 11.57h-56.93zm-90.63 66.28c-1.19 0-2.25-5.2-2.25-11.59 0-6.36.92-11.57 2.25-11.57h56.93c1.17 0 2.23 5.21 2.23 11.57 0 6.39-.92 11.59-2.23 11.59H57.08zm90.77 0c-1.19 0-2.25-5.2-2.25-11.59 0-6.36.92-11.57 2.25-11.57h56.93c1.17 0 2.23 5.21 2.23 11.57 0 6.39-.92 11.59-2.23 11.59h-56.93zM106.82 17.11c0-9.42 9.3-17.11 20.86-17.11 11.59 0 20.88 7.64 20.88 17.11v74.84c0 9.42-9.34 17.11-20.88 17.11-11.56 0-20.86-7.64-20.86-17.11V17.11zM22.98 163.63h397.38V77.46c0-2.94-1.19-5.53-3.09-7.43-1.89-1.9-4.6-3.08-7.42-3.08h-38.1c-6.38 0-11.58-5.2-11.58-11.57 0-6.38 5.2-11.58 11.58-11.58h38.1c9.32 0 17.68 3.76 23.82 9.88 6.12 6.14 9.87 14.5 9.87 23.82v136.81c-7.6-2.61-15.41-4.73-23.43-6.29v-21.37h.25H22.98V409.8c0 2.95 1.18 5.53 3.08 7.43 1.9 1.9 4.61 3.08 7.45 3.08h188.84c2.14 8.02 4.85 15.83 8.11 23.36H33.71c-9.3 0-17.69-3.76-23.82-9.89C3.77 427.71 0 419.35 0 410V77.55c0-9.29 3.77-17.7 9.89-23.82 6.13-6.13 14.49-9.89 23.82-9.89h40.68c6.37 0 11.57 5.2 11.57 11.57 0 6.39-5.2 11.59-11.57 11.59H33.71c-2.96 0-5.53 1.18-7.43 3.08-1.9 1.9-3.08 4.59-3.08 7.42v86.17h-.22v-.04zm158.95-96.68c-6.39 0-11.57-5.2-11.57-11.57 0-6.38 5.18-11.58 11.57-11.58h77.55c6.37 0 11.57 5.2 11.57 11.58 0 6.37-5.2 11.57-11.57 11.57h-77.55z"/></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="105.765px" height="122.88px" viewBox="0 0 105.765 122.88" enable-background="new 0 0 105.765 122.88" xml:space="preserve"><g><path d="M82.872,90.81c-2.983-8.16-7.707-14.175-13.283-18.06c-3.772-2.629-7.914-4.284-12.133-4.97 c-4.236-0.686-8.583-0.408-12.747,0.828C35.573,71.323,27.33,78.716,22.903,90.81H82.872L82.872,90.81z M20.618,27.21h64.535 c0.346-2.922,1.154-13.713,1.119-16.995H19.497C19.462,13.498,20.27,24.288,20.618,27.21L20.618,27.21L20.618,27.21z M0.91,112.665 h9.567C10.222,85.12,22.648,68.03,38.027,61.466C22.637,54.9,10.205,37.79,10.478,10.214l-9.567,0c-0.501,0-0.909-0.46-0.909-1.025 L0,1.024C0,0.46,0.409,0,0.91,0h103.944c0.5,0,0.91,0.46,0.91,1.024v8.164c0,0.563-0.41,1.024-0.91,1.024h-9.57 c0.225,23.214-8.581,39.038-20.546,47.376c-2.188,1.522-4.543,2.832-6.994,3.873c2.446,1.049,4.81,2.354,6.992,3.88 c11.955,8.332,20.756,24.139,20.546,47.321l9.572,0.001c0.5,0,0.91,0.463,0.91,1.026v8.162c0,0.564-0.41,1.027-0.91,1.027H0.91 c-0.501,0-0.909-0.463-0.909-1.026v-8.162C0.001,113.128,0.41,112.665,0.91,112.665L0.91,112.665L0.91,112.665z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 406.6"><path d="M334.1 1.64a202.504 202.504 0 0 1 135.16 77.02c68.84 88.6 52.82 216.19-35.78 285.03-.08.05-.14.11-.22.18-88.57 68.82-216.15 52.81-284.97-35.76-.04-.06-.09-.12-.14-.17A204.822 204.822 0 0 1 125.31 291a168.69 168.69 0 0 0 37.79-5.42 172.61 172.61 0 0 0 13.55 20.29c56.7 72.81 161.67 85.86 234.46 29.15 72.8-56.69 85.84-161.66 29.15-234.46-40.28-51.71-107.08-75.09-170.82-59.79a171.08 171.08 0 0 0-21.88-31.29c2.46-.8 4.95-1.51 7.46-2.21 25.77-7.13 52.69-9.03 79.19-5.63h-.11zM0 129.16v-15.4C3.97 50.8 56.26.95 120.21.92h.05c66.58-.01 120.55 53.93 120.59 120.51.03 66.58-53.93 120.56-120.51 120.59C56.33 242.04 3.97 192.17 0 129.16zm106.56 31.56h27.62v24.45h-27.62v-24.45zm27.6-14.21h-27.6c-2.75-33.56-8.53-32.84-8.53-66.35 0-12.37 10.03-22.39 22.39-22.39 12.36 0 22.4 10.02 22.4 22.39 0 33.49-5.85 32.83-8.66 66.35zm163.46-42c1.24-9.88 10.24-16.88 20.09-15.64h.04c9.82 1.32 16.73 10.32 15.46 20.13l-11.7 94.09 65.06 50.55c7.85 6.1 9.3 17.4 3.2 25.28a18.011 18.011 0 0 1-11.95 6.82c-4.73.62-9.51-.68-13.26-3.62l-72.82-56.61a17.818 17.818 0 0 1-5.79-7.08 18.336 18.336 0 0 1-1.46-9.67l13.13-104.2v-.05z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 112.9"><title>time-period</title><path d="M35.69,101.21a24.08,24.08,0,0,0-4.23-11.35c-2.27-3.17-5.22-5.33-8.32-5.33s-6.06,2.16-8.33,5.33a24.08,24.08,0,0,0-4.23,11.35Zm78.39-73.63a4.17,4.17,0,0,0-7.37,3.81,4.68,4.68,0,0,0,.37.7,44,44,0,0,1,3.6,6.74,4.17,4.17,0,0,0,7.94-2.29,4.32,4.32,0,0,0-.3-1,52.05,52.05,0,0,0-4.24-7.93ZM107.14,16.5a4.63,4.63,0,0,1-3.23,5.18L91.54,25.46a4.63,4.63,0,1,1-2.69-8.86L90,16.24A47,47,0,0,0,22.46,44.49H13.84A55.33,55.33,0,0,1,94.7,9.33l-1.16-3A4.64,4.64,0,1,1,102.22,3l4.62,12.09a4.81,4.81,0,0,1,.3,1.42ZM67.6,104.55a53.52,53.52,0,0,0,9.43-.87,4.17,4.17,0,0,1,1,8.25,61.44,61.44,0,0,1-7.38.94c-1.31.06-3,0-4.34,0a55.19,55.19,0,0,1-10.91-1.33V103a46.85,46.85,0,0,0,12.15,1.59Zm23.25-6a4.17,4.17,0,1,0,4.09,7.26,55.27,55.27,0,0,0,7.46-5.06,4.17,4.17,0,0,0-3.89-7.21,4.07,4.07,0,0,0-1.34.73,47.39,47.39,0,0,1-6.32,4.28Zm16.42-15.64a4.16,4.16,0,1,0,7.06,4.41,55.51,55.51,0,0,0,4.15-8,4.17,4.17,0,0,0-7.15-4.14,4.11,4.11,0,0,0-.54.93,46,46,0,0,1-3.52,6.79Zm7.13-21.62a4.17,4.17,0,0,0,8.16,1.46,3.91,3.91,0,0,0,.15-.83,56.09,56.09,0,0,0,0-9,4.16,4.16,0,1,0-8.3.69,47.78,47.78,0,0,1,0,7.66ZM59.12,35a4.29,4.29,0,0,1,8.57,0V61.09l17.84,7.85a4.28,4.28,0,1,1-3.44,7.83L61.91,67.9a4.29,4.29,0,0,1-2.79-4V35ZM12.59,70.51h21.1a20.92,20.92,0,0,0,2-7H10.56a20.7,20.7,0,0,0,2,7ZM2.47,105.83a2.09,2.09,0,1,1,0-4.1H5.55a28.67,28.67,0,0,1,5.13-14.44,19.38,19.38,0,0,1,6.1-5.67,18.41,18.41,0,0,1-6.17-5.21,24.83,24.83,0,0,1-5.07-14H2.61a2.09,2.09,0,1,1,0-4.1H43.93a2.09,2.09,0,1,1,0,4.1h-3.2a24.83,24.83,0,0,1-5.07,14,18.41,18.41,0,0,1-6.17,5.21,19.38,19.38,0,0,1,6.1,5.67,28.67,28.67,0,0,1,5.13,14.44H43.8a2.09,2.09,0,1,1,0,4.1H2.47Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@ -0,0 +1,111 @@
|
||||
import { getDb } from './db';
|
||||
import { randomTokenBase64Url, sha256Hex } from './crypto';
|
||||
import { WEB_BASE_URL } from './env';
|
||||
|
||||
export type CalendarTokenType = 'personal' | 'group' | 'aggregate';
|
||||
|
||||
function toIsoSql(d: Date = new Date()): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
function requireBaseUrl(): string {
|
||||
const base = (WEB_BASE_URL || '').trim();
|
||||
if (!base) {
|
||||
throw new Error('[calendar-tokens] WEB_BASE_URL no está configurado');
|
||||
}
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function buildCalendarIcsUrl(type: CalendarTokenType, token: string): string {
|
||||
const base = requireBaseUrl();
|
||||
const segment = type === 'personal' ? 'personal' : type === 'group' ? 'group' : 'aggregate';
|
||||
return `${base}/ics/${segment}/${token}.ics`;
|
||||
}
|
||||
|
||||
export async function findActiveToken(
|
||||
type: CalendarTokenType,
|
||||
userId: string,
|
||||
groupId?: string | null
|
||||
): Promise<{
|
||||
id: number;
|
||||
type: CalendarTokenType;
|
||||
user_id: string;
|
||||
group_id: string | null;
|
||||
token_hash: string;
|
||||
token_plain: string | null;
|
||||
created_at: string;
|
||||
revoked_at: string | null;
|
||||
last_used_at: string | null;
|
||||
} | null> {
|
||||
const db = await getDb();
|
||||
const sql = groupId
|
||||
? `
|
||||
SELECT id, type, user_id, group_id, token_hash, token_plain, created_at, revoked_at, last_used_at
|
||||
FROM calendar_tokens
|
||||
WHERE type = ? AND user_id = ? AND group_id = ? AND revoked_at IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
: `
|
||||
SELECT id, type, user_id, group_id, token_hash, token_plain, created_at, revoked_at, last_used_at
|
||||
FROM calendar_tokens
|
||||
WHERE type = ? AND user_id = ? AND group_id IS NULL AND revoked_at IS NULL
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const stmt = db.prepare(sql);
|
||||
const row = groupId
|
||||
? stmt.get(type, userId, groupId)
|
||||
: stmt.get(type, userId);
|
||||
return (row as any) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea un nuevo token ICS y devuelve la URL completa (no se guarda el token en claro).
|
||||
* Lanza si existe una entrada activa y se viola la unicidad; usar findActiveToken antes si quieres evitar error.
|
||||
*/
|
||||
export async function createCalendarTokenUrl(
|
||||
type: CalendarTokenType,
|
||||
userId: string,
|
||||
groupId?: string | null
|
||||
): Promise<{ url: string; token: string; id: number }> {
|
||||
const db = await getDb();
|
||||
|
||||
const token = randomTokenBase64Url(32);
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const createdAt = toIsoSql(new Date());
|
||||
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO calendar_tokens (type, user_id, group_id, token_hash, token_plain, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
const res = insert.run(type, userId, groupId ?? null, tokenHash, token, createdAt);
|
||||
const id = Number(res.lastInsertRowid || 0);
|
||||
|
||||
return { url: buildCalendarIcsUrl(type, token), token, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoca el token activo (si existe) y crea uno nuevo. Devuelve la nueva URL completa.
|
||||
*/
|
||||
export async function rotateCalendarTokenUrl(
|
||||
type: CalendarTokenType,
|
||||
userId: string,
|
||||
groupId?: string | null
|
||||
): Promise<{ url: string; token: string; id: number; revoked: number | null }> {
|
||||
const db = await getDb();
|
||||
const now = toIsoSql(new Date());
|
||||
|
||||
const existing = await findActiveToken(type, userId, groupId ?? null);
|
||||
let revoked: number | null = null;
|
||||
if (existing) {
|
||||
db.prepare(`UPDATE calendar_tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`).run(
|
||||
now,
|
||||
existing.id
|
||||
);
|
||||
revoked = existing.id;
|
||||
}
|
||||
|
||||
const created = await createCalendarTokenUrl(type, userId, groupId ?? null);
|
||||
return { ...created, revoked };
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { randomTokenBase64Url, sha256Hex } from '../../../../../src/utils/crypto';
|
||||
@ -0,0 +1,165 @@
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { resolveDbAbsolutePath, isDev, DEV_AUTOSEED_DB, DEV_DEFAULT_USER } from './env';
|
||||
|
||||
function applyDefaultPragmas(instance: any): void {
|
||||
try {
|
||||
instance.exec(`PRAGMA busy_timeout = 5000;`);
|
||||
// Intentar activar WAL (si no es soportado, SQLite devolverá 'memory' u otro modo)
|
||||
try {
|
||||
if (typeof instance.query === 'function') {
|
||||
instance.query(`PRAGMA journal_mode = WAL`)?.get?.();
|
||||
} else {
|
||||
instance.prepare?.(`PRAGMA journal_mode = WAL`)?.get?.();
|
||||
}
|
||||
} catch {}
|
||||
instance.exec(`PRAGMA synchronous = NORMAL;`);
|
||||
instance.exec(`PRAGMA wal_autocheckpoint = 1000;`);
|
||||
// Asegurar claves foráneas siempre activas
|
||||
instance.exec(`PRAGMA foreign_keys = ON;`);
|
||||
} catch (e) {
|
||||
console.warn('[web/db] No se pudieron aplicar PRAGMAs (WAL, busy_timeout...):', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intenta cargar un constructor de Database compatible:
|
||||
* - En Bun (SSR nativo): bun:sqlite
|
||||
* - En Node (Vite dev SSR): better-sqlite3
|
||||
*/
|
||||
async function importSqliteDatabase(): Promise<any> {
|
||||
// En desarrollo (Vite SSR), cargar better-sqlite3 vía require de Node para mantener el contexto CJS
|
||||
if (import.meta.env.DEV) {
|
||||
const modModule: any = await import('node:module');
|
||||
const require = modModule.createRequire(import.meta.url);
|
||||
const mod = require('better-sqlite3');
|
||||
return (mod as any).default || (mod as any).Database || mod;
|
||||
}
|
||||
// En producción (Bun en runtime), usar bun:sqlite nativo
|
||||
const mod: any = await import('bun:sqlite');
|
||||
return (mod as any).Database || (mod as any).default || mod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre la BD compartida. En desarrollo, si el archivo no existe y DEV_AUTOSEED_DB=true,
|
||||
* inicializa el esquema (migraciones) y siembra datos de demo.
|
||||
* Nota: usa bun:sqlite si está disponible; en SSR Node usa better-sqlite3.
|
||||
*/
|
||||
async function openDb(filename: string = 'tasks.db'): Promise<any> {
|
||||
const absolutePath = resolveDbAbsolutePath(filename);
|
||||
const firstCreate = !existsSync(absolutePath);
|
||||
|
||||
// Crear directorio padre si no existe
|
||||
try {
|
||||
mkdirSync(dirname(absolutePath), { recursive: true });
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'EEXIST') throw err;
|
||||
}
|
||||
|
||||
const DatabaseCtor = await importSqliteDatabase();
|
||||
const instance = new DatabaseCtor(absolutePath);
|
||||
applyDefaultPragmas(instance);
|
||||
|
||||
// Auto-inicialización de esquema en desarrollo si falta y seed opcional
|
||||
if (isDev()) {
|
||||
// ¿Existe la tabla principal?
|
||||
let hasTasksTable = false;
|
||||
try {
|
||||
instance.prepare(`SELECT 1 FROM tasks LIMIT 1`).get();
|
||||
hasTasksTable = true;
|
||||
} catch {}
|
||||
|
||||
// Si no existe el esquema, aplicar inicialización/migraciones
|
||||
if (!hasTasksTable) {
|
||||
const isBun = typeof (globalThis as any).Bun !== 'undefined';
|
||||
|
||||
if (isBun) {
|
||||
// En Bun podemos reutilizar initializeDatabase del repo principal
|
||||
try {
|
||||
const dbModule = await import('../../../../../src/db');
|
||||
if (typeof (dbModule as any).initializeDatabase === 'function') {
|
||||
(dbModule as any).initializeDatabase(instance);
|
||||
hasTasksTable = true;
|
||||
console.info('[web/db] DEV: esquema inicializado (Bun initializeDatabase).');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[web/db] No se pudo ejecutar initializeDatabase en dev (Bun):', e);
|
||||
}
|
||||
} else {
|
||||
// En SSR Node: aplicar migraciones directamente con compat para .query
|
||||
try {
|
||||
const mod = await import('../../../../../src/db/migrations/index.ts');
|
||||
const list = (mod as any).migrations as any[];
|
||||
const compat: any = instance;
|
||||
if (typeof compat.query !== 'function') {
|
||||
compat.query = (sql: string) => ({
|
||||
all: () => compat.prepare(sql).all(),
|
||||
get: () => compat.prepare(sql).get()
|
||||
});
|
||||
}
|
||||
try { compat.exec?.(`PRAGMA foreign_keys = ON;`); } catch {}
|
||||
for (const m of list) {
|
||||
try {
|
||||
await (m.up as any)(compat);
|
||||
} catch (e) {
|
||||
console.warn('[web/db] Error aplicando migración en dev (Node):', (m as any)?.name ?? '(sin nombre)', e);
|
||||
}
|
||||
}
|
||||
// Verificar de nuevo
|
||||
try {
|
||||
compat.prepare(`SELECT 1 FROM tasks LIMIT 1`).get();
|
||||
hasTasksTable = true;
|
||||
console.info('[web/db] DEV: esquema inicializado (migraciones aplicadas en Node).');
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
console.warn('[web/db] No se pudieron aplicar migraciones en dev (Node):', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed de datos de demo si la tabla está vacía (por defecto habilitado en dev)
|
||||
try {
|
||||
let count = 0;
|
||||
try {
|
||||
const row = instance.prepare(`SELECT COUNT(1) AS c FROM tasks`).get() as any;
|
||||
count = Number(row?.c ?? 0);
|
||||
} catch {
|
||||
// Si aún no existe la tabla, no seedear
|
||||
count = 0;
|
||||
}
|
||||
|
||||
const shouldSeed = (typeof DEV_AUTOSEED_DB === 'boolean' ? DEV_AUTOSEED_DB : true);
|
||||
if (count === 0 && shouldSeed) {
|
||||
console.info('[web/db] DEV: tabla tasks vacía; iniciando seed de demo...');
|
||||
try {
|
||||
const seed = await import('./dev-seed');
|
||||
if (typeof (seed as any).seedDev === 'function') {
|
||||
await (seed as any).seedDev(instance, DEV_DEFAULT_USER);
|
||||
console.info('[web/db] DEV: seed de demo completado.');
|
||||
} else {
|
||||
console.warn('[web/db] DEV: módulo dev-seed sin función seedDev; omitiendo seed.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[web/db] DEV: no se pudo cargar/ejecutar dev-seed; omitiendo seed. Error:', e);
|
||||
}
|
||||
} else {
|
||||
console.info(`[web/db] DEV: seed no aplicado (count=${count}, DEV_AUTOSEED_DB=${shouldSeed}).`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[web/db] DEV: error al evaluar seed de demo:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
let _db: any | null = null;
|
||||
|
||||
/**
|
||||
* Devuelve una única instancia compartida (lazy) de la BD.
|
||||
*/
|
||||
export async function getDb(filename: string = 'tasks.db'): Promise<any> {
|
||||
if (_db) return _db;
|
||||
_db = await openDb(filename);
|
||||
return _db;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import { join, resolve } from 'path';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
/**
|
||||
* Resuelve la ruta absoluta al archivo de la base de datos SQLite compartida.
|
||||
* Prioridad:
|
||||
* 1) DB_PATH (ruta completa al archivo)
|
||||
* 2) DATA_DIR + filename (en prod por defecto /app/data; en dev por defecto ./tmp)
|
||||
*/
|
||||
export function resolveDbAbsolutePath(filename: string = 'tasks.db'): string {
|
||||
const dbPathEnv = (env.DB_PATH || '').trim();
|
||||
if (dbPathEnv) {
|
||||
return resolve(dbPathEnv);
|
||||
}
|
||||
const isProdEnv = String(env.NODE_ENV || 'development').trim().toLowerCase() === 'production';
|
||||
const dataDir = env.DATA_DIR ? String(env.DATA_DIR) : (isProdEnv ? '/app/data' : 'tmp');
|
||||
return resolve(join(dataDir, filename));
|
||||
}
|
||||
|
||||
export const WEB_BASE_URL = (env.WEB_BASE_URL || '').trim();
|
||||
export const COOKIE_SECRET = (env.COOKIE_SECRET || '').trim();
|
||||
|
||||
const SESSION_IDLE_TTL_MIN = Number(env.SESSION_IDLE_TTL_MIN || 120);
|
||||
export const sessionIdleTtlMs = Math.max(1, Math.floor(SESSION_IDLE_TTL_MIN)) * 60 * 1000;
|
||||
|
||||
export const NODE_ENV = (env.NODE_ENV || 'development').trim().toLowerCase();
|
||||
export const isProd = () => NODE_ENV === 'production';
|
||||
export const isDev = () => NODE_ENV === 'development';
|
||||
|
||||
// Flags de desarrollo (solo en entornos no productivos)
|
||||
const toBool = (v: string) => ['1', 'true', 'yes', 'on'].includes(String(v || '').trim().toLowerCase());
|
||||
export const DEV_BYPASS_AUTH = toBool(env.DEV_BYPASS_AUTH || '');
|
||||
export const DEV_DEFAULT_USER = (env.DEV_DEFAULT_USER || 'demo').trim();
|
||||
export const DEV_AUTOSEED_DB = toBool(env.DEV_AUTOSEED_DB || '');
|
||||
|
||||
// ICS: horizonte en meses y rate limit (por minuto, 0 = desactivado)
|
||||
const ICS_HORIZON_MONTHS = Number(env.ICS_HORIZON_MONTHS || 12);
|
||||
export const icsHorizonMonths = Math.max(1, Math.floor(ICS_HORIZON_MONTHS));
|
||||
|
||||
const ICS_RATE_LIMIT_PER_MIN = Number(env.ICS_RATE_LIMIT_PER_MIN || 0);
|
||||
export const icsRateLimitPerMin = Math.max(0, Math.floor(ICS_RATE_LIMIT_PER_MIN));
|
||||
|
||||
// Uncomplete window (minutos; por defecto 1440 = 24h)
|
||||
const UNCOMPLETE_WINDOW_MIN_RAW = Number(env.UNCOMPLETE_WINDOW_MIN || 1440);
|
||||
export const UNCOMPLETE_WINDOW_MIN = Math.max(1, Math.floor(UNCOMPLETE_WINDOW_MIN_RAW));
|
||||
export const uncompleteWindowMs = UNCOMPLETE_WINDOW_MIN * 60 * 1000;
|
||||
@ -0,0 +1,91 @@
|
||||
import { sha256Hex } from './crypto';
|
||||
|
||||
function escapeIcsText(s: string): string {
|
||||
return String(s)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/;/g, '\\;');
|
||||
}
|
||||
|
||||
function foldIcsLine(line: string): string {
|
||||
// 75 octetos; para simplicidad contamos caracteres (UTF-8 simple en nuestro caso)
|
||||
const max = 75;
|
||||
if (line.length <= max) return line;
|
||||
const parts: string[] = [];
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
const chunk = line.slice(i, i + max);
|
||||
parts.push(i === 0 ? chunk : ' ' + chunk);
|
||||
i += max;
|
||||
}
|
||||
return parts.join('\r\n');
|
||||
}
|
||||
|
||||
function padTaskId(id: number, width: number = 4): string {
|
||||
const s = String(Math.max(0, Math.floor(id)));
|
||||
if (s.length >= width) return s;
|
||||
return '0'.repeat(width - s.length) + s;
|
||||
}
|
||||
|
||||
function ymdToBasic(ymd: string): string {
|
||||
// Espera YYYY-MM-DD
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
|
||||
if (!m) return '';
|
||||
return `${m[1]}${m[2]}${m[3]}`;
|
||||
}
|
||||
|
||||
function addDays(ymd: string, days: number): string {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd);
|
||||
if (!m) return ymd;
|
||||
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
const yyyy = String(d.getUTCFullYear()).padStart(4, '0');
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export type IcsEvent = {
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string; // YYYY-MM-DD
|
||||
group_name?: string | null;
|
||||
prefix?: string; // ej: "T" para [T0123]
|
||||
};
|
||||
|
||||
export async function buildIcsCalendar(title: string, events: IcsEvent[]): Promise<{ body: string; etag: string }> {
|
||||
const lines: string[] = [];
|
||||
lines.push('BEGIN:VCALENDAR');
|
||||
lines.push('VERSION:2.0');
|
||||
lines.push('PRODID:-//TaskWhatsApp//Calendar//ES');
|
||||
lines.push('CALSCALE:GREGORIAN');
|
||||
lines.push('METHOD:PUBLISH');
|
||||
lines.push(`X-WR-CALNAME:${escapeIcsText(title)}`);
|
||||
lines.push('X-WR-TIMEZONE:UTC');
|
||||
|
||||
for (const ev of events) {
|
||||
const idPad = padTaskId(ev.id);
|
||||
const summary = `[${ev.prefix || 'T'}${idPad}] ${ev.description}`;
|
||||
const dtStart = ymdToBasic(ev.due_date);
|
||||
const dtEnd = ymdToBasic(addDays(ev.due_date, 1));
|
||||
const uid = `task-${ev.id}@tw`;
|
||||
|
||||
lines.push('BEGIN:VEVENT');
|
||||
lines.push(foldIcsLine(`UID:${uid}`));
|
||||
lines.push(foldIcsLine(`SUMMARY:${escapeIcsText(summary)}`));
|
||||
lines.push(`DTSTART;VALUE=DATE:${dtStart}`);
|
||||
lines.push(`DTEND;VALUE=DATE:${dtEnd}`);
|
||||
if (ev.group_name) {
|
||||
lines.push(foldIcsLine(`CATEGORIES:${escapeIcsText(ev.group_name || '')}`));
|
||||
}
|
||||
lines.push('END:VEVENT');
|
||||
}
|
||||
|
||||
lines.push('END:VCALENDAR');
|
||||
|
||||
const body = lines.join('\r\n') + '\r\n';
|
||||
const etag = await sha256Hex(body);
|
||||
return { body, etag: `W/"${etag}"` };
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ToastType = 'info' | 'success' | 'error';
|
||||
|
||||
export type ToastItem = {
|
||||
id: string;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const toasts = writable<ToastItem[]>([]);
|
||||
|
||||
function uid(): string {
|
||||
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
export function show(message: string, type: ToastType = 'info', timeout = 2500): string {
|
||||
const id = uid();
|
||||
toasts.update((list) => [...list, { id, type, message, timeout }]);
|
||||
if (timeout > 0) {
|
||||
setTimeout(() => dismiss(id), timeout);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function success(message: string, timeout = 2500): string {
|
||||
return show(message, 'success', timeout);
|
||||
}
|
||||
|
||||
export function error(message: string, timeout = 3500): string {
|
||||
return show(message, 'error', timeout);
|
||||
}
|
||||
|
||||
export function info(message: string, timeout = 2500): string {
|
||||
return show(message, 'info', timeout);
|
||||
}
|
||||
|
||||
export function dismiss(id: string): void {
|
||||
toasts.update((list) => list.filter((t) => t.id !== id));
|
||||
}
|
||||
@ -0,0 +1,104 @@
|
||||
/* Reset/normalización ligera */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: 0.8rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Accesibilidad: foco visible */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Utilidades */
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Controles base */
|
||||
button,
|
||||
input[type="submit"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.15s ease, transform 0.05s ease;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
button:focus-visible {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
button:active {
|
||||
transform: translateY(0.5px) scale(0.99);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
button.primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
button:disabled:hover,
|
||||
button:disabled:focus-visible {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
:root {
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f7f7f8;
|
||||
--color-text: #111111;
|
||||
--color-text-muted: #555555;
|
||||
--color-border: #e5e7eb;
|
||||
|
||||
--color-primary: #2563eb; /* azul */
|
||||
--color-danger: #dc2626; /* rojo */
|
||||
--color-warning: #d97706; /* ámbar */
|
||||
--color-success: #16a34a; /* verde */
|
||||
--color-primary-muted: #60a5fa55;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 24px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #0b0c0f;
|
||||
--color-surface: #14161a;
|
||||
--color-text: #e6e7eb;
|
||||
--color-text-muted: #a1a1aa;
|
||||
--color-border: #26272b;
|
||||
|
||||
--color-primary: #60a5fa;
|
||||
--color-danger: #f87171;
|
||||
--color-warning: #fbbf24;
|
||||
--color-success: #34d399;
|
||||
--color-primary-muted: #60a5fa55;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
export let tone: 'default' | 'warning' | 'danger' | 'success' = 'default';
|
||||
</script>
|
||||
|
||||
<span class={`badge ${tone}`}><slot /></span>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
font-size: 12px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
gap: 6px;
|
||||
}
|
||||
.badge.warning {
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
border-color: rgba(217, 119, 6, 0.35);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
.badge.danger {
|
||||
background: rgba(220, 38, 38, 0.12);
|
||||
border-color: rgba(220, 38, 38, 0.35);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.badge.success {
|
||||
background: rgba(22, 163, 74, 0.12);
|
||||
border-color: rgba(22, 163, 74, 0.35);
|
||||
color: var(--color-success);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
export let variant: 'primary' | 'secondary' | 'ghost' | 'danger' = 'secondary';
|
||||
export let size: 'sm' | 'md' = 'md';
|
||||
export let type: 'button' | 'submit' | 'reset' = 'button';
|
||||
export let disabled: boolean = false;
|
||||
</script>
|
||||
|
||||
<button class={`btn ${variant} ${size}`} {type} {disabled} on:click on:keydown on:focus on:blur {...$$restProps}>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
min-height: 36px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, box-shadow 120ms ease, transform 80ms ease;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn.sm { min-height: 30px; padding: 0 10px; font-size: 0.95rem; }
|
||||
.btn.md { min-height: 36px; }
|
||||
|
||||
.btn:hover { box-shadow: var(--shadow-sm); }
|
||||
.btn:active { transform: translateY(0.5px); }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.btn.primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
.btn.primary:hover { filter: brightness(0.98); }
|
||||
.btn.danger {
|
||||
background: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
.btn.secondary {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
export let width: string = '100%';
|
||||
export let height: string = '12px';
|
||||
export let radius: string = '6px';
|
||||
</script>
|
||||
|
||||
<div class="skeleton" style={`width:${width};height:${height};border-radius:${radius};`} />
|
||||
|
||||
<style>
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12), rgba(0,0,0,0.06));
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.2s infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.16), rgba(255,255,255,0.08));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,11 @@
|
||||
<span class="sr-only"><slot /></span>
|
||||
|
||||
<style>
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden; clip: rect(0,0,0,0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/ui/layout/Card.svelte';
|
||||
import Button from '$lib/ui/atoms/Button.svelte';
|
||||
import { copyToClipboard } from '$lib/utils/copy';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/stores/toasts';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let title: string;
|
||||
export let description: string = '';
|
||||
export let url: string | null = null;
|
||||
export let rotating: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{ rotate: void }>();
|
||||
|
||||
async function handleCopy() {
|
||||
if (!url) return;
|
||||
const ok = await copyToClipboard(url);
|
||||
if (ok) toastSuccess('Enlace de calendario copiado');
|
||||
else toastError('No se pudo copiar el enlace');
|
||||
}
|
||||
|
||||
function handleRotate() {
|
||||
dispatch('rotate');
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<div class="head">
|
||||
<div class="titles">
|
||||
<div class="title">{title}</div>
|
||||
{#if description}<div class="desc">{description}</div>{/if}
|
||||
{#if url}
|
||||
<div class="url">{url}</div>
|
||||
{:else}
|
||||
<div class="hint">Por seguridad, la URL no se muestra. Pulsa “Rotar” para generar una nueva.</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<Button variant="secondary" size="sm" on:click={handleCopy} disabled={!url} aria-disabled={!url} title={!url ? 'URL no disponible' : undefined}>Copiar</Button>
|
||||
<Button variant="danger" size="sm" on:click={handleRotate} disabled={rotating} aria-disabled={rotating} aria-busy={rotating} title={rotating ? 'Actualizando…' : undefined}>
|
||||
{rotating ? 'Rotando…' : 'Rotar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.titles {
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.desc {
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.url {
|
||||
margin-top: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
.actions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/ui/layout/Card.svelte';
|
||||
import Badge from '$lib/ui/atoms/Badge.svelte';
|
||||
import { success, error as toastError } from '$lib/stores/toasts';
|
||||
|
||||
export type Counts = { open: number; unassigned: number };
|
||||
export type TaskPreview = { id: number; description: string; due_date: string | null; display_code: number | null };
|
||||
|
||||
export let id: string;
|
||||
export let name: string | null = null;
|
||||
export let counts: Counts = { open: 0, unassigned: 0 };
|
||||
export let previews: TaskPreview[] = [];
|
||||
|
||||
let busyTaskId: number | null = null;
|
||||
|
||||
async function claim(taskId: number) {
|
||||
if (busyTaskId) return;
|
||||
busyTaskId = taskId;
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${taskId}/claim`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
success('Tarea reclamada');
|
||||
// Actualizar estado local sin recargar
|
||||
previews = previews.filter((t) => t.id !== taskId);
|
||||
counts = { ...counts, unassigned: Math.max(0, (counts?.unassigned ?? 0) - 1) };
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
toastError(txt || 'No se pudo reclamar');
|
||||
}
|
||||
} catch {
|
||||
toastError('Error de red');
|
||||
} finally {
|
||||
busyTaskId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<div class="header">
|
||||
<strong class="name">{name ?? id}</strong>
|
||||
<div class="badges">
|
||||
<Badge>abiertas: {counts.open}</Badge>
|
||||
<Badge tone="warning">sin responsable: {counts.unassigned}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if previews?.length}
|
||||
<div class="previews">
|
||||
<em class="title">Sin responsable:</em>
|
||||
<ul class="list">
|
||||
{#each previews as t}
|
||||
<li class="row">
|
||||
<div class="info">
|
||||
<span>#{t.display_code ?? t.id} — {t.description}</span>
|
||||
{#if t.due_date}<small class="muted"> (vence: {t.due_date})</small>{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" on:click|preventDefault={() => claim(t.id)} disabled={busyTaskId === t.id}>Reclamar</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<style>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
justify-content: space-between;
|
||||
}
|
||||
.name { font-size: 1rem; }
|
||||
.badges { display: inline-flex; gap: var(--space-2); flex-wrap: wrap; }
|
||||
.previews { margin-top: var(--space-3); }
|
||||
.title { color: var(--color-text); }
|
||||
.list { margin: 6px 0 0 18px; padding: 0; }
|
||||
.list li { margin: 4px 0; }
|
||||
.muted { color: var(--color-text-muted); }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.info {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.actions {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.btn {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
</style>
|
||||
@ -0,0 +1,911 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
compareYmd,
|
||||
todayYmdUTC,
|
||||
ymdToDmy,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
} from "$lib/utils/date";
|
||||
import { success, error as toastError } from "$lib/stores/toasts";
|
||||
import { tick, onDestroy, createEventDispatcher } from "svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import Popover from "$lib/ui/feedback/Popover.svelte";
|
||||
import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone";
|
||||
import { colorForGroup } from "$lib/utils/groupColor";
|
||||
import Hourglass from "$lib/ui/icons/Hourglass.svelte";
|
||||
import duedateicon from "$lib/assets/on-time-icon.svg";
|
||||
import releaseicon from "$lib/assets/emergency-exit-icon.svg";
|
||||
import overdueicon from "$lib/assets/time-period-icon.svg";
|
||||
import asigneesicon from "$lib/assets/friends-icon.svg";
|
||||
import claimicon from "$lib/assets/mining-icon.svg";
|
||||
import changedateicon from "$lib/assets/remove-date-calendar-icon.svg";
|
||||
|
||||
export let id: number;
|
||||
export let description: string;
|
||||
export let due_date: string | null = null;
|
||||
export let display_code: number | null = null;
|
||||
export let assignees: string[] = [];
|
||||
export let currentUserId: string | null | undefined = null;
|
||||
export let completed: boolean = false;
|
||||
export let completed_at: string | null = null;
|
||||
export let groupName: string | null = null;
|
||||
export let groupId: string | null = null;
|
||||
|
||||
const dispatch = createEventDispatcher<{ changed: { id: number; action: string; patch: any } }>();
|
||||
|
||||
const code = display_code ?? id;
|
||||
const codeStr = String(code).padStart(4, "0");
|
||||
$: isAssigned =
|
||||
!!currentUserId &&
|
||||
assignees.some(
|
||||
(a) => normalizeDigits(a) === normalizeDigits(currentUserId),
|
||||
);
|
||||
$: today = todayYmdUTC();
|
||||
$: overdue = !!due_date && compareYmd(due_date, today) < 0;
|
||||
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
|
||||
$: dateDmy = due_date ? ymdToDmy(due_date) : "";
|
||||
$: groupLabel = groupName != null ? groupName : "Personal";
|
||||
$: gc = groupId ? colorForGroup(groupId) : null;
|
||||
|
||||
let editing = false;
|
||||
let dateValue: string = due_date ?? "";
|
||||
let busy = false;
|
||||
|
||||
// Popover de responsables
|
||||
let showAssignees = false;
|
||||
let assigneesButtonEl: HTMLButtonElement | null = null;
|
||||
$: assigneesCount = Array.isArray(assignees) ? assignees.length : 0;
|
||||
$: canUnassign = !(groupId == null && assigneesCount === 1 && isAssigned);
|
||||
$: assigneesAria =
|
||||
assigneesCount === 0
|
||||
? "Sin responsables"
|
||||
: `${assigneesCount} responsable${assigneesCount === 1 ? "" : "s"}${isAssigned ? "; tú incluido" : ""}`;
|
||||
|
||||
onDestroy(() => {
|
||||
// Cerrar popover si se desmonta el item (por navegación o filtrado)
|
||||
showAssignees = false;
|
||||
});
|
||||
|
||||
// Edición de texto (inline)
|
||||
let editingText = false;
|
||||
let descEl: HTMLElement | null = null;
|
||||
|
||||
async function doClaim() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}/claim`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
// Actualizar estado local (añadirte si no estabas)
|
||||
if (currentUserId) {
|
||||
const set = new Set<string>(assignees || []);
|
||||
set.add(String(currentUserId));
|
||||
assignees = Array.from(set);
|
||||
}
|
||||
success("Tarea reclamada");
|
||||
dispatch("changed", { id, action: "claim", patch: { assignees } });
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
toastError(txt || "No se pudo reclamar");
|
||||
}
|
||||
} catch (e) {
|
||||
toastError("Error de red");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doComplete() {
|
||||
if (busy || completed) return;
|
||||
busy = true;
|
||||
try {
|
||||
const hadNoAssignees = assigneesCount === 0;
|
||||
const res = await fetch(`/api/tasks/${id}/complete`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
const newCompletedAt: string | null = data?.task?.completed_at ? String(data.task.completed_at) : new Date().toISOString().replace('T', ' ').replace('Z', '');
|
||||
// Si no tenía responsables, el backend te auto-asigna: reflejarlo localmente
|
||||
if (hadNoAssignees && currentUserId) {
|
||||
const set = new Set<string>(assignees || []);
|
||||
set.add(String(currentUserId));
|
||||
assignees = Array.from(set);
|
||||
}
|
||||
completed = true;
|
||||
completed_at = newCompletedAt;
|
||||
success(hadNoAssignees ? "Te has asignado y completado la tarea" : "Tarea completada");
|
||||
dispatch("changed", { id, action: "complete", patch: { completed: true, completed_at, assignees } });
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
toastError(txt || "No se pudo completar la tarea");
|
||||
}
|
||||
} catch {
|
||||
toastError("Error de red");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doUncomplete() {
|
||||
if (busy || !completed) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}/uncomplete`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
await res.json().catch(() => null);
|
||||
completed = false;
|
||||
completed_at = null;
|
||||
success("Tarea reabierta");
|
||||
dispatch("changed", { id, action: "uncomplete", patch: { completed: false, completed_at: null } });
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
toastError(txt || "No se pudo deshacer completar");
|
||||
}
|
||||
} catch {
|
||||
toastError("Error de red");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doUnassign() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}/unassign`, { method: "POST" });
|
||||
if (res.ok) {
|
||||
if (currentUserId) {
|
||||
const after = (assignees || []).filter((a) => normalizeDigits(a) !== normalizeDigits(String(currentUserId)));
|
||||
assignees = after;
|
||||
}
|
||||
success("Asignación eliminada");
|
||||
dispatch("changed", { id, action: "unassign", patch: { assignees } });
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
toastError(txt || "No se pudo soltar");
|
||||
}
|
||||
} catch {
|
||||
toastError("Error de red");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDate() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const body = {
|
||||
due_date: dateValue && dateValue.trim() !== "" ? dateValue.trim() : null,
|
||||
};
|
||||
const res = await fetch(`/api/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
due_date = body.due_date;
|
||||
success("Fecha actualizada");
|
||||
dispatch("changed", { id, action: "update_due", patch: { due_date } });
|
||||
editing = false;
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
toastError(txt || "No se pudo actualizar la fecha");
|
||||
}
|
||||
} catch {
|
||||
toastError("Error de red");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
editing = !editing;
|
||||
if (editing) editingText = false;
|
||||
dateValue = due_date ?? "";
|
||||
}
|
||||
|
||||
function clearDate() {
|
||||
if (busy) return;
|
||||
if (!confirm("¿Quitar la fecha de vencimiento?")) return;
|
||||
dateValue = "";
|
||||
saveDate();
|
||||
}
|
||||
|
||||
function toggleEditText() {
|
||||
editingText = !editingText;
|
||||
if (editingText) {
|
||||
editing = false;
|
||||
// Asegurar que el elemento refleja el texto actual y enfocarlo
|
||||
if (descEl) {
|
||||
descEl.textContent = description;
|
||||
}
|
||||
tick().then(() => {
|
||||
if (descEl) {
|
||||
descEl.focus();
|
||||
placeCaretAtEnd(descEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(el: HTMLElement) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
range.collapse(false);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
|
||||
async function saveText() {
|
||||
if (busy) return;
|
||||
const raw = (descEl?.textContent || "").replace(/\s+/g, " ").trim();
|
||||
if (raw.length < 1 || raw.length > 1000) {
|
||||
toastError("La descripción debe tener entre 1 y 1000 caracteres.");
|
||||
return;
|
||||
}
|
||||
if (raw === description) {
|
||||
editingText = false;
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
const res = await fetch(`/api/tasks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ description: raw }),
|
||||
});
|
||||
if (res.ok) {
|
||||
description = raw;
|
||||
success("Descripción actualizada");
|
||||
dispatch("changed", { id, action: "update_desc", patch: { description } });
|
||||
editingText = false;
|
||||
} else {
|
||||
const txt = await res.text();
|
||||
toastError(txt || "No se pudo actualizar la descripción");
|
||||
}
|
||||
} catch {
|
||||
toastError("Error de red");
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelText() {
|
||||
if (descEl) {
|
||||
descEl.textContent = description;
|
||||
}
|
||||
editingText = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class="task" class:completed in:fade={{ duration: 180 }} out:fade={{ duration: 180 }}>
|
||||
<div class="code">{codeStr}</div>
|
||||
<div
|
||||
tabindex="0"
|
||||
class="desc"
|
||||
class:editing={editingText}
|
||||
class:completed
|
||||
contenteditable={editingText && !completed}
|
||||
role="textbox"
|
||||
aria-label="Descripción de la tarea"
|
||||
spellcheck="true"
|
||||
bind:this={descEl}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelText();
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveText();
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<span
|
||||
class="group-badge"
|
||||
title="Grupo"
|
||||
style={gc
|
||||
? `--gc-border: ${gc.border}; --gc-bg: ${gc.bg}; --gc-text: ${gc.text};`
|
||||
: undefined}>{groupLabel}</span
|
||||
>
|
||||
{#if due_date}
|
||||
<span
|
||||
class="date-badge"
|
||||
class:overdue
|
||||
class:soon={imminent}
|
||||
title={overdue ? "Vencida" : imminent ? "Próxima" : "Fecha"}
|
||||
>
|
||||
{#if !overdue && !imminent}
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 122.88 99.56"
|
||||
style="enable-background:new 0 0 122.88 99.56"
|
||||
xml:space="preserve"
|
||||
><style type="text/css">
|
||||
.st0 {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
.st1 {
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
fill: #38ae48;
|
||||
}
|
||||
</style><g
|
||||
><path
|
||||
class="st0"
|
||||
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
|
||||
/><path
|
||||
class="st1"
|
||||
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
{:else if imminent}
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 122.88 99.56"
|
||||
style="enable-background:new 0 0 122.88 99.56"
|
||||
xml:space="preserve"
|
||||
><style type="text/css">
|
||||
.st0 {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
.st3 {
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
fill: var(--color-warning);
|
||||
}
|
||||
</style><g
|
||||
><path
|
||||
class="st0"
|
||||
d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"
|
||||
/><path
|
||||
class="st3"
|
||||
d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
{:else}
|
||||
<svg
|
||||
version="1.1"
|
||||
id="Layer_2"
|
||||
x="0px"
|
||||
y="0px"
|
||||
viewBox="0 0 122.88 100.6"
|
||||
style="enable-background:new 0 0 122.88 100.6"
|
||||
xml:space="preserve"
|
||||
><style type="text/css">
|
||||
.st0 {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
.st2 {
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
fill: #d8453e;
|
||||
}
|
||||
</style><g
|
||||
><path
|
||||
class="st0"
|
||||
d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"
|
||||
/><path
|
||||
class="st2"
|
||||
d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
{/if}
|
||||
{dateDmy}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="complete">
|
||||
{#if completed}
|
||||
<button
|
||||
class="btn primary primary-action"
|
||||
aria-label="Deshacer completar"
|
||||
title="Deshacer completar"
|
||||
on:click|preventDefault={doUncomplete}
|
||||
disabled={busy}
|
||||
>
|
||||
↩️ Deshacer
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn primary primary-action"
|
||||
aria-label="Completar"
|
||||
title="Completar"
|
||||
on:click|preventDefault={doComplete}
|
||||
disabled={busy}
|
||||
><svg viewBox="0 0 96 96" xml:space="preserve"
|
||||
><g
|
||||
><path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
fill="#6BBE66"
|
||||
class=""
|
||||
d="M48,0c26.51,0,48,21.49,48,48S74.51,96,48,96S0,74.51,0,48 S21.49,0,48,0L48,0z M26.764,49.277c0.644-3.734,4.906-5.813,8.269-3.79c0.305,0.182,0.596,0.398,0.867,0.646l0.026,0.025 c1.509,1.446,3.2,2.951,4.876,4.443l1.438,1.291l17.063-17.898c1.019-1.067,1.764-1.757,3.293-2.101 c5.235-1.155,8.916,5.244,5.206,9.155L46.536,63.366c-2.003,2.137-5.583,2.332-7.736,0.291c-1.234-1.146-2.576-2.312-3.933-3.489 c-2.35-2.042-4.747-4.125-6.701-6.187C26.993,52.809,26.487,50.89,26.764,49.277L26.764,49.277z"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
Completar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="assignees-container">
|
||||
{#if assigneesCount === 0}
|
||||
<button
|
||||
class="assignees-badge empty"
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
aria-controls={"assignees-popover-" + id}
|
||||
title="Sin responsables"
|
||||
disabled
|
||||
bind:this={assigneesButtonEl}
|
||||
>
|
||||
🙅
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="assignees-badge"
|
||||
class:mine={isAssigned}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={showAssignees}
|
||||
aria-controls={"assignees-popover-" + id}
|
||||
title={assigneesAria}
|
||||
aria-label={assigneesAria}
|
||||
on:click|preventDefault={() => (showAssignees = true)}
|
||||
bind:this={assigneesButtonEl}
|
||||
>
|
||||
<span class="icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 122.88 91.99" xml:space="preserve"
|
||||
><g
|
||||
><path
|
||||
class="icon-btn-svg"
|
||||
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="count">{assigneesCount}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions">
|
||||
{#if !completed}
|
||||
{#if !isAssigned}
|
||||
<button
|
||||
class="icon-btn secondary-action"
|
||||
aria-label="Reclamar"
|
||||
on:click|preventDefault={doClaim}
|
||||
disabled={busy}
|
||||
><svg viewBox="0 0 121.2 122.88"
|
||||
><g
|
||||
><path
|
||||
class="icon-btn-svg"
|
||||
d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
Reclamar</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="icon-btn secondary-action"
|
||||
aria-label="Soltar"
|
||||
title={canUnassign ? "Soltar" : "No puedes soltar una tarea personal. Márcala como completada para eliminarla"}
|
||||
on:click|preventDefault={doUnassign}
|
||||
disabled={busy || !canUnassign}
|
||||
><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.01 122.88">
|
||||
<path
|
||||
class="icon-btn-svg"
|
||||
d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"
|
||||
/></svg
|
||||
>
|
||||
Soltar</button
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if !editingText}
|
||||
<button
|
||||
class="icon-btn secondary-action"
|
||||
aria-label="Editar texto"
|
||||
title="Editar texto"
|
||||
on:click|preventDefault={toggleEditText}
|
||||
disabled={busy}
|
||||
><svg viewBox="0 0 121.48 122.88"
|
||||
><g
|
||||
><path
|
||||
class="icon-btn-svg"
|
||||
d="M96.84,2.22l22.42,22.42c2.96,2.96,2.96,7.8,0,10.76l-12.4,12.4L73.68,14.62l12.4-12.4 C89.04-0.74,93.88-0.74,96.84,2.22L96.84,2.22z M70.18,52.19L70.18,52.19l0,0.01c0.92,0.92,1.38,2.14,1.38,3.34 c0,1.2-0.46,2.41-1.38,3.34v0.01l-0.01,0.01L40.09,88.99l0,0h-0.01c-0.26,0.26-0.55,0.48-0.84,0.67h-0.01 c-0.3,0.19-0.61,0.34-0.93,0.45c-1.66,0.58-3.59,0.2-4.91-1.12h-0.01l0,0v-0.01c-0.26-0.26-0.48-0.55-0.67-0.84v-0.01 c-0.19-0.3-0.34-0.61-0.45-0.93c-0.58-1.66-0.2-3.59,1.11-4.91v-0.01l30.09-30.09l0,0h0.01c0.92-0.92,2.14-1.38,3.34-1.38 c1.2,0,2.41,0.46,3.34,1.38L70.18,52.19L70.18,52.19L70.18,52.19z M45.48,109.11c-8.98,2.78-17.95,5.55-26.93,8.33 C-2.55,123.97-2.46,128.32,3.3,108l9.07-32v0l-0.03-0.03L67.4,20.9l33.18,33.18l-55.07,55.07L45.48,109.11L45.48,109.11z M18.03,81.66l21.79,21.79c-5.9,1.82-11.8,3.64-17.69,5.45c-13.86,4.27-13.8,7.13-10.03-6.22L18.03,81.66L18.03,81.66z"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
Editar</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="btn primary secondary-action"
|
||||
on:click|preventDefault={saveText}
|
||||
disabled={busy}>Guardar</button
|
||||
>
|
||||
<button
|
||||
class="btn ghost secondary-action"
|
||||
on:click|preventDefault={cancelText}
|
||||
disabled={busy}>Cancelar</button
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if !editing}
|
||||
<button
|
||||
class="icon-btn secondary-action"
|
||||
aria-label="Editar fecha"
|
||||
title="Editar fecha"
|
||||
on:click|preventDefault={toggleEdit}
|
||||
disabled={busy}
|
||||
><svg viewBox="0 0 110.01 122.88" xml:space="preserve"
|
||||
><g
|
||||
><path
|
||||
class="icon-btn-svg"
|
||||
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M71.6,74.59 c2.68-0.02,4.85,2.14,4.85,4.82c-0.01,2.68-2.19,4.87-4.87,4.89l-11.76,0.08l-0.08,11.77c-0.02,2.66-2.21,4.81-4.89,4.81 c-2.68-0.01-4.84-2.17-4.81-4.83l0.08-11.69L38.4,84.54c-2.68,0.02-4.85-2.14-4.85-4.82c0.01-2.68,2.19-4.88,4.87-4.9l11.76-0.08 l0.08-11.77c0.02-2.66,2.21-4.82,4.89-4.81c2.68,0,4.83,2.16,4.81,4.82l-0.08,11.69L71.6,74.59L71.6,74.59L71.6,74.59z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.46-0.21-0.46-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M8.84,50.58h93.84c0.52,0,0.94,0.45,0.94,0.94v62.85 c0,0.49-0.45,0.94-0.94,0.94H8.39c-0.49,0-0.94-0.42-0.94-0.94v-62.4c0-1.03,0.84-1.86,1.86-1.86L8.84,50.58L8.84,50.58z M78.34,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.11l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13L78.34,29.87 L78.34,29.87z M29.29,29.87c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H24.06l-0.03,0.41v10.16c0,2.27,2.36,4.13,5.25,4.13V29.87 L29.29,29.87z"
|
||||
/></g
|
||||
></svg
|
||||
>
|
||||
Fecha</button
|
||||
>
|
||||
{:else}
|
||||
<input class="date" type="date" bind:value={dateValue} />
|
||||
<button
|
||||
class="btn primary secondary-action"
|
||||
on:click|preventDefault={saveDate}
|
||||
disabled={busy}>Guardar</button
|
||||
>
|
||||
<button
|
||||
class="btn danger secondary-action"
|
||||
on:click|preventDefault={clearDate}
|
||||
disabled={busy}>Quitar</button
|
||||
>
|
||||
<button
|
||||
class="btn ghost secondary-action"
|
||||
on:click|preventDefault={toggleEdit}
|
||||
disabled={busy}>Cancelar</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<Popover
|
||||
bind:open={showAssignees}
|
||||
ariaLabel="Responsables"
|
||||
id={"assignees-popover-" + id}
|
||||
>
|
||||
<h3 class="popover-title">Responsables</h3>
|
||||
{#if assigneesCount === 0}
|
||||
<p class="muted">No hay responsables asignados.</p>
|
||||
{:else}
|
||||
<ul class="assignees-list">
|
||||
{#each assignees as a}
|
||||
<li>
|
||||
<a
|
||||
href={buildWaMeUrl(normalizeDigits(a))}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
{normalizeDigits(a)}
|
||||
</a>
|
||||
{#if currentUserId && normalizeDigits(a) === normalizeDigits(currentUserId)}
|
||||
<span class="you-pill">tú</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="popover-actions">
|
||||
<button class="btn ghost" on:click={() => (showAssignees = false)}
|
||||
>Cerrar</button
|
||||
>
|
||||
</div>
|
||||
</Popover>
|
||||
</li>
|
||||
|
||||
<style>
|
||||
.task {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr max-content;
|
||||
grid-template-rows: min-content max-content max-content max-content;
|
||||
grid-gap: 2px;
|
||||
padding: 4px 0 8px 0;
|
||||
border-bottom: 2px dashed var(--color-border);
|
||||
position: relative;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
.task:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.code {
|
||||
font-weight: 300;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
letter-spacing: 0.5px;
|
||||
grid-row: 1/2;
|
||||
grid-column: 1/2;
|
||||
align-self: center;
|
||||
}
|
||||
.completed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.desc {
|
||||
padding: 8px 4px;
|
||||
grid-column: 1/3;
|
||||
grid-row: 2/3;
|
||||
}
|
||||
|
||||
.desc.editing {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
background: var(--color-surface);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
grid-column: 1/3;
|
||||
grid-row: 2/3;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.desc.completed {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.meta {
|
||||
justify-self: end;
|
||||
align-items: start;
|
||||
grid-row: 1/2;
|
||||
grid-column: 2/3;
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.group-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--gc-border, var(--color-border));
|
||||
background: var(--gc-bg, transparent);
|
||||
color: var(--gc-text, inherit);
|
||||
font-size: 12px;
|
||||
}
|
||||
.date-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 12px;
|
||||
}
|
||||
.date-badge img {
|
||||
max-height: 1rem;
|
||||
min-width: 1.2rem;
|
||||
}
|
||||
.date-badge.overdue {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
.date-badge.soon {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
.assignees-container {
|
||||
grid-row: 4/5;
|
||||
grid-column: 1/2;
|
||||
}
|
||||
.task.completed {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.complete {
|
||||
grid-row: 3/4;
|
||||
grid-column: 2/3;
|
||||
justify-self: end;
|
||||
}
|
||||
.actions {
|
||||
justify-self: stretch;
|
||||
grid-column: 2/3;
|
||||
grid-row: 4/5;
|
||||
margin: 2px 0 4px 0;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
box-shadow: 0 0 8px 4px var(--color-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.btn[disabled] {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.primary {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn.primary svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
.btn.danger {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
}
|
||||
.icon-btn {
|
||||
border: 1px solid var(--color-surface);
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-family: monospace;
|
||||
box-shadow: 0 0 8px 4px var(--color-border);
|
||||
}
|
||||
.icon-btn svg {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.date {
|
||||
padding: 4px 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.actions {
|
||||
justify-self: stretch;
|
||||
}
|
||||
.actions .secondary-action {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* Botón de completar a ancho completo en mobile */
|
||||
.complete {
|
||||
grid-column: 1/3;
|
||||
justify-self: stretch;
|
||||
}
|
||||
.complete .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.task {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.meta {
|
||||
justify-self: end;
|
||||
}
|
||||
/* En 1 columna, colocamos acciones en una fila propia bajo el badge */
|
||||
.actions {
|
||||
grid-row: 4/5;
|
||||
grid-column: 1/3;
|
||||
justify-self: flex-end;
|
||||
}
|
||||
.icon-btn {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge de responsables */
|
||||
.assignees-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
gap: 8px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 4px 4px var(--color-border);
|
||||
}
|
||||
.assignees-badge .icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.assignees-badge .count {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
.assignees-badge.mine {
|
||||
border-color: var(--color-surface);
|
||||
}
|
||||
.assignees-badge.mine .icon {
|
||||
position: relative;
|
||||
}
|
||||
.assignees-badge.mine .icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--color-primary);
|
||||
border: 1px solid var(--color-surface);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.assignees-badge[aria-expanded="true"] {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.assignees-badge:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.assignees-badge.empty {
|
||||
padding: 2px 6px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.assignees-list {
|
||||
list-style: none;
|
||||
margin: 8px 0;
|
||||
padding: 0;
|
||||
}
|
||||
.assignees-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.assignees-list a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.assignees-list a:hover,
|
||||
.assignees-list a:focus-visible {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.popover-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.popover-title {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.you-pill {
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--color-primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.icon-btn-svg {
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
fill: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,13 @@
|
||||
<div class="empty">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
padding: var(--space-4);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,16 @@
|
||||
<div class="error-banner" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-banner {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--color-danger);
|
||||
background: rgba(220,38,38,0.08);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.error-banner { background: rgba(248,113,113,0.12); }
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { tick, onDestroy } from 'svelte';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export let open: boolean = false;
|
||||
export let ariaLabel: string = 'Diálogo';
|
||||
export let id: string | undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let panelEl: HTMLElement | null = null;
|
||||
let lastActive: Element | null = null;
|
||||
|
||||
function close() {
|
||||
open = false;
|
||||
dispatch('closed');
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
} else if (e.key === 'Tab' && panelEl) {
|
||||
const focusables = Array.from(
|
||||
panelEl.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
).filter((el) => el.offsetParent !== null);
|
||||
if (focusables.length === 0) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey) {
|
||||
if (active === first || !panelEl.contains(active)) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: if (open) {
|
||||
if (browser) {
|
||||
lastActive = document.activeElement;
|
||||
tick().then(() => {
|
||||
panelEl?.focus();
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (browser) {
|
||||
document.body.style.overflow = '';
|
||||
if (lastActive instanceof HTMLElement) {
|
||||
tick().then(() => lastActive?.focus());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (browser) {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="popover-overlay"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Cerrar"
|
||||
on:click={close}
|
||||
on:keydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); close(); } }}
|
||||
></div>
|
||||
<div
|
||||
class="popover-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
{id}
|
||||
aria-label={ariaLabel}
|
||||
tabindex="-1"
|
||||
bind:this={panelEl}
|
||||
on:keydown={handleKeydown}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.popover-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
z-index: 1000;
|
||||
}
|
||||
.popover-panel {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
max-width: min(420px, 92vw);
|
||||
width: 92vw;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 12px;
|
||||
outline: none;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.popover-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
export let size: number = 16;
|
||||
export let className: string = '';
|
||||
export let ariaLabel: string | undefined;
|
||||
export let title: string | undefined;
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class={className}
|
||||
role={ariaLabel ? 'img' : undefined}
|
||||
aria-label={ariaLabel}
|
||||
aria-hidden={ariaLabel ? undefined : 'true'}
|
||||
>
|
||||
{#if title}<title>{title}</title>{/if}
|
||||
<path d="M6 2h12" />
|
||||
<path d="M6 22h12" />
|
||||
<path d="M8 4l8 8" />
|
||||
<path d="M8 20l8-8" />
|
||||
</svg>
|
||||
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
export type Option = { label: string; value: string };
|
||||
export let name: string;
|
||||
export let options: Option[] = [];
|
||||
export let value: string;
|
||||
</script>
|
||||
|
||||
<div class="segmented" role="radiogroup" aria-label={name}>
|
||||
{#each options as opt}
|
||||
<label class={`item ${value === opt.value ? 'active' : ''}`}>
|
||||
<input type="radio" {name} value={opt.value} bind:group={value} />
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.item {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.item:last-child { border-right: 0; }
|
||||
.item input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.item.active {
|
||||
background: rgba(37,99,235,0.12);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.item.active { background: rgba(96,165,250,0.14); }
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
export let type: string = 'text';
|
||||
export let name: string | undefined;
|
||||
export let value: string | number | undefined = undefined;
|
||||
export let placeholder: string = '';
|
||||
export let disabled: boolean = false;
|
||||
</script>
|
||||
|
||||
<input
|
||||
class="textfield"
|
||||
{type}
|
||||
{name}
|
||||
bind:value
|
||||
{placeholder}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.textfield {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.textfield:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import Toast from "$lib/ui/feedback/Toast.svelte";
|
||||
$: pathname = $page.url.pathname;
|
||||
$: currentTitle =
|
||||
pathname === "/app"
|
||||
? "Tareas"
|
||||
: pathname.startsWith("/app/groups")
|
||||
? "Grupos"
|
||||
: pathname.startsWith("/app/preferences")
|
||||
? "Recordatorios"
|
||||
: pathname.startsWith("/app/integrations")
|
||||
? "Calendarios"
|
||||
: "Tareas";
|
||||
</script>
|
||||
|
||||
<header class="app-header">
|
||||
<div class="container row">
|
||||
<a class="brand" href="/app" aria-label="Inicio">Tareas</a>
|
||||
<nav class="nav">
|
||||
<a href="/app" class:active={$page.url.pathname === "/app"}>Tareas</a>
|
||||
<a
|
||||
href="/app/groups"
|
||||
class:active={$page.url.pathname.startsWith("/app/groups")}>Grupos</a
|
||||
>
|
||||
<a
|
||||
href="/app/preferences"
|
||||
class:active={$page.url.pathname.startsWith("/app/preferences")}
|
||||
>Recordatorios</a
|
||||
>
|
||||
<a
|
||||
href="/app/integrations"
|
||||
class="calendar"
|
||||
class:active={$page.url.pathname.startsWith("/app/integrations")}
|
||||
>Calendarios</a
|
||||
>
|
||||
</nav>
|
||||
<form method="POST" action="/api/logout">
|
||||
<button type="submit" class="logout">Cerrar sesión</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Barra superior móvil (solo título) -->
|
||||
<div class="mobile-topbar" aria-hidden="true">
|
||||
<div class="container topbar-inner">{currentTitle}</div>
|
||||
</div>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="container main">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<nav class="tabbar" aria-label="Navegación inferior">
|
||||
<a
|
||||
href="/app"
|
||||
class:active={$page.url.pathname === "/app"}
|
||||
aria-label="Tareas"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 117.45 122.88">
|
||||
<path
|
||||
class="tabbar-icon-svg"
|
||||
d="M53.4,91.75c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h19.85c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4 L53.4,91.75z M23.23,88.24c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72L29,87.45l5.65-6.88 c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,88.24L23.23,88.24z M23.23,63.34c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,62.55l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,63.34L23.23,63.34z M23.23,38.43c-0.8-1.2-0.48-2.82,0.72-3.63c1.2-0.8,2.82-0.48,3.63,0.72 L29,37.64l5.65-6.88c0.92-1.11,2.56-1.27,3.68-0.36c1.11,0.92,1.27,2.56,0.36,3.68l-7.82,9.51c-0.17,0.22-0.38,0.42-0.62,0.58 c-1.2,0.8-2.82,0.48-3.63-0.72L23.23,38.43L23.23,38.43z M53.4,39.03c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,39.03z M8.22,0h101.02c2.27,0,4.33,0.92,5.81,2.4 c1.48,1.48,2.4,3.54,2.4,5.81v106.44c0,2.27-0.92,4.33-2.4,5.81c-1.48,1.48-3.54,2.4-5.81,2.4H8.22c-2.27,0-4.33-0.92-5.81-2.4 C0.92,119,0,116.93,0,114.66V8.22C0,5.95,0.92,3.88,2.4,2.4C3.88,0.92,5.95,0,8.22,0L8.22,0z M109.24,7.08H8.22 c-0.32,0-0.61,0.13-0.82,0.34c-0.21,0.21-0.34,0.5-0.34,0.82v106.44c0,0.32,0.13,0.61,0.34,0.82c0.21,0.21,0.5,0.34,0.82,0.34 h101.02c0.32,0,0.61-0.13,0.82-0.34c0.21-0.21,0.34-0.5,0.34-0.82V8.24c0-0.32-0.13-0.61-0.34-0.82 C109.84,7.21,109.55,7.08,109.24,7.08L109.24,7.08z M53.4,65.39c-1.96,0-3.54-1.59-3.54-3.54s1.59-3.54,3.54-3.54h36.29 c1.96,0,3.54,1.59,3.54,3.54s-1.59,3.54-3.54,3.54H53.4L53.4,65.39z"
|
||||
/>
|
||||
</svg></span
|
||||
>
|
||||
<span class="label">Tareas</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/groups"
|
||||
class:active={$page.url.pathname.startsWith("/app/groups")}
|
||||
aria-label="Grupos"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 122.88 91.99"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Grupos</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/preferences"
|
||||
class:active={$page.url.pathname.startsWith("/app/preferences")}
|
||||
aria-label="Recordatorios"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 493 511.92"
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
fill-rule="nonzero"
|
||||
d="M277.16 41.75c49.87 6.77 94.55 29.88 128.47 63.79 40.67 40.67 65.83 96.87 65.83 158.93 0 62.08-25.15 118.28-65.83 158.96a227.22 227.22 0 0 1-25.34 21.83l27.24 38.33c5.68 8.18 3.65 19.42-4.54 25.11-8.19 5.68-19.44 3.65-25.12-4.54l-28.28-39.78c-30.84 15.91-65.83 24.89-102.92 24.89-37.7 0-73.23-9.28-104.43-25.69l-26.59 39.71c-5.54 8.28-16.76 10.5-25.04 4.95-8.29-5.54-10.5-16.75-4.95-25.03l26.07-38.95a225.636 225.636 0 0 1-24-20.83c-40.68-40.68-65.84-96.89-65.84-158.96 0-62.07 25.16-118.26 65.84-158.94 36.44-36.43 85.34-60.39 139.74-65.03 16.45-1.4 33.38-.96 49.69 1.25zm204.53 102.98c17.3-41.28 15.24-84.52-9.51-113.49-29.7-34.77-83.39-38.75-133.26-14.3 53.01 36.36 101.12 78.78 142.77 127.79zm-470.15 1.35C-6.1 104.02-4.01 59.97 21.21 30.45 51.47-4.97 106.18-9.03 156.99 15.88c-54 37.06-103.03 80.26-145.45 130.2zm269.3 101.47 67.65-1.18c9.97-.17 18.19 7.76 18.36 17.73.18 9.97-7.76 18.19-17.73 18.37l-69.51 1.21c-6.61 11.32-18.89 18.93-32.94 18.93-21.05 0-38.12-17.08-38.12-38.13 0-14.52 8.13-27.15 20.08-33.58v-87.35c0-9.97 8.07-18.05 18.04-18.05 9.97 0 18.06 8.08 18.06 18.05v87.35a38.324 38.324 0 0 1 16.11 16.65zm99.27-116.5c-34.14-34.14-81.32-55.26-133.43-55.26-52.1 0-99.28 21.12-133.42 55.26-34.15 34.14-55.27 81.32-55.27 133.43 0 52.11 21.12 99.28 55.27 133.43 34.14 34.14 81.31 55.26 133.41 55.26 52.12 0 99.29-21.12 133.43-55.26 34.14-34.15 55.28-81.32 55.28-133.44 0-52.1-21.13-99.27-55.27-133.42z"
|
||||
/></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Alertas</span>
|
||||
</a>
|
||||
<a
|
||||
href="/app/integrations"
|
||||
class="calendar"
|
||||
class:active={$page.url.pathname.startsWith("/app/integrations")}
|
||||
aria-label="Calendarios"
|
||||
>
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 110.01 122.88"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M1.87,14.69h22.66L24.5,14.3V4.13C24.5,1.86,26.86,0,29.76,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39 h38.59l-0.03-0.39V4.13C73.55,1.86,75.91,0,78.8,0c2.89,0,5.26,1.87,5.26,4.13V14.3l-0.03,0.39h24.11c1.03,0,1.87,0.84,1.87,1.87 v19.46c0,1.03-0.84,1.87-1.87,1.87H1.87C0.84,37.88,0,37.04,0,36.01V16.55C0,15.52,0.84,14.69,1.87,14.69L1.87,14.69z M0.47,42.19 h109.08c0.26,0,0.46,0.21,0.46,0.46l0,0v79.76c0,0.25-0.21,0.46-0.46,0.46l-109.08,0c-0.25,0-0.47-0.21-0.47-0.46V42.66 C0,42.4,0.21,42.19,0.47,42.19L0.47,42.19L0.47,42.19z M97.27,52.76H83.57c-0.83,0-1.5,0.63-1.5,1.4V66.9c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4V54.16C98.78,53.39,98.1,52.76,97.27,52.76L97.27,52.76z M12.24,74.93h13.7 c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H12.71c-0.83,0-1.5-0.63-1.5-1.4V75.87c0-0.77,0.68-1.4,1.5-1.4 L12.24,74.93L12.24,74.93z M12.24,97.11h13.7c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.24,0 c-0.83,0-1.5-0.63-1.5-1.4V98.51c0-0.77,0.68-1.4,1.5-1.4L12.24,97.11L12.24,97.11z M12.24,52.76h13.7c0.83,0,1.51,0.63,1.51,1.4 V66.9c0,0.77-0.68,1.4-1.51,1.4l-13.24,0c-0.83,0-1.5-0.63-1.5-1.4V54.16c0-0.77,0.68-1.4,1.5-1.4L12.24,52.76L12.24,52.76z M36.02,52.76h13.71c0.83,0,1.5,0.63,1.5,1.4V66.9c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16 C34.51,53.39,35.19,52.76,36.02,52.76L36.02,52.76L36.02,52.76z M36.02,74.93h13.71c0.83,0,1.5,0.63,1.5,1.4v12.74 c0,0.77-0.68,1.4-1.5,1.4H36.02c-0.83,0-1.51-0.63-1.51-1.4V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L36.02,74.93z M36.02,97.11h13.71 c0.83,0,1.5,0.63,1.5,1.4v12.74c0,0.77-0.68,1.4-1.5,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V98.51 C34.51,97.74,35.19,97.11,36.02,97.11L36.02,97.11L36.02,97.11z M59.79,52.76H73.5c0.83,0,1.51,0.63,1.51,1.4V66.9 c0,0.77-0.68,1.4-1.51,1.4l-13.71,0c-0.83,0-1.51-0.63-1.51-1.4V54.16C58.29,53.39,58.96,52.76,59.79,52.76L59.79,52.76 L59.79,52.76z M59.79,74.93H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4H59.79c-0.83,0-1.51-0.63-1.51-1.4 V75.87c0-0.77,0.68-1.4,1.51-1.4V74.93L59.79,74.93z M97.27,74.93H83.57c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4 h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4L97.27,74.93L97.27,74.93z M97.27,97.11H83.57 c-0.83,0-1.5,0.63-1.5,1.4v12.74c0,0.77,0.67,1.4,1.5,1.4h13.71c0.83,0,1.51-0.63,1.51-1.4l0-13.21c0-0.77-0.68-1.4-1.51-1.4 L97.27,97.11L97.27,97.11z M59.79,97.11H73.5c0.83,0,1.51,0.63,1.51,1.4v12.74c0,0.77-0.68,1.4-1.51,1.4l-13.71,0 c-0.83,0-1.51-0.63-1.51-1.4V98.51C58.29,97.74,58.96,97.11,59.79,97.11L59.79,97.11L59.79,97.11z M7.01,47.71h96.92 c0.52,0,0.94,0.44,0.94,0.94v67.77c0,0.5-0.44,0.94-0.94,0.94H6.08c-0.5,0-0.94-0.42-0.94-0.94V49.58 C5.14,48.55,5.98,47.71,7.01,47.71L7.01,47.71L7.01,47.71z M78.8,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11l-0.03-0.41H73.58 l-0.03,0.41v10.16C73.55,27.54,75.91,29.4,78.8,29.4L78.8,29.4L78.8,29.4z M29.76,29.4c2.89,0,5.26-1.87,5.26-4.13V15.11 l-0.03-0.41H24.53l-0.03,0.41v10.16C24.5,27.54,26.86,29.4,29.76,29.4L29.76,29.4z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Calendarios</span>
|
||||
</a>
|
||||
<form
|
||||
method="POST"
|
||||
action="/api/logout"
|
||||
class="logout-tab"
|
||||
aria-label="Salir"
|
||||
>
|
||||
<button type="submit">
|
||||
<span class="icon"
|
||||
><svg viewBox="0 0 113.525 122.879"
|
||||
><g
|
||||
><path
|
||||
class="tabbar-icon-svg"
|
||||
d="M78.098,13.509l0.033,0.013h0.008c2.908,1.182,5.699,2.603,8.34,4.226c2.621,1.612,5.121,3.455,7.467,5.491 c11.992,10.408,19.58,25.764,19.58,42.879v0.016h-0.006c-0.006,15.668-6.361,29.861-16.633,40.127 c-10.256,10.256-24.434,16.605-40.09,16.613v0.006h-0.033h-0.015v-0.006c-15.666-0.004-29.855-6.357-40.123-16.627l-0.005,0.004 C6.365,95.994,0.015,81.814,0.006,66.15H0v-0.033v-0.039h0.006c0.004-6.898,1.239-13.511,3.492-19.615 c0.916-2.486,2.009-4.897,3.255-7.21C13.144,27.38,23.649,18.04,36.356,13.142l2.634-1.017v2.817v18.875v1.089l-0.947,0.569 l-0.007,0.004l-0.008,0.005l-0.007,0.004c-1.438,0.881-2.809,1.865-4.101,2.925l0.004,0.004c-1.304,1.079-2.532,2.242-3.659,3.477 h-0.007c-5.831,6.375-9.393,14.881-9.393,24.22v0.016h-0.007c0.002,9.9,4.028,18.877,10.527,25.375l-0.004,0.004 c6.492,6.488,15.457,10.506,25.349,10.512v-0.006h0.033h0.015v0.006c9.907-0.002,18.883-4.025,25.374-10.518 S92.66,76.045,92.668,66.148H92.66v-0.033V66.09h0.008c-0.002-6.295-1.633-12.221-4.484-17.362 c-0.451-0.811-0.953-1.634-1.496-2.453c-2.719-4.085-6.252-7.591-10.359-10.266l-0.885-0.577v-1.042V15.303v-2.857L78.098,13.509 L78.098,13.509z M47.509,0h18.507h1.938v1.937v49.969v1.937h-1.938H47.509h-1.937v-1.937V1.937V0H47.509L47.509,0z"
|
||||
/></g
|
||||
></svg
|
||||
></span
|
||||
>
|
||||
<span class="label">Salir</span>
|
||||
</button>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<Toast />
|
||||
|
||||
<style>
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
backdrop-filter: saturate(180%) blur(8px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(8px);
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 58px;
|
||||
}
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.2px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
.nav a {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.nav a:hover,
|
||||
.nav a:focus-visible {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.nav a.active {
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav a.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 3px;
|
||||
height: 2px;
|
||||
background: var(--color-primary);
|
||||
border-radius: 1px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav a:hover,
|
||||
.nav a:focus-visible {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.nav a.active {
|
||||
background: rgba(96, 165, 250, 0.14);
|
||||
}
|
||||
}
|
||||
.logout {
|
||||
margin-left: var(--space-2);
|
||||
min-height: 36px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.main {
|
||||
padding-top: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
/* Barra superior móvil oculta por defecto */
|
||||
.mobile-topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Barra de pestañas inferior (solo móvil) */
|
||||
.tabbar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: none;
|
||||
z-index: 20;
|
||||
min-height: 48px;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tabbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.tabbar a,
|
||||
.tabbar button {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-surface);
|
||||
box-shadow: 0 0 8px 4px var(--color-border);
|
||||
}
|
||||
.tabbar form.logout-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.tabbar a.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Atenuar la pestaña de Calendarios cuando está inactiva */
|
||||
.tabbar a.calendar {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.tabbar a.calendar.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.tabbar .icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
justify-self: center;
|
||||
}
|
||||
.tabbar .label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
.tabbar-icon-svg {
|
||||
fill: var(--color-text);
|
||||
}
|
||||
/* Reservar espacio en el main para no tapar contenido y la barra superior */
|
||||
.main {
|
||||
padding-top: calc(var(--space-4) + 24px + env(safe-area-inset-top));
|
||||
padding-bottom: calc(var(--space-4) + 48px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.tabbar .label {
|
||||
display: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ocultar header y mostrar topbar en móvil */
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
display: none;
|
||||
}
|
||||
.mobile-topbar {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 12;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
min-height: 24px;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
.mobile-topbar .topbar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,14 @@
|
||||
<div class="card">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
/* sin sombra: contenedor no interactivo */
|
||||
padding: var(--space-1);
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
export let prevHref: string | null = null;
|
||||
export let nextHref: string | null = null;
|
||||
</script>
|
||||
|
||||
<nav class="pagination" aria-label="Paginación">
|
||||
{#if prevHref}
|
||||
<a class="link" rel="prev" href={prevHref}>Anterior</a>
|
||||
{/if}
|
||||
{#if nextHref}
|
||||
<a class="link" rel="next" href={nextHref}>Siguiente</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.link {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
text-decoration: none;
|
||||
}
|
||||
.link:hover,
|
||||
.link:focus-visible {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,22 @@
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'absolute';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
export function todayYmdUTC(): string {
|
||||
const d = new Date();
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function compareYmd(a: string, b: string): number {
|
||||
// returns -1 if a<b, 0 if equal, 1 if a>b
|
||||
if (a === b) return 0;
|
||||
return a < b ? -1 : 1;
|
||||
}
|
||||
|
||||
export function addDaysYmd(ymd: string, days: number): string {
|
||||
const d = new Date(`${ymd}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + days);
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
export function dueStatus(ymd: string | null, soonDays: number = 3): 'none' | 'overdue' | 'soon' {
|
||||
if (!ymd) return 'none';
|
||||
const today = todayYmdUTC();
|
||||
if (compareYmd(ymd, today) < 0) return 'overdue';
|
||||
const soonCut = addDaysYmd(today, soonDays);
|
||||
if (compareYmd(ymd, soonCut) <= 0) return 'soon';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function ymdToDmy(ymd: string): string {
|
||||
const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(ymd || '');
|
||||
if (!m) return ymd;
|
||||
return `${m[3]}/${m[2]}/${m[1]}`;
|
||||
}
|
||||
|
||||
export function isToday(ymd: string): boolean {
|
||||
return ymd === todayYmdUTC();
|
||||
}
|
||||
|
||||
export function isTomorrow(ymd: string): boolean {
|
||||
return ymd === addDaysYmd(todayYmdUTC(), 1);
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
export type GroupColor = {
|
||||
border: string;
|
||||
bg: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
const PALETTE: GroupColor[] = [
|
||||
// 1) Blue
|
||||
{ border: '#2563EB', bg: '#DBEAFE', text: '#1E3A8A' },
|
||||
// 2) Indigo
|
||||
{ border: '#4F46E5', bg: '#E0E7FF', text: '#312E81' },
|
||||
// 3) Violet
|
||||
{ border: '#7C3AED', bg: '#EDE9FE', text: '#4C1D95' },
|
||||
// 4) Purple
|
||||
{ border: '#9333EA', bg: '#F3E8FF', text: '#581C87' },
|
||||
// 5) Fuchsia
|
||||
{ border: '#C026D3', bg: '#FAE8FF', text: '#701A75' },
|
||||
// 6) Pink
|
||||
{ border: '#DB2777', bg: '#FCE7F3', text: '#831843' },
|
||||
// 7) Rose
|
||||
{ border: '#E11D48', bg: '#FFE4E6', text: '#881337' },
|
||||
// 8) Red
|
||||
{ border: '#DC2626', bg: '#FEE2E2', text: '#7F1D1D' },
|
||||
// 9) Orange
|
||||
{ border: '#EA580C', bg: '#FFE7D1', text: '#7C2D12' },
|
||||
// 10) Amber
|
||||
{ border: '#D97706', bg: '#FEF3C7', text: '#78350F' },
|
||||
// 11) Green
|
||||
{ border: '#16A34A', bg: '#DCFCE7', text: '#14532D' },
|
||||
// 12) Teal
|
||||
{ border: '#0D9488', bg: '#CCFBF1', text: '#134E4A' }
|
||||
];
|
||||
|
||||
function hashString(input: string): number {
|
||||
// Hash sencillo y rápido (similar a multiplicador 31)
|
||||
let h = 0;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h = (h * 31 + input.charCodeAt(i)) | 0;
|
||||
}
|
||||
// Convertir a entero positivo de 32 bits
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve un esquema de color determinista para un groupId dado.
|
||||
* - Si groupId es falsy o vacío, devuelve null (usar estilos neutros por defecto).
|
||||
*/
|
||||
export function colorForGroup(groupId: string | null | undefined): GroupColor | null {
|
||||
const s = String(groupId || '').trim();
|
||||
if (!s) return null;
|
||||
const idx = hashString(s) % PALETTE.length;
|
||||
return PALETTE[idx];
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
export function normalizeDigits(input: string | null | undefined): string {
|
||||
return String(input ?? '').replace(/\D+/g, '');
|
||||
}
|
||||
|
||||
export function buildWaMeUrl(input: string): string {
|
||||
const digits = normalizeDigits(input);
|
||||
return `https://wa.me/${digits}`;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/tokens.css';
|
||||
import '$lib/styles/base.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import Toast from '$lib/ui/feedback/Toast.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
|
||||
<slot />
|
||||
<Toast />
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
||||
|
||||
<p><a href="/app">Ir al panel</a></p>
|
||||
@ -0,0 +1,114 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
// Requiere sesión
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const groupId = event.params.id;
|
||||
if (!groupId) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
const unassignedFirst =
|
||||
(url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true';
|
||||
const onlyUnassigned =
|
||||
(url.searchParams.get('onlyUnassigned') || '').trim().toLowerCase() === 'true';
|
||||
let limit = parseInt(url.searchParams.get('limit') || '', 10);
|
||||
if (!Number.isFinite(limit) || limit <= 0) limit = 0;
|
||||
if (limit > 100) limit = 100;
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
// Gating: grupo permitido + usuario es miembro activo
|
||||
const allowed = db
|
||||
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
|
||||
.get(groupId);
|
||||
const active = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
|
||||
)
|
||||
.get(groupId, userId);
|
||||
|
||||
if (!allowed || !active) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
const orderParts: string[] = [];
|
||||
if (unassignedFirst) {
|
||||
orderParts.push(
|
||||
`CASE WHEN EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id) THEN 1 ELSE 0 END ASC`
|
||||
);
|
||||
}
|
||||
orderParts.push(
|
||||
`CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END`,
|
||||
`t.due_date ASC`,
|
||||
`t.id ASC`
|
||||
);
|
||||
|
||||
const whereParts = [
|
||||
`t.group_id = ?`,
|
||||
`COALESCE(t.completed, 0) = 0`,
|
||||
`t.completed_at IS NULL`
|
||||
];
|
||||
if (onlyUnassigned) {
|
||||
whereParts.push(
|
||||
`NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)`
|
||||
);
|
||||
}
|
||||
|
||||
const params: any[] = [groupId];
|
||||
|
||||
const sql = `
|
||||
SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
|
||||
FROM tasks t
|
||||
WHERE ${whereParts.join(' AND ')}
|
||||
ORDER BY ${orderParts.join(', ')}${limit > 0 ? ' LIMIT ?' : ''}`;
|
||||
|
||||
if (limit > 0) params.push(limit);
|
||||
|
||||
const rows = db.prepare(sql).all(...params) as any[];
|
||||
|
||||
let items = rows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
description: String(r.description || ''),
|
||||
due_date: r.due_date ? String(r.due_date) : null,
|
||||
group_id: r.group_id ? String(r.group_id) : null,
|
||||
display_code: r.display_code != null ? Number(r.display_code) : null,
|
||||
assignees: [] as string[]
|
||||
}));
|
||||
|
||||
// Cargar asignados
|
||||
if (items.length > 0 && !onlyUnassigned) {
|
||||
const ids = items.map((it) => it.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const assignRows = db
|
||||
.prepare(
|
||||
`SELECT task_id, user_id
|
||||
FROM task_assignments
|
||||
WHERE task_id IN (${placeholders})
|
||||
ORDER BY assigned_at ASC`
|
||||
)
|
||||
.all(...ids) as any[];
|
||||
|
||||
const map = new Map<number, string[]>();
|
||||
for (const row of assignRows) {
|
||||
const tid = Number(row.task_id);
|
||||
const uid = String(row.user_id);
|
||||
if (!map.has(tid)) map.set(tid, []);
|
||||
map.get(tid)!.push(uid);
|
||||
}
|
||||
for (const it of items) {
|
||||
it.assignees = map.get(it.id) || [];
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ items }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,87 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { findActiveToken, createCalendarTokenUrl, buildCalendarIcsUrl, rotateCalendarTokenUrl } from '$lib/server/calendar-tokens';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
// Requiere sesión
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
// Listar solo grupos permitidos donde el usuario está activo (mismo gating que /api/me/groups)
|
||||
const groups = db
|
||||
.prepare(
|
||||
`SELECT g.id, g.name
|
||||
FROM groups g
|
||||
INNER JOIN group_members gm
|
||||
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
|
||||
INNER JOIN allowed_groups ag
|
||||
ON ag.group_id = g.id AND ag.status = 'allowed'
|
||||
WHERE COALESCE(g.active, 1) = 1 AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0
|
||||
ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
|
||||
)
|
||||
.all(userId) as Array<{ id: string; name: string | null }>;
|
||||
|
||||
// Personal
|
||||
const personalExisting = await findActiveToken('personal', userId, null);
|
||||
const personal = await (async () => {
|
||||
if (personalExisting) {
|
||||
if (personalExisting.token_plain) {
|
||||
return { url: buildCalendarIcsUrl('personal', personalExisting.token_plain) };
|
||||
}
|
||||
const rotated = await rotateCalendarTokenUrl('personal', userId, null);
|
||||
return { url: rotated.url };
|
||||
} else {
|
||||
const created = await createCalendarTokenUrl('personal', userId, null);
|
||||
return { url: created.url };
|
||||
}
|
||||
})();
|
||||
|
||||
// Aggregate (multigrupo)
|
||||
const aggregateExisting = await findActiveToken('aggregate', userId, null);
|
||||
const aggregate = await (async () => {
|
||||
if (aggregateExisting) {
|
||||
if (aggregateExisting.token_plain) {
|
||||
return { url: buildCalendarIcsUrl('aggregate', aggregateExisting.token_plain) };
|
||||
}
|
||||
const rotated = await rotateCalendarTokenUrl('aggregate', userId, null);
|
||||
return { url: rotated.url };
|
||||
} else {
|
||||
const created = await createCalendarTokenUrl('aggregate', userId, null);
|
||||
return { url: created.url };
|
||||
}
|
||||
})();
|
||||
|
||||
// Por grupo (B): autogenerar si falta
|
||||
const groupFeeds: Array<{ groupId: string; groupName: string | null; url: string | null }> = [];
|
||||
for (const g of groups) {
|
||||
const ex = await findActiveToken('group', userId, g.id);
|
||||
if (ex) {
|
||||
let url: string | null;
|
||||
if (ex.token_plain) {
|
||||
url = buildCalendarIcsUrl('group', ex.token_plain);
|
||||
} else {
|
||||
const rotated = await rotateCalendarTokenUrl('group', userId, g.id);
|
||||
url = rotated.url;
|
||||
}
|
||||
groupFeeds.push({ groupId: g.id, groupName: g.name ?? null, url });
|
||||
} else {
|
||||
const created = await createCalendarTokenUrl('group', userId, g.id);
|
||||
groupFeeds.push({ groupId: g.id, groupName: g.name ?? null, url: created.url });
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
personal,
|
||||
groups: groupFeeds,
|
||||
aggregate
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,63 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { rotateCalendarTokenUrl } from '$lib/server/calendar-tokens';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
// Requiere sesión
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = await event.request.json();
|
||||
} catch {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const type = String(payload?.type || '').trim().toLowerCase();
|
||||
const groupId = payload?.groupId ? String(payload.groupId).trim() : null;
|
||||
|
||||
if (!['personal', 'group', 'aggregate'].includes(type)) {
|
||||
return new Response(JSON.stringify({ error: 'type inválido' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Validación de gating/membresía si es group
|
||||
if (type === 'group') {
|
||||
if (!groupId) {
|
||||
return new Response(JSON.stringify({ error: 'groupId requerido para type=group' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
const db = await getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM groups g
|
||||
INNER JOIN group_members gm
|
||||
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
|
||||
INNER JOIN allowed_groups ag
|
||||
ON ag.group_id = g.id AND ag.status = 'allowed'
|
||||
WHERE g.id = ? AND COALESCE(g.active, 1) = 1 AND COALESCE(g.is_community, 0) = 0 AND COALESCE(g.archived, 0) = 0
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(userId, groupId) as any;
|
||||
if (!row) {
|
||||
return new Response(JSON.stringify({ error: 'forbidden' }), {
|
||||
status: 403,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rotated = await rotateCalendarTokenUrl(type as any, userId, groupId);
|
||||
return new Response(JSON.stringify({ url: rotated.url }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { sha256Hex } from '$lib/server/crypto';
|
||||
import { isProd } from '$lib/server/env';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const sid = event.cookies.get('sid');
|
||||
if (sid) {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const hash = await sha256Hex(sid);
|
||||
// Intentar borrar; si falla, expirar
|
||||
try {
|
||||
db.prepare(`DELETE FROM web_sessions WHERE session_hash = ?`).run(hash);
|
||||
} catch {
|
||||
db.prepare(
|
||||
`UPDATE web_sessions
|
||||
SET expires_at = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE session_hash = ?`
|
||||
).run(hash);
|
||||
}
|
||||
} catch {
|
||||
// Ignorar errores de DB en logout
|
||||
}
|
||||
}
|
||||
// Limpiar cookie (asegurar mismos atributos que al crearla)
|
||||
event.cookies.delete('sid', { path: '/', httpOnly: true, sameSite: 'lax', secure: isProd() });
|
||||
|
||||
// Redirigir a home para que el navegador navegue sin depender de JS
|
||||
throw redirect(303, '/');
|
||||
};
|
||||
@ -0,0 +1,61 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
// Requiere sesión
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
// Listar solo grupos permitidos donde el usuario está activo
|
||||
const groups = db
|
||||
.prepare(
|
||||
`SELECT g.id, g.name
|
||||
FROM groups g
|
||||
INNER JOIN group_members gm
|
||||
ON gm.group_id = g.id AND gm.user_id = ? AND gm.is_active = 1
|
||||
INNER JOIN allowed_groups ag
|
||||
ON ag.group_id = g.id AND ag.status = 'allowed'
|
||||
WHERE COALESCE(g.active, 1) = 1
|
||||
ORDER BY (g.name IS NULL) ASC, g.name ASC, g.id ASC`
|
||||
)
|
||||
.all(userId) as any[];
|
||||
|
||||
// Preparar statements para contadores
|
||||
const countOpenStmt = db.prepare(
|
||||
`SELECT COUNT(*) AS cnt
|
||||
FROM tasks t
|
||||
WHERE t.group_id = ?
|
||||
AND COALESCE(t.completed, 0) = 0
|
||||
AND t.completed_at IS NULL`
|
||||
);
|
||||
const countUnassignedStmt = db.prepare(
|
||||
`SELECT COUNT(*) AS cnt
|
||||
FROM tasks t
|
||||
WHERE t.group_id = ?
|
||||
AND COALESCE(t.completed, 0) = 0
|
||||
AND t.completed_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)`
|
||||
);
|
||||
|
||||
const items = groups.map((g) => {
|
||||
const open = countOpenStmt.get(g.id) as any;
|
||||
const unassigned = countUnassignedStmt.get(g.id) as any;
|
||||
return {
|
||||
id: String(g.id),
|
||||
name: g.name != null ? String(g.name) : null,
|
||||
counts: {
|
||||
open: Number(open?.cnt || 0),
|
||||
unassigned: Number(unassigned?.cnt || 0)
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ items }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,127 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
// Requiere sesión
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT reminder_freq AS freq, reminder_time AS time
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(userId) as any;
|
||||
|
||||
const body =
|
||||
row && row.freq
|
||||
? { freq: String(row.freq), time: row.time ? String(row.time) : null }
|
||||
: { freq: 'off', time: '08:30' };
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
// Requiere sesión
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = await event.request.json();
|
||||
} catch {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const freqRaw = String(payload?.freq || '').trim().toLowerCase();
|
||||
const timeRaw = payload?.time == null ? null : String(payload.time).trim();
|
||||
|
||||
const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']);
|
||||
if (!allowed.has(freqRaw)) {
|
||||
return new Response(JSON.stringify({ error: 'freq inválida' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTime(input: string): string | null {
|
||||
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||
const hh = String(h).padStart(2, '0');
|
||||
const mm = String(min).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
let timeToSave: string | null = null;
|
||||
|
||||
if (freqRaw === 'off') {
|
||||
// Hora opcional: si viene, validar/normalizar; si no, conservar la actual o usar '08:30'
|
||||
if (timeRaw && timeRaw.length > 0) {
|
||||
const norm = normalizeTime(timeRaw);
|
||||
if (!norm) {
|
||||
return new Response(JSON.stringify({ error: 'hora inválida' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
timeToSave = norm;
|
||||
} else {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT reminder_time AS time
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(userId) as any;
|
||||
timeToSave = row?.time ? String(row.time) : '08:30';
|
||||
}
|
||||
} else {
|
||||
// daily/weekly/weekdays: si no se especifica hora, usar '08:30'
|
||||
if (!timeRaw || timeRaw.length === 0) {
|
||||
timeToSave = '08:30';
|
||||
} else {
|
||||
const norm = normalizeTime(timeRaw);
|
||||
if (!norm) {
|
||||
return new Response(JSON.stringify({ error: 'hora inválida' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
timeToSave = norm;
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert preferencia (mantener last_reminded_on intacto)
|
||||
db.prepare(
|
||||
`INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
|
||||
VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
reminder_freq = excluded.reminder_freq,
|
||||
reminder_time = excluded.reminder_time,
|
||||
updated_at = excluded.updated_at`
|
||||
).run(userId, freqRaw, timeToSave, userId);
|
||||
|
||||
const responseBody = { freq: freqRaw, time: timeToSave };
|
||||
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,235 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
// Requiere sesión
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
const search = (url.searchParams.get('search') || '').trim();
|
||||
const status = (url.searchParams.get('status') || 'open').trim().toLowerCase();
|
||||
const page = clamp(parseInt(url.searchParams.get('page') || '1', 10) || 1, 1, 100000);
|
||||
const limit = clamp(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 1, 100);
|
||||
const orderParam = (url.searchParams.get('order') || 'due').trim().toLowerCase();
|
||||
const order = orderParam === 'group_then_due' ? 'group_then_due' : 'due';
|
||||
const dueBeforeParam = (url.searchParams.get('dueBefore') || '').trim();
|
||||
const soonDaysParam = parseInt(url.searchParams.get('soonDays') || '', 10);
|
||||
const soonDays = Number.isFinite(soonDaysParam) && soonDaysParam >= 0 ? Math.min(soonDaysParam, 365) : null;
|
||||
let dueCutoff: string | null = dueBeforeParam || null;
|
||||
if (!dueCutoff && soonDays != null) {
|
||||
const d = new Date();
|
||||
d.setUTCDate(d.getUTCDate() + soonDays);
|
||||
dueCutoff = d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Acepta "open" (por defecto) o "recent" (completadas <24h)
|
||||
if (status !== 'open' && status !== 'recent') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
if (status === 'recent') {
|
||||
// Construir filtros para tareas completadas en <24h asignadas al usuario.
|
||||
const whereParts = [
|
||||
`a.user_id = ?`,
|
||||
`(COALESCE(t.completed, 0) = 1 OR t.completed_at IS NOT NULL)`,
|
||||
`t.completed_at IS NOT NULL AND t.completed_at >= strftime('%Y-%m-%d %H:%M:%f','now','-24 hours')`,
|
||||
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
|
||||
];
|
||||
const params: any[] = [userId, userId];
|
||||
|
||||
if (search) {
|
||||
whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
|
||||
params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
|
||||
}
|
||||
|
||||
// Total
|
||||
const totalRow = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS cnt
|
||||
FROM tasks t
|
||||
INNER JOIN task_assignments a ON a.task_id = t.id
|
||||
WHERE ${whereParts.join(' AND ')}`
|
||||
)
|
||||
.get(...params) as any;
|
||||
const total = Number(totalRow?.cnt || 0);
|
||||
|
||||
// Items (order by completed_at DESC)
|
||||
const itemsRows = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, COALESCE(t.completed,0) as completed, t.completed_at
|
||||
FROM tasks t
|
||||
INNER JOIN task_assignments a ON a.task_id = t.id
|
||||
WHERE ${whereParts.join(' AND ')}
|
||||
ORDER BY t.completed_at DESC, t.id DESC
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...params, limit, offset) as any[];
|
||||
|
||||
const items = itemsRows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
description: String(r.description || ''),
|
||||
due_date: r.due_date ? String(r.due_date) : null,
|
||||
group_id: r.group_id ? String(r.group_id) : null,
|
||||
display_code: r.display_code != null ? Number(r.display_code) : null,
|
||||
completed: Number(r.completed || 0) === 1,
|
||||
completed_at: r.completed_at ? String(r.completed_at) : null,
|
||||
assignees: [] as string[]
|
||||
}));
|
||||
|
||||
// Cargar asignados
|
||||
if (items.length > 0) {
|
||||
const ids = items.map((it) => it.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const assignRows = db
|
||||
.prepare(
|
||||
`SELECT task_id, user_id
|
||||
FROM task_assignments
|
||||
WHERE task_id IN (${placeholders})
|
||||
ORDER BY assigned_at ASC`
|
||||
)
|
||||
.all(...ids) as any[];
|
||||
|
||||
const map = new Map<number, string[]>();
|
||||
for (const row of assignRows) {
|
||||
const tid = Number(row.task_id);
|
||||
const uid = String(row.user_id);
|
||||
if (!map.has(tid)) map.set(tid, []);
|
||||
map.get(tid)!.push(uid);
|
||||
}
|
||||
for (const it of items) {
|
||||
it.assignees = map.get(it.id) || [];
|
||||
const personal = it.group_id == null;
|
||||
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
|
||||
const mine = (it.assignees || []).some((uid) => uid === userId);
|
||||
(it as any).can_unassign = !(personal && cnt === 1 && mine);
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
hasMore: offset + items.length < total
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// OPEN (comportamiento existente)
|
||||
// Construir filtros dinámicos (con gating por grupo permitido y membresía activa)
|
||||
const whereParts = [
|
||||
`a.user_id = ?`,
|
||||
`COALESCE(t.completed, 0) = 0`,
|
||||
`t.completed_at IS NULL`,
|
||||
`(t.group_id IS NULL OR (EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed') AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1) AND EXISTS (SELECT 1 FROM groups g WHERE g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0)))`
|
||||
];
|
||||
const params: any[] = [userId];
|
||||
|
||||
// Añadir userId para el chequeo de membresía en el filtro de gating
|
||||
params.push(userId);
|
||||
|
||||
if (search) {
|
||||
whereParts.push(`t.description LIKE ? ESCAPE '\\'`);
|
||||
params.push(`%${search.replace(/%/g, '\\%').replace(/_/g, '\\_')}%`);
|
||||
}
|
||||
|
||||
if (dueCutoff) {
|
||||
whereParts.push(`t.due_date IS NOT NULL AND t.due_date <= ?`);
|
||||
params.push(dueCutoff);
|
||||
}
|
||||
|
||||
// Total
|
||||
const totalRow = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS cnt
|
||||
FROM tasks t
|
||||
INNER JOIN task_assignments a ON a.task_id = t.id
|
||||
WHERE ${whereParts.join(' AND ')}`
|
||||
)
|
||||
.get(...params) as any;
|
||||
const total = Number(totalRow?.cnt || 0);
|
||||
|
||||
// Items
|
||||
const orderBy =
|
||||
order === 'group_then_due'
|
||||
? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
|
||||
: `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
|
||||
|
||||
const itemsRows = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code
|
||||
FROM tasks t
|
||||
LEFT JOIN groups g ON g.id = t.group_id
|
||||
INNER JOIN task_assignments a ON a.task_id = t.id
|
||||
WHERE ${whereParts.join(' AND ')}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...params, limit, offset) as any[];
|
||||
|
||||
const items = itemsRows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
description: String(r.description || ''),
|
||||
due_date: r.due_date ? String(r.due_date) : null,
|
||||
group_id: r.group_id ? String(r.group_id) : null,
|
||||
display_code: r.display_code != null ? Number(r.display_code) : null,
|
||||
assignees: [] as string[]
|
||||
}));
|
||||
|
||||
// Cargar asignados de todas las tareas recuperadas (si hay)
|
||||
if (items.length > 0) {
|
||||
const ids = items.map((it) => it.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const assignRows = db
|
||||
.prepare(
|
||||
`SELECT task_id, user_id
|
||||
FROM task_assignments
|
||||
WHERE task_id IN (${placeholders})
|
||||
ORDER BY assigned_at ASC`
|
||||
)
|
||||
.all(...ids) as any[];
|
||||
|
||||
const map = new Map<number, string[]>();
|
||||
for (const row of assignRows) {
|
||||
const tid = Number(row.task_id);
|
||||
const uid = String(row.user_id);
|
||||
if (!map.has(tid)) map.set(tid, []);
|
||||
map.get(tid)!.push(uid);
|
||||
}
|
||||
for (const it of items) {
|
||||
it.assignees = map.get(it.id) || [];
|
||||
const personal = it.group_id == null;
|
||||
const cnt = Array.isArray(it.assignees) ? it.assignees.length : 0;
|
||||
const mine = (it.assignees || []).some((uid) => uid === userId);
|
||||
(it as any).can_unassign = !(personal && cnt === 1 && mine);
|
||||
}
|
||||
}
|
||||
|
||||
const body = {
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
hasMore: offset + items.length < total
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,113 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
// Requiere sesión
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
const orderParam = (url.searchParams.get('order') || 'due').trim().toLowerCase();
|
||||
const order = orderParam === 'group_then_due' ? 'group_then_due' : 'due';
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
// Orden para "assigned"
|
||||
const assignedOrder =
|
||||
order === 'group_then_due'
|
||||
? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
|
||||
: `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
|
||||
|
||||
// Tareas asignadas al usuario (abiertas)
|
||||
const assignedRows = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
|
||||
FROM tasks t
|
||||
LEFT JOIN groups g ON g.id = t.group_id
|
||||
INNER JOIN task_assignments a ON a.task_id = t.id
|
||||
WHERE a.user_id = ?
|
||||
AND COALESCE(t.completed, 0) = 0
|
||||
AND t.completed_at IS NULL
|
||||
AND (
|
||||
t.group_id IS NULL OR (
|
||||
EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed')
|
||||
AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)
|
||||
)
|
||||
)
|
||||
ORDER BY ${assignedOrder}`
|
||||
)
|
||||
.all(userId, userId) as any[];
|
||||
|
||||
const assigned = assignedRows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
description: String(r.description || ''),
|
||||
due_date: r.due_date ? String(r.due_date) : null,
|
||||
group_id: r.group_id ? String(r.group_id) : null,
|
||||
group_name: r.group_name != null ? String(r.group_name) : null, // personales => null
|
||||
display_code: r.display_code != null ? Number(r.display_code) : null,
|
||||
assignees: [] as string[]
|
||||
}));
|
||||
|
||||
// Cargar asignados completos para "assigned"
|
||||
if (assigned.length > 0) {
|
||||
const ids = assigned.map((it) => it.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const assignRows = db
|
||||
.prepare(
|
||||
`SELECT task_id, user_id
|
||||
FROM task_assignments
|
||||
WHERE task_id IN (${placeholders})
|
||||
ORDER BY assigned_at ASC`
|
||||
)
|
||||
.all(...ids) as any[];
|
||||
const map = new Map<number, string[]>();
|
||||
for (const row of assignRows) {
|
||||
const tid = Number(row.task_id);
|
||||
const uid = String(row.user_id);
|
||||
if (!map.has(tid)) map.set(tid, []);
|
||||
map.get(tid)!.push(uid);
|
||||
}
|
||||
for (const it of assigned) {
|
||||
it.assignees = map.get(it.id) || [];
|
||||
}
|
||||
}
|
||||
|
||||
// Orden para "unassigned"
|
||||
const unassignedOrder =
|
||||
order === 'group_then_due'
|
||||
? `(t.group_id IS NULL) ASC, g.name COLLATE NOCASE ASC, CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`
|
||||
: `CASE WHEN t.due_date IS NULL THEN 1 ELSE 0 END, t.due_date ASC, t.id ASC`;
|
||||
|
||||
// Tareas sin responsable (solo de grupos permitidos donde soy miembro activo)
|
||||
const unassignedRows = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.description, t.due_date, t.group_id, t.display_code, g.name AS group_name
|
||||
FROM tasks t
|
||||
LEFT JOIN groups g ON g.id = t.group_id
|
||||
WHERE t.group_id IS NOT NULL
|
||||
AND COALESCE(t.completed, 0) = 0
|
||||
AND t.completed_at IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
|
||||
AND EXISTS (SELECT 1 FROM allowed_groups ag WHERE ag.group_id = t.group_id AND ag.status = 'allowed')
|
||||
AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1)
|
||||
ORDER BY ${unassignedOrder}`
|
||||
)
|
||||
.all(userId) as any[];
|
||||
|
||||
const unassigned = unassignedRows.map((r) => ({
|
||||
id: Number(r.id),
|
||||
description: String(r.description || ''),
|
||||
due_date: r.due_date ? String(r.due_date) : null,
|
||||
group_id: r.group_id ? String(r.group_id) : null,
|
||||
group_name: r.group_name != null ? String(r.group_name) : null,
|
||||
display_code: r.display_code != null ? Number(r.display_code) : null,
|
||||
assignees: [] as string[] // por definición, vacío
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({ assigned, unassigned }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,165 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
function isValidYmd(input: string): boolean {
|
||||
const m = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(input || '');
|
||||
if (!m) return false;
|
||||
const y = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
|
||||
if (!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d)) return false;
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31) return false;
|
||||
const dt = new Date(`${String(y).padStart(4, '0')}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}T00:00:00Z`);
|
||||
// Comprobar que el Date resultante coincide (evita 2025-02-31)
|
||||
return dt.getUTCFullYear() === y && (dt.getUTCMonth() + 1) === mo && dt.getUTCDate() === d;
|
||||
}
|
||||
|
||||
export const PATCH: RequestHandler = async (event) => {
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const idStr = event.params.id || '';
|
||||
const taskId = parseInt(idStr, 10);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
let payload: any = null;
|
||||
try {
|
||||
payload = await event.request.json();
|
||||
} catch {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// Validar que al menos se envíe algún campo editable
|
||||
const hasDueField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'due_date');
|
||||
const hasDescField = Object.prototype.hasOwnProperty.call(payload ?? {}, 'description');
|
||||
if (!hasDueField && !hasDescField) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// due_date (opcional)
|
||||
const due_date_raw = payload?.due_date;
|
||||
if (hasDueField && due_date_raw !== null && due_date_raw !== undefined && typeof due_date_raw !== 'string') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
const due_date =
|
||||
!hasDueField || due_date_raw == null || String(due_date_raw).trim() === ''
|
||||
? null
|
||||
: String(due_date_raw).trim();
|
||||
|
||||
if (hasDueField && due_date !== null && !isValidYmd(due_date)) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_due_date' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// description (opcional)
|
||||
let description: string | undefined = undefined;
|
||||
if (hasDescField) {
|
||||
const descRaw = payload?.description;
|
||||
if (descRaw !== null && descRaw !== undefined && typeof descRaw !== 'string') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
if (descRaw == null) {
|
||||
// No permitimos null en description (columna NOT NULL)
|
||||
return new Response(JSON.stringify({ error: 'invalid_description' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
const normalized = String(descRaw).replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length < 1 || normalized.length > 1000) {
|
||||
return new Response(JSON.stringify({ error: 'invalid_description' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
description = normalized;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
// Cargar tarea y validar abierta
|
||||
const task = db
|
||||
.prepare(
|
||||
`SELECT id, description, due_date, group_id, created_by, COALESCE(completed, 0) AS completed, completed_at, display_code
|
||||
FROM tasks
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(taskId) as any;
|
||||
|
||||
if (!task) {
|
||||
return new Response(JSON.stringify({ status: 'not_found' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
if (Number(task.completed) !== 0 || task.completed_at) {
|
||||
return new Response(JSON.stringify({ status: 'completed' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
|
||||
const groupId: string | null = task.group_id ? String(task.group_id) : null;
|
||||
if (groupId) {
|
||||
const allowed = db
|
||||
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
|
||||
.get(groupId);
|
||||
const active = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
|
||||
)
|
||||
.get(groupId, userId);
|
||||
const gstatus = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM groups WHERE id = ? AND COALESCE(active,1)=1 AND COALESCE(archived,0)=0 LIMIT 1`
|
||||
)
|
||||
.get(groupId);
|
||||
|
||||
if (!allowed || !active || !gstatus) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
} else {
|
||||
// Tarea sin grupo: permitir si el usuario está asignado o es el creador
|
||||
const isAssigned = db
|
||||
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
|
||||
.get(taskId, userId);
|
||||
const isCreator = String(task.created_by || '') === String(userId);
|
||||
|
||||
if (!isAssigned && !isCreator) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Aplicar actualización
|
||||
if (hasDescField && hasDueField) {
|
||||
db.prepare(`UPDATE tasks SET description = ?, due_date = ? WHERE id = ?`).run(description!, due_date, taskId);
|
||||
} else if (hasDescField) {
|
||||
db.prepare(`UPDATE tasks SET description = ? WHERE id = ?`).run(description!, taskId);
|
||||
} else if (hasDueField) {
|
||||
db.prepare(`UPDATE tasks SET due_date = ? WHERE id = ?`).run(due_date, taskId);
|
||||
}
|
||||
|
||||
const updated = db
|
||||
.prepare(`SELECT id, description, due_date, display_code FROM tasks WHERE id = ?`)
|
||||
.get(taskId) as any;
|
||||
|
||||
const body = {
|
||||
status: 'updated',
|
||||
task: {
|
||||
id: Number(updated.id),
|
||||
description: String(updated.description || ''),
|
||||
due_date: updated.due_date ? String(updated.due_date) : null,
|
||||
display_code: updated.display_code != null ? Number(updated.display_code) : null
|
||||
}
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,99 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const idStr = event.params.id || '';
|
||||
const taskId = parseInt(idStr, 10);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
// Cargar tarea y validar abierta
|
||||
const task = db
|
||||
.prepare(
|
||||
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
|
||||
FROM tasks
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(taskId) as any;
|
||||
|
||||
if (!task) {
|
||||
return new Response(JSON.stringify({ status: 'not_found' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
if (Number(task.completed) !== 0 || task.completed_at) {
|
||||
return new Response(JSON.stringify({ status: 'completed' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
|
||||
const groupId: string | null = task.group_id ? String(task.group_id) : null;
|
||||
if (groupId) {
|
||||
const allowed = db
|
||||
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
|
||||
.get(groupId);
|
||||
const active = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
|
||||
)
|
||||
.get(groupId, userId);
|
||||
|
||||
if (!allowed || !active) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Asegurar existencia del usuario (best-effort)
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, first_seen, last_seen)
|
||||
VALUES (?, strftime('%Y-%m-%d %H:%M:%f','now'), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(id) DO NOTHING`
|
||||
).run(userId);
|
||||
db.prepare(
|
||||
`UPDATE users SET last_seen = strftime('%Y-%m-%d %H:%M:%f','now') WHERE id = ?`
|
||||
).run(userId);
|
||||
})();
|
||||
} catch {}
|
||||
|
||||
// Reclamar (idempotente)
|
||||
const res = db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
|
||||
VALUES (?, ?, ?)`
|
||||
)
|
||||
.run(taskId, userId, userId) as any;
|
||||
|
||||
const status = Number(res?.changes || 0) > 0 ? 'claimed' : 'already';
|
||||
|
||||
const body = {
|
||||
status,
|
||||
task: {
|
||||
id: Number(task.id),
|
||||
description: String(task.description || ''),
|
||||
due_date: task.due_date ? String(task.due_date) : null,
|
||||
display_code: task.display_code != null ? Number(task.display_code) : null
|
||||
}
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,120 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const idStr = event.params.id || '';
|
||||
const taskId = parseInt(idStr, 10);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
const task = db.prepare(`
|
||||
SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
|
||||
FROM tasks
|
||||
WHERE id = ?
|
||||
`).get(taskId) as any;
|
||||
|
||||
if (!task) {
|
||||
return new Response(JSON.stringify({ status: 'not_found' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Gating:
|
||||
// - Si tiene group_id: grupo allowed y miembro activo
|
||||
// - Si NO tiene group_id: debe estar asignada al usuario
|
||||
const groupId: string | null = task.group_id ? String(task.group_id) : null;
|
||||
if (groupId) {
|
||||
const allowed = db
|
||||
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
|
||||
.get(groupId);
|
||||
const active = db
|
||||
.prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
|
||||
.get(groupId, userId);
|
||||
if (!allowed || !active) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
} else {
|
||||
const isAssigned = db
|
||||
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
|
||||
.get(taskId, userId);
|
||||
if (!isAssigned) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
if (Number(task.completed) !== 0 || task.completed_at) {
|
||||
const body = {
|
||||
status: 'already',
|
||||
task: {
|
||||
id: Number(task.id),
|
||||
description: String(task.description || ''),
|
||||
due_date: task.due_date ? String(task.due_date) : null,
|
||||
display_code: task.display_code != null ? Number(task.display_code) : null,
|
||||
completed: 1,
|
||||
completed_at: task.completed_at ? String(task.completed_at) : null
|
||||
}
|
||||
};
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Transacción: auto-asignar si no hay responsables y completar
|
||||
const tx = db.transaction(() => {
|
||||
const cntRow = db
|
||||
.prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
|
||||
.get(taskId) as any;
|
||||
const cnt = Number(cntRow?.cnt || 0);
|
||||
if (cnt === 0) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO task_assignments (task_id, user_id, assigned_by)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(taskId, userId, userId);
|
||||
}
|
||||
db.prepare(`
|
||||
UPDATE tasks
|
||||
SET completed = 1,
|
||||
completed_at = strftime('%Y-%m-%d %H:%M:%f', 'now'),
|
||||
completed_by = ?
|
||||
WHERE id = ?
|
||||
AND COALESCE(completed, 0) = 0
|
||||
AND completed_at IS NULL
|
||||
`).run(userId, taskId);
|
||||
});
|
||||
tx();
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT id, description, due_date, display_code, COALESCE(completed, 0) AS completed, completed_at
|
||||
FROM tasks
|
||||
WHERE id = ?
|
||||
`).get(taskId) as any;
|
||||
|
||||
const statusStr = Number(updated.completed || 0) === 1 ? 'updated' : 'already';
|
||||
|
||||
const body = {
|
||||
status: statusStr,
|
||||
task: {
|
||||
id: Number(updated.id),
|
||||
description: String(updated.description || ''),
|
||||
due_date: updated.due_date ? String(updated.due_date) : null,
|
||||
display_code: updated.display_code != null ? Number(updated.display_code) : null,
|
||||
completed: Number(updated.completed || 0),
|
||||
completed_at: updated.completed_at ? String(updated.completed_at) : null
|
||||
}
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,103 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const idStr = event.params.id || '';
|
||||
const taskId = parseInt(idStr, 10);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
// Cargar tarea y validar abierta
|
||||
const task = db
|
||||
.prepare(
|
||||
`SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
|
||||
FROM tasks
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(taskId) as any;
|
||||
|
||||
if (!task) {
|
||||
return new Response(JSON.stringify({ status: 'not_found' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
if (Number(task.completed) !== 0 || task.completed_at) {
|
||||
return new Response(JSON.stringify({ status: 'completed' }), {
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Gating: grupo permitido + usuario miembro activo (si tiene group_id)
|
||||
const groupId: string | null = task.group_id ? String(task.group_id) : null;
|
||||
if (groupId) {
|
||||
const allowed = db
|
||||
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
|
||||
.get(groupId);
|
||||
const active = db
|
||||
.prepare(
|
||||
`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`
|
||||
)
|
||||
.get(groupId, userId);
|
||||
|
||||
if (!allowed || !active) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Bloqueo de tareas personales: si es la última asignación del propio usuario, denegar
|
||||
const stats = db
|
||||
.prepare(`
|
||||
SELECT COUNT(*) AS cnt,
|
||||
SUM(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS mine
|
||||
FROM task_assignments
|
||||
WHERE task_id = ?
|
||||
`)
|
||||
.get(userId, taskId) as any;
|
||||
const cnt = Number(stats?.cnt || 0);
|
||||
const mine = Number(stats?.mine || 0) > 0;
|
||||
|
||||
if (!groupId && cnt === 1 && mine) {
|
||||
return new Response('No puedes soltar una tarea personal. Márcala como completada para eliminarla', {
|
||||
status: 409,
|
||||
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Eliminar asignación (idempotente)
|
||||
const delRes = db
|
||||
.prepare(`DELETE FROM task_assignments WHERE task_id = ? AND user_id = ?`)
|
||||
.run(taskId, userId) as any;
|
||||
|
||||
const cntRow = db
|
||||
.prepare(`SELECT COUNT(*) AS cnt FROM task_assignments WHERE task_id = ?`)
|
||||
.get(taskId) as any;
|
||||
const remaining = Number(cntRow?.cnt || 0);
|
||||
|
||||
const status = Number(delRes?.changes || 0) > 0 ? 'unassigned' : 'not_assigned';
|
||||
|
||||
const body = {
|
||||
status,
|
||||
now_unassigned: remaining === 0,
|
||||
task: {
|
||||
id: Number(task.id),
|
||||
description: String(task.description || ''),
|
||||
due_date: task.due_date ? String(task.due_date) : null,
|
||||
display_code: task.display_code != null ? Number(task.display_code) : null
|
||||
}
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,117 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { UNCOMPLETE_WINDOW_MIN } from '$lib/server/env';
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const idStr = event.params.id || '';
|
||||
const taskId = parseInt(idStr, 10);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
|
||||
const task = db.prepare(`
|
||||
SELECT id, description, due_date, group_id, COALESCE(completed, 0) AS completed, completed_at, display_code
|
||||
FROM tasks
|
||||
WHERE id = ?
|
||||
`).get(taskId) as any;
|
||||
|
||||
if (!task) {
|
||||
return new Response(JSON.stringify({ status: 'not_found' }), {
|
||||
status: 404,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Si ya está sin completar, es idempotente
|
||||
if (Number(task.completed) === 0) {
|
||||
const body = {
|
||||
status: 'already',
|
||||
task: {
|
||||
id: Number(task.id),
|
||||
description: String(task.description || ''),
|
||||
due_date: task.due_date ? String(task.due_date) : null,
|
||||
display_code: task.display_code != null ? Number(task.display_code) : null,
|
||||
completed: 0,
|
||||
completed_at: null
|
||||
}
|
||||
};
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
}
|
||||
|
||||
// Gating:
|
||||
// - Si tiene group_id: grupo allowed y miembro activo
|
||||
// - Si NO tiene group_id: debe estar asignada al usuario
|
||||
const groupId: string | null = task.group_id ? String(task.group_id) : null;
|
||||
if (groupId) {
|
||||
const allowed = db
|
||||
.prepare(`SELECT 1 FROM allowed_groups WHERE group_id = ? AND status = 'allowed' LIMIT 1`)
|
||||
.get(groupId);
|
||||
const active = db
|
||||
.prepare(`SELECT 1 FROM group_members WHERE group_id = ? AND user_id = ? AND is_active = 1 LIMIT 1`)
|
||||
.get(groupId, userId);
|
||||
if (!allowed || !active) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
} else {
|
||||
const isAssigned = db
|
||||
.prepare(`SELECT 1 FROM task_assignments WHERE task_id = ? AND user_id = ? LIMIT 1`)
|
||||
.get(taskId, userId);
|
||||
if (!isAssigned) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
// Validar ventana: completed_at dentro de UNCOMPLETE_WINDOW_MIN
|
||||
if (!task.completed_at) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
const cutoff = toIsoSql(new Date(Date.now() - UNCOMPLETE_WINDOW_MIN * 60 * 1000));
|
||||
if (String(task.completed_at) < String(cutoff)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
|
||||
// Deshacer completar (no tocamos completed_by)
|
||||
db.prepare(`
|
||||
UPDATE tasks
|
||||
SET completed = 0,
|
||||
completed_at = NULL
|
||||
WHERE id = ?
|
||||
`).run(taskId);
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT id, description, due_date, display_code, COALESCE(completed, 0) AS completed, completed_at
|
||||
FROM tasks
|
||||
WHERE id = ?
|
||||
`).get(taskId) as any;
|
||||
|
||||
const body = {
|
||||
status: 'updated',
|
||||
task: {
|
||||
id: Number(updated.id),
|
||||
description: String(updated.description || ''),
|
||||
due_date: updated.due_date ? String(updated.due_date) : null,
|
||||
display_code: updated.display_code != null ? Number(updated.display_code) : null,
|
||||
completed: Number(updated.completed || 0),
|
||||
completed_at: updated.completed_at ? String(updated.completed_at) : null
|
||||
}
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
return { userId };
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AppShell from '$lib/ui/layout/AppShell.svelte';
|
||||
</script>
|
||||
|
||||
<AppShell>
|
||||
<slot />
|
||||
</AppShell>
|
||||
@ -0,0 +1,118 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const userId = event.locals.userId ?? null;
|
||||
if (!userId) {
|
||||
// No hay sesión: redirigir a la home
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
|
||||
// Parámetros de orden y paginación
|
||||
const orderParam = (event.url.searchParams.get('order') || 'due').trim().toLowerCase();
|
||||
const order: 'due' | 'group' = orderParam === 'group' ? 'group' : 'due';
|
||||
const pageStr = (event.url.searchParams.get('page') || '1').trim();
|
||||
const page = Math.max(1, parseInt(pageStr, 10) || 1);
|
||||
|
||||
// Cargar "mis tareas" desde la API interna
|
||||
let openTasks: Array<{
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
group_id: string | null;
|
||||
display_code: number | null;
|
||||
assignees: string[];
|
||||
}> = [];
|
||||
let recentTasks: Array<{
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
group_id: string | null;
|
||||
display_code: number | null;
|
||||
assignees: string[];
|
||||
completed?: boolean;
|
||||
completed_at?: string | null;
|
||||
}> = [];
|
||||
let hasMore: boolean = false;
|
||||
|
||||
// Agregado: "Sin responsable de mis grupos"
|
||||
let unassignedOpen: Array<{
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
group_id: string | null;
|
||||
display_code: number | null;
|
||||
assignees: string[];
|
||||
}> = [];
|
||||
const groupNames: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
// Mis tareas abiertas (paginadas, orden controlado por el servidor)
|
||||
const orderParamApi = order === 'group' ? 'group_then_due' : 'due';
|
||||
let fetchUrl = `/api/me/tasks?limit=20&order=${encodeURIComponent(orderParamApi)}`;
|
||||
fetchUrl += `&page=${encodeURIComponent(String(page))}`;
|
||||
|
||||
const res = await event.fetch(fetchUrl, { headers: { 'cache-control': 'no-store' } });
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
openTasks = Array.isArray(json?.items) ? json.items : [];
|
||||
hasMore = Boolean(json?.hasMore);
|
||||
}
|
||||
|
||||
// Completadas en las últimas 24h (sin paginar por ahora)
|
||||
const resRecent = await event.fetch('/api/me/tasks?limit=20&status=recent', {
|
||||
headers: { 'cache-control': 'no-store' }
|
||||
});
|
||||
if (resRecent.ok) {
|
||||
const jsonRecent = await resRecent.json();
|
||||
recentTasks = Array.isArray(jsonRecent?.items) ? jsonRecent.items : [];
|
||||
}
|
||||
|
||||
// Overview: obtener "sin responsable" de todos los grupos en una sola llamada
|
||||
const overviewOrder = order === 'group' ? 'group_then_due' : 'due';
|
||||
const resOverview = await event.fetch(
|
||||
`/api/me/tasks/overview?order=${encodeURIComponent(overviewOrder)}`,
|
||||
{ headers: { 'cache-control': 'no-store' } }
|
||||
);
|
||||
if (resOverview.ok) {
|
||||
const jsonOv = await resOverview.json();
|
||||
const items: any[] = Array.isArray(jsonOv?.unassigned) ? jsonOv.unassigned : [];
|
||||
unassignedOpen = items.map((it) => ({
|
||||
id: Number(it.id),
|
||||
description: String(it.description || ''),
|
||||
due_date: it.due_date ? String(it.due_date) : null,
|
||||
group_id: it.group_id ? String(it.group_id) : null,
|
||||
display_code: it.display_code != null ? Number(it.display_code) : null,
|
||||
assignees: Array.isArray(it.assignees) ? it.assignees.map(String) : []
|
||||
}));
|
||||
}
|
||||
|
||||
// Mis grupos (para nombres y para recolectar "sin responsable")
|
||||
const resGroups = await event.fetch('/api/me/groups', {
|
||||
headers: { 'cache-control': 'no-store' }
|
||||
});
|
||||
if (resGroups.ok) {
|
||||
const jsonGroups = await resGroups.json();
|
||||
const groups = Array.isArray(jsonGroups?.items) ? jsonGroups.items : [];
|
||||
for (const g of groups) {
|
||||
const gid = String(g.id);
|
||||
const gname = g.name != null ? String(g.name) : null;
|
||||
if (gname) groupNames[gid] = gname;
|
||||
}
|
||||
}
|
||||
|
||||
} catch {
|
||||
// Ignorar errores y dejar listas vacías
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
openTasks,
|
||||
recentTasks,
|
||||
unassignedOpen,
|
||||
groupNames,
|
||||
order,
|
||||
page,
|
||||
hasMore
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,335 @@
|
||||
<script lang="ts">
|
||||
import Card from "$lib/ui/layout/Card.svelte";
|
||||
import TaskItem from "$lib/ui/data/TaskItem.svelte";
|
||||
import Pagination from "$lib/ui/layout/Pagination.svelte";
|
||||
|
||||
type Task = {
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
group_id: string | null;
|
||||
display_code: number | null;
|
||||
assignees: string[];
|
||||
};
|
||||
|
||||
export let data: {
|
||||
userId: string;
|
||||
openTasks: Task[];
|
||||
recentTasks: (Task & {
|
||||
completed?: boolean;
|
||||
completed_at?: string | null;
|
||||
})[];
|
||||
unassignedOpen: Task[];
|
||||
groupNames: Record<string, string>;
|
||||
order: "due" | "group";
|
||||
page?: number | null;
|
||||
hasMore?: boolean | null;
|
||||
};
|
||||
|
||||
// Estado local para permitir actualización sin recargar ni perder scroll
|
||||
let openTasks: Task[] = [...data.openTasks];
|
||||
let unassignedOpen: Task[] = [...data.unassignedOpen];
|
||||
let recentTasks: (Task & {
|
||||
completed?: boolean;
|
||||
completed_at?: string | null;
|
||||
})[] = [...data.recentTasks];
|
||||
|
||||
function buildQuery(params: { order?: "due" | "group"; page?: number }) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.order) sp.set("order", params.order);
|
||||
if (params.page && params.page > 1) sp.set("page", String(params.page));
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
function sortByDue(items: Task[]): Task[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const ad = a.due_date,
|
||||
bd = b.due_date;
|
||||
if (ad == null && bd == null) return a.id - b.id;
|
||||
if (ad == null) return 1;
|
||||
if (bd == null) return -1;
|
||||
if (ad < bd) return -1;
|
||||
if (ad > bd) return 1;
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
function groupByGroup(
|
||||
items: Task[],
|
||||
): { id: string; name: string; tasks: Task[] }[] {
|
||||
const map = new Map<string, Task[]>();
|
||||
for (const it of items) {
|
||||
const gid = it.group_id ? String(it.group_id) : "";
|
||||
if (!map.has(gid)) map.set(gid, []);
|
||||
map.get(gid)!.push(it);
|
||||
}
|
||||
const groups = Array.from(map.entries()).map(([gid, tasks]) => ({
|
||||
id: gid,
|
||||
name: gid ? data.groupNames[gid] || gid : "Personal",
|
||||
tasks,
|
||||
}));
|
||||
// Mantener el orden provisto por el servidor (ya ordenado alfabéticamente con "Personal" al final)
|
||||
return groups;
|
||||
}
|
||||
|
||||
function maintainScrollWhile(mutate: () => void) {
|
||||
const y = window.scrollY;
|
||||
mutate();
|
||||
queueMicrotask(() => window.scrollTo({ top: y }));
|
||||
}
|
||||
|
||||
function updateTaskInLists(detail: {
|
||||
id: number;
|
||||
action: string;
|
||||
patch: Partial<
|
||||
Task & { completed?: boolean; completed_at?: string | null }
|
||||
>;
|
||||
}) {
|
||||
const { id, action, patch } = detail;
|
||||
|
||||
const patchIn = (arr: Task[]) => {
|
||||
const idx = arr.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
arr[idx] = { ...arr[idx], ...patch };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (action === "complete") {
|
||||
maintainScrollWhile(() => {
|
||||
let moved = false;
|
||||
let idx = unassignedOpen.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
const [it] = unassignedOpen.splice(idx, 1);
|
||||
const completedItem: any = { ...it, ...patch, completed: true };
|
||||
recentTasks = [completedItem, ...recentTasks];
|
||||
moved = true;
|
||||
}
|
||||
idx = openTasks.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
const [it] = openTasks.splice(idx, 1);
|
||||
const completedItem: any = { ...it, ...patch, completed: true };
|
||||
recentTasks = [completedItem, ...recentTasks];
|
||||
moved = true;
|
||||
}
|
||||
if (!moved) {
|
||||
patchIn(recentTasks as any);
|
||||
}
|
||||
// Forzar reactividad en listas mutadas
|
||||
openTasks = [...openTasks];
|
||||
unassignedOpen = [...unassignedOpen];
|
||||
recentTasks = [...recentTasks];
|
||||
});
|
||||
} else if (action === "uncomplete") {
|
||||
maintainScrollWhile(() => {
|
||||
const idx = recentTasks.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
const [it] = recentTasks.splice(idx, 1);
|
||||
const reopened: any = { ...it, ...patch, completed: false };
|
||||
openTasks = [reopened, ...openTasks];
|
||||
} else {
|
||||
patchIn(openTasks);
|
||||
}
|
||||
openTasks = [...openTasks];
|
||||
recentTasks = [...recentTasks];
|
||||
});
|
||||
} else if (action === "claim") {
|
||||
maintainScrollWhile(() => {
|
||||
const idx = unassignedOpen.findIndex((t) => t.id === id);
|
||||
if (idx >= 0) {
|
||||
const [it] = unassignedOpen.splice(idx, 1);
|
||||
const claimed = { ...it, ...patch };
|
||||
if (!openTasks.some((x) => x.id === id)) {
|
||||
openTasks = [claimed, ...openTasks];
|
||||
} else {
|
||||
patchIn(openTasks);
|
||||
}
|
||||
} else {
|
||||
patchIn(openTasks);
|
||||
}
|
||||
openTasks = [...openTasks];
|
||||
unassignedOpen = [...unassignedOpen];
|
||||
});
|
||||
} else if (action === "unassign") {
|
||||
maintainScrollWhile(() => {
|
||||
if (!patchIn(openTasks)) patchIn(unassignedOpen);
|
||||
// Si quedó sin responsables, mover a "sin responsable"
|
||||
const idx = openTasks.findIndex((t) => t.id === id);
|
||||
if (idx >= 0 && (openTasks[idx].assignees || []).length === 0) {
|
||||
const [it] = openTasks.splice(idx, 1);
|
||||
unassignedOpen = [it, ...unassignedOpen];
|
||||
}
|
||||
openTasks = [...openTasks];
|
||||
unassignedOpen = [...unassignedOpen];
|
||||
});
|
||||
} else {
|
||||
// update_due, update_desc u otros parches ligeros
|
||||
if (!patchIn(openTasks)) {
|
||||
if (!patchIn(unassignedOpen)) {
|
||||
patchIn(recentTasks as any);
|
||||
}
|
||||
}
|
||||
openTasks = [...openTasks];
|
||||
unassignedOpen = [...unassignedOpen];
|
||||
recentTasks = [...recentTasks];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tareas</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<p class="subtle">Sesión: <strong>{data.userId}</strong></p>
|
||||
|
||||
<div class="order-toggle">
|
||||
<span>Orden:</span>
|
||||
<a
|
||||
class:active={data.order === "due"}
|
||||
href={`/app?${buildQuery({ order: "due", page: data.page ?? 1 })}`}>Fecha</a
|
||||
>
|
||||
<a
|
||||
class:active={data.order === "group"}
|
||||
href={`/app?${buildQuery({ order: "group", page: data.page ?? 1 })}`}
|
||||
>Grupo</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Mis tareas</h2>
|
||||
{#if openTasks.length === 0}
|
||||
<p>No tienes tareas asignadas. Crea o reclama una para empezar.</p>
|
||||
{:else}
|
||||
<Card>
|
||||
<ul class="list">
|
||||
{#each openTasks as t (t.id)}
|
||||
<TaskItem
|
||||
{...t}
|
||||
currentUserId={data.userId}
|
||||
groupName={t.group_id
|
||||
? data.groupNames[t.group_id] || t.group_id
|
||||
: "Personal"}
|
||||
groupId={t.group_id}
|
||||
on:changed={(e) => updateTaskInLists(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
{#if (data.page ?? 1) > 1 || data.hasMore}
|
||||
<Pagination
|
||||
prevHref={(data.page ?? 1) > 1
|
||||
? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) - 1 })}`
|
||||
: null}
|
||||
nextHref={data.hasMore
|
||||
? `/app?${buildQuery({ order: data.order, page: (data.page ?? 1) + 1 })}`
|
||||
: null}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<h2 class="section-title">Sin responsable de mis grupos</h2>
|
||||
{#if unassignedOpen.length === 0}
|
||||
<p>
|
||||
No hay tareas sin responsable en tus grupos. Crea una nueva o invita a
|
||||
alguien.
|
||||
</p>
|
||||
{:else if data.order === "group"}
|
||||
{#each groupByGroup(unassignedOpen) as g (g.id)}
|
||||
<h3 class="group-subtitle">{g.name}</h3>
|
||||
<Card>
|
||||
<ul class="list">
|
||||
{#each g.tasks as t (t.id)}
|
||||
<TaskItem
|
||||
{...t}
|
||||
currentUserId={data.userId}
|
||||
groupName={g.name}
|
||||
groupId={t.group_id}
|
||||
on:changed={(e) => updateTaskInLists(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
{/each}
|
||||
{:else}
|
||||
<Card>
|
||||
<ul class="list">
|
||||
{#each unassignedOpen as t (t.id)}
|
||||
<TaskItem
|
||||
{...t}
|
||||
currentUserId={data.userId}
|
||||
groupName={t.group_id
|
||||
? data.groupNames[t.group_id] || t.group_id
|
||||
: "Personal"}
|
||||
groupId={t.group_id}
|
||||
on:changed={(e) => updateTaskInLists(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<h2 class="section-title">Completadas (últimas 24 h)</h2>
|
||||
{#if recentTasks.length === 0}
|
||||
<p>No hay tareas completadas recientemente.</p>
|
||||
{:else}
|
||||
<Card>
|
||||
<ul class="list">
|
||||
{#each recentTasks as t (t.id)}
|
||||
<TaskItem
|
||||
{...t}
|
||||
currentUserId={data.userId}
|
||||
groupId={t.group_id}
|
||||
completed={true}
|
||||
completed_at={t.completed_at ?? null}
|
||||
groupName={t.group_id
|
||||
? data.groupNames[t.group_id] || t.group_id
|
||||
: "Personal"}
|
||||
on:changed={(e) => updateTaskInLists(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.subtle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
.order-toggle {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.order-toggle a {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.order-toggle a.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.group-subtitle {
|
||||
margin: 0.5rem 0 0.25rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.footnote {
|
||||
margin-top: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,35 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const userId = event.locals.userId ?? null;
|
||||
const res = await event.fetch('/api/me/groups', { headers: { 'cache-control': 'no-store' } });
|
||||
if (!res.ok) {
|
||||
// El gate del layout debería impedir llegar aquí sin sesión; devolvemos vacío como salvaguarda.
|
||||
return { groups: [], itemsByGroup: {}, unassignedFirst: false };
|
||||
}
|
||||
const data = await res.json();
|
||||
const groups = Array.isArray(data?.items) ? data.items : [];
|
||||
|
||||
// Leer preferencia de orden para el listado del grupo
|
||||
const unassignedFirst =
|
||||
(event.url.searchParams.get('unassignedFirst') || '').trim().toLowerCase() === 'true';
|
||||
|
||||
// Recolectar TODAS las tareas abiertas por grupo (sin límite)
|
||||
const itemsByGroup: Record<string, any[]> = {};
|
||||
for (const g of groups) {
|
||||
try {
|
||||
const url = `/api/groups/${encodeURIComponent(g.id)}/tasks?limit=0${
|
||||
unassignedFirst ? '&unassignedFirst=true' : ''
|
||||
}`;
|
||||
const r = await event.fetch(url, { headers: { 'cache-control': 'no-store' } });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
itemsByGroup[String(g.id)] = Array.isArray(j?.items) ? j.items : [];
|
||||
}
|
||||
} catch {
|
||||
// ignorar errores de un grupo y continuar
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, itemsByGroup, unassignedFirst, userId };
|
||||
};
|
||||
@ -0,0 +1,240 @@
|
||||
<script lang="ts">
|
||||
import TaskItem from "$lib/ui/data/TaskItem.svelte";
|
||||
import Card from "$lib/ui/layout/Card.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
type GroupItem = {
|
||||
id: string;
|
||||
name: string | null;
|
||||
counts: { open: number; unassigned: number };
|
||||
};
|
||||
type Task = {
|
||||
id: number;
|
||||
description: string;
|
||||
due_date: string | null;
|
||||
display_code: number | null;
|
||||
group_id?: string | null;
|
||||
assignees?: string[];
|
||||
};
|
||||
|
||||
export let data: {
|
||||
userId: string | null;
|
||||
groups: GroupItem[];
|
||||
itemsByGroup: Record<string, Task[]>;
|
||||
unassignedFirst?: boolean;
|
||||
};
|
||||
const groups = data.groups || [];
|
||||
let itemsByGroup: Record<string, Task[]> = {};
|
||||
for (const [gid, arr] of Object.entries(data.itemsByGroup || {})) {
|
||||
itemsByGroup[gid] = Array.isArray(arr as any) ? ([...(arr as any)]) : [];
|
||||
}
|
||||
|
||||
function buildQuery(params: { unassignedFirst?: boolean }) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.unassignedFirst) sp.set("unassignedFirst", "true");
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
const storageKey = `groupsCollapsed:v1:${data.userId ?? "anon"}`;
|
||||
let collapsed: Record<string, boolean> = {};
|
||||
|
||||
function hasTasks(groupId: string): boolean {
|
||||
const arr = itemsByGroup[groupId] || [];
|
||||
return Array.isArray(arr) && arr.length > 0;
|
||||
}
|
||||
|
||||
function defaultCollapsedFor(groupId: string): boolean {
|
||||
// Por defecto, colapsado si no tiene tareas abiertas
|
||||
return !hasTasks(groupId);
|
||||
}
|
||||
|
||||
function isOpen(groupId: string): boolean {
|
||||
const v = collapsed[groupId];
|
||||
if (typeof v === "boolean") return !v;
|
||||
return !defaultCollapsedFor(groupId);
|
||||
}
|
||||
|
||||
function saveCollapsed() {
|
||||
try {
|
||||
const currentIds = new Set(groups.map((g) => g.id));
|
||||
const pruned: Record<string, boolean> = {};
|
||||
for (const id of Object.keys(collapsed)) {
|
||||
if (currentIds.has(id)) pruned[id] = !!collapsed[id];
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify(pruned));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function handleToggle(groupId: string, e: Event) {
|
||||
const open = (e.currentTarget as HTMLDetailsElement).open;
|
||||
collapsed = { ...collapsed, [groupId]: !open };
|
||||
saveCollapsed();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
const saved = raw ? JSON.parse(raw) : {};
|
||||
const map: Record<string, boolean> = {};
|
||||
const currentIds = new Set(groups.map((g) => g.id));
|
||||
for (const g of groups) {
|
||||
map[g.id] =
|
||||
typeof saved?.[g.id] === "boolean"
|
||||
? !!saved[g.id]
|
||||
: defaultCollapsedFor(g.id);
|
||||
}
|
||||
// Limpieza de claves obsoletas en storage
|
||||
const cleaned: Record<string, boolean> = {};
|
||||
for (const k of Object.keys(saved || {})) {
|
||||
if (currentIds.has(k)) cleaned[k] = !!saved[k];
|
||||
}
|
||||
collapsed = map;
|
||||
localStorage.setItem(storageKey, JSON.stringify(cleaned || map));
|
||||
} catch {
|
||||
// si falla, dejamos los defaults (basados en tareas)
|
||||
collapsed = {};
|
||||
}
|
||||
});
|
||||
|
||||
function maintainScrollWhile(mutate: () => void) {
|
||||
const y = window.scrollY;
|
||||
mutate();
|
||||
queueMicrotask(() => window.scrollTo({ top: y }));
|
||||
}
|
||||
|
||||
function updateGroupTask(groupId: string, detail: { id: number; action: string; patch: Partial<Task & { completed?: boolean; completed_at?: string | null }> }) {
|
||||
const { id, action, patch } = detail;
|
||||
const arr = itemsByGroup[groupId] || [];
|
||||
const idx = arr.findIndex((t) => t.id === id);
|
||||
|
||||
if (action === 'complete') {
|
||||
if (idx >= 0) {
|
||||
maintainScrollWhile(() => {
|
||||
arr.splice(idx, 1);
|
||||
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (idx >= 0) {
|
||||
arr[idx] = { ...arr[idx], ...patch };
|
||||
itemsByGroup = { ...itemsByGroup, [groupId]: [...arr] };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Grupos</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
{#if groups.length === 0}
|
||||
<p>No perteneces a ningún grupo permitido.</p>
|
||||
{:else}
|
||||
<h1 class="title">Grupos</h1>
|
||||
|
||||
<div class="toolbar">
|
||||
<label class="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!data.unassignedFirst}
|
||||
on:change={(e) => {
|
||||
const checked = (e.currentTarget as HTMLInputElement).checked;
|
||||
const q = buildQuery({ unassignedFirst: checked });
|
||||
location.href = q ? `/app/groups?${q}` : `/app/groups`;
|
||||
}}
|
||||
/>
|
||||
Sin responsable primero
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#each groups as g (g.id)}
|
||||
<details
|
||||
class="group"
|
||||
open={isOpen(g.id)}
|
||||
on:toggle={(e) => handleToggle(g.id, e)}
|
||||
>
|
||||
<summary class="group-header">
|
||||
<span class="name">{g.name ?? g.id}</span>
|
||||
<span class="counts">
|
||||
<span class="badge">tareas: {g.counts.open}</span>
|
||||
<span class="badge warn">🙅♂️: {g.counts.unassigned}</span>
|
||||
</span>
|
||||
</summary>
|
||||
{#if isOpen(g.id)}
|
||||
<div in:slide={{ duration: 180 }} out:slide={{ duration: 180 }}>
|
||||
<Card>
|
||||
<ul class="list">
|
||||
{#each itemsByGroup[g.id] || [] as t (t.id)}
|
||||
<TaskItem
|
||||
id={t.id}
|
||||
description={t.description}
|
||||
due_date={t.due_date}
|
||||
display_code={t.display_code}
|
||||
assignees={t.assignees || []}
|
||||
currentUserId={data.userId}
|
||||
groupName={g.name ?? g.id}
|
||||
groupId={t.group_id ?? g.id}
|
||||
on:changed={(e) => updateGroupTask(g.id, e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.title {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.group {
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.group-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.group-header .name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.counts {
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.badge.warn {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
.list {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem 0.5rem 0.5rem;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,14 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const res = await fetch('/api/integrations/feeds', { method: 'GET', headers: { 'cache-control': 'no-store' } });
|
||||
if (!res.ok) {
|
||||
return {
|
||||
personal: { url: null },
|
||||
aggregate: { url: null },
|
||||
groups: []
|
||||
};
|
||||
}
|
||||
const data = await res.json();
|
||||
return data;
|
||||
};
|
||||
@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import FeedCard from '$lib/ui/data/FeedCard.svelte';
|
||||
import EmptyState from '$lib/ui/feedback/EmptyState.svelte';
|
||||
import { success, error } from '$lib/stores/toasts';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let data: {
|
||||
personal: { url: string | null };
|
||||
aggregate: { url: string | null };
|
||||
groups: Array<{ groupId: string; groupName: string | null; url: string | null }>;
|
||||
};
|
||||
|
||||
let personalUrl: string | null = data.personal?.url ?? null;
|
||||
let aggregateUrl: string | null = data.aggregate?.url ?? null;
|
||||
let groups = data.groups?.map(g => ({ ...g })) || [];
|
||||
|
||||
let rotating: Record<string, boolean> = {};
|
||||
|
||||
async function rotate(type: 'personal' | 'aggregate' | 'group', groupId?: string) {
|
||||
try {
|
||||
if (type === 'group' && groupId) rotating[groupId] = true;
|
||||
if (type === 'personal') rotating['personal'] = true;
|
||||
if (type === 'aggregate') rotating['aggregate'] = true;
|
||||
rotating = { ...rotating };
|
||||
|
||||
const res = await fetch('/api/integrations/feeds/rotate', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ type, groupId: groupId ?? null })
|
||||
});
|
||||
if (!res.ok) {
|
||||
error('No se puedo actualizar');
|
||||
return;
|
||||
}
|
||||
const body = await res.json();
|
||||
if (type === 'personal') {
|
||||
personalUrl = body.url || null;
|
||||
} else if (type === 'aggregate') {
|
||||
aggregateUrl = body.url || null;
|
||||
} else if (type === 'group' && groupId) {
|
||||
const idx = groups.findIndex(g => g.groupId === groupId);
|
||||
if (idx >= 0) {
|
||||
groups[idx] = { ...groups[idx], url: body.url || null };
|
||||
groups = [...groups];
|
||||
}
|
||||
}
|
||||
success('Feed de calendario actualizado');
|
||||
} catch (e) {
|
||||
error('No se puedo actualizar');
|
||||
} finally {
|
||||
if (type === 'group' && groupId) rotating[groupId] = false;
|
||||
if (type === 'personal') rotating['personal'] = false;
|
||||
if (type === 'aggregate') rotating['aggregate'] = false;
|
||||
rotating = { ...rotating };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Integraciones</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<section style="max-width: 920px; margin: 1.5rem auto; padding: 0 1rem;">
|
||||
<h1 style="font-size: 1.4rem; font-weight: 600; margin-bottom: .75rem;">Integraciones</h1>
|
||||
|
||||
<h2 style="font-size: 1.1rem; font-weight: 600; margin: .5rem 0;">Feed personal</h2>
|
||||
<FeedCard
|
||||
title="Mis tareas (con fecha)"
|
||||
description="Suscríbete a este feed en tu calendario para ver tus tareas con fecha de vencimiento."
|
||||
url={personalUrl}
|
||||
rotating={!!rotating['personal']}
|
||||
on:rotate={() => rotate('personal')}
|
||||
/>
|
||||
|
||||
<h2 style="font-size: 1.1rem; font-weight: 600; margin: 1rem 0 .5rem;">Feed multigrupo</h2>
|
||||
<FeedCard
|
||||
title="Mis grupos (sin responsable)"
|
||||
description="Tareas sin responsable agregadas de tus grupos permitidos."
|
||||
url={aggregateUrl}
|
||||
rotating={!!rotating['aggregate']}
|
||||
on:rotate={() => rotate('aggregate')}
|
||||
/>
|
||||
|
||||
<h2 style="font-size: 1.1rem; font-weight: 600; margin: 1rem 0 .5rem;">Feeds por grupo (sin responsable)</h2>
|
||||
{#if groups.length === 0}
|
||||
<EmptyState>No hay grupos disponibles todavía. Cuando los haya, verás aquí sus feeds ICS.</EmptyState>
|
||||
{:else}
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3);">
|
||||
{#each groups as g (g.groupId)}
|
||||
<FeedCard
|
||||
title={g.groupName || g.groupId}
|
||||
description="Tareas sin responsable"
|
||||
url={g.url}
|
||||
rotating={!!rotating[g.groupId]}
|
||||
on:rotate={() => rotate('group', g.groupId)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@ -0,0 +1,178 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
|
||||
function ymdInTZ(d: Date, tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(d);
|
||||
const get = (t: string) => parts.find((p) => p.type === t)?.value || '';
|
||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||
}
|
||||
|
||||
function hmInTZ(d: Date, tz: string): string {
|
||||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: tz,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
}).formatToParts(d);
|
||||
const get = (t: string) => parts.find((p) => p.type === t)?.value || '';
|
||||
return `${get('hour')}:${get('minute')}`;
|
||||
}
|
||||
|
||||
function weekdayShortInTZ(d: Date, tz: string): string {
|
||||
return new Intl.DateTimeFormat('en-GB', { timeZone: tz, weekday: 'short' }).format(d);
|
||||
}
|
||||
|
||||
function normalizeTime(input: string): string | null {
|
||||
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||
const hh = String(h).padStart(2, '0');
|
||||
const mm = String(min).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function computeNextReminder(
|
||||
freq: 'off' | 'daily' | 'weekly' | 'weekdays',
|
||||
time: string | null,
|
||||
now: Date,
|
||||
tz: string
|
||||
): string | null {
|
||||
if (freq === 'off' || !time) return null;
|
||||
|
||||
const nowHM = hmInTZ(now, tz);
|
||||
const [nowH, nowM] = String(nowHM).split(':');
|
||||
const [cfgH, cfgM] = String(time).split(':');
|
||||
const nowMin = (parseInt(nowH || '0', 10) || 0) * 60 + (parseInt(nowM || '0', 10) || 0);
|
||||
const cfgMin = (parseInt(cfgH || '0', 10) || 0) * 60 + (parseInt(cfgM || '0', 10) || 0);
|
||||
|
||||
const allowDay = (w: string) => {
|
||||
if (freq === 'daily') return true;
|
||||
if (freq === 'weekly') return w === 'Mon';
|
||||
// weekdays
|
||||
return w !== 'Sat' && w !== 'Sun';
|
||||
};
|
||||
|
||||
for (let offset = 0; offset < 14; offset++) {
|
||||
const cand = new Date(now.getTime() + offset * 24 * 60 * 60 * 1000);
|
||||
const wd = weekdayShortInTZ(cand, tz);
|
||||
if (!allowDay(wd)) continue;
|
||||
|
||||
if (offset === 0 && nowMin >= cfgMin) {
|
||||
// hoy ya pasó la hora → buscar siguiente día permitido
|
||||
continue;
|
||||
}
|
||||
const ymd = ymdInTZ(cand, tz);
|
||||
return `${ymd} ${normalizeTime(time)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const userId = locals.userId ?? null;
|
||||
if (!userId) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT reminder_freq AS freq, reminder_time AS time
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(userId) as any;
|
||||
|
||||
const pref =
|
||||
row && row.freq
|
||||
? { freq: String(row.freq) as 'off' | 'daily' | 'weekly' | 'weekdays', time: row.time ? String(row.time) : null }
|
||||
: { freq: 'off' as const, time: '08:30' as string };
|
||||
|
||||
const tz = (process.env.TZ && process.env.TZ.trim()) || 'Europe/Madrid';
|
||||
const next = computeNextReminder(pref.freq, pref.time, new Date(), tz);
|
||||
|
||||
return {
|
||||
pref,
|
||||
tz,
|
||||
next
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ locals, request }) => {
|
||||
const userId = locals.userId ?? null;
|
||||
if (!userId) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const freqRaw = String(form.get('freq') || '').trim().toLowerCase();
|
||||
const timeRaw = form.has('time') ? String(form.get('time') || '').trim() : null;
|
||||
|
||||
const allowed = new Set(['off', 'daily', 'weekly', 'weekdays']);
|
||||
if (!allowed.has(freqRaw)) {
|
||||
return fail(400, { error: 'freq inválida' });
|
||||
}
|
||||
|
||||
function normalizeTime(input: string): string | null {
|
||||
const m = /^\s*(\d{1,2}):(\d{1,2})\s*$/.exec(input || '');
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const min = Number(m[2]);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(min)) return null;
|
||||
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
|
||||
const hh = String(h).padStart(2, '0');
|
||||
const mm = String(min).padStart(2, '0');
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
let timeToSave: string | null = null;
|
||||
|
||||
if (freqRaw === 'off') {
|
||||
if (timeRaw && timeRaw.length > 0) {
|
||||
const norm = normalizeTime(timeRaw);
|
||||
if (!norm) return fail(400, { error: 'hora inválida' });
|
||||
timeToSave = norm;
|
||||
} else {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT reminder_time AS time
|
||||
FROM user_preferences
|
||||
WHERE user_id = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(userId) as any;
|
||||
timeToSave = row?.time ? String(row.time) : '08:30';
|
||||
}
|
||||
} else {
|
||||
if (!timeRaw || timeRaw.length === 0) {
|
||||
timeToSave = '08:30';
|
||||
} else {
|
||||
const norm = normalizeTime(timeRaw);
|
||||
if (!norm) return fail(400, { error: 'hora inválida' });
|
||||
timeToSave = norm;
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO user_preferences (user_id, reminder_freq, reminder_time, last_reminded_on, updated_at)
|
||||
VALUES (?, ?, ?, (SELECT last_reminded_on FROM user_preferences WHERE user_id = ?), strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
reminder_freq = excluded.reminder_freq,
|
||||
reminder_time = excluded.reminder_time,
|
||||
updated_at = excluded.updated_at`
|
||||
).run(userId, freqRaw, timeToSave, userId);
|
||||
|
||||
return { success: true, pref: { freq: freqRaw, time: timeToSave } };
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { sha256Hex } from '$lib/server/crypto';
|
||||
import { icsHorizonMonths } from '$lib/server/env';
|
||||
import { buildIcsCalendar } from '$lib/server/ics';
|
||||
|
||||
function toIsoSql(d = new Date()): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
function ymdUTC(date: Date): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function addMonthsUTC(date: Date, months: number): Date {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
return d;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, request }) => {
|
||||
const db = await getDb();
|
||||
const token = params.token || '';
|
||||
if (!token) return new Response('Not Found', { status: 404 });
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, type, user_id, group_id, revoked_at
|
||||
FROM calendar_tokens
|
||||
WHERE token_hash = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(tokenHash) as any;
|
||||
|
||||
if (!row) return new Response('Not Found', { status: 404 });
|
||||
if (row.revoked_at) return new Response('Gone', { status: 410 });
|
||||
if (String(row.type) !== 'aggregate') return new Response('Not Found', { status: 404 });
|
||||
|
||||
const today = new Date();
|
||||
const startYmd = ymdUTC(today);
|
||||
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
|
||||
|
||||
// Sin responsable en todos los grupos allowed donde el usuario esté activo
|
||||
const tasks = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.description, t.due_date, g.name AS group_name
|
||||
FROM tasks t
|
||||
INNER JOIN groups g ON g.id = t.group_id AND COALESCE(g.active,1)=1 AND COALESCE(g.archived,0)=0 AND COALESCE(g.is_community,0)=0
|
||||
INNER JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1
|
||||
INNER JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed'
|
||||
WHERE COALESCE(t.completed, 0) = 0
|
||||
AND t.due_date IS NOT NULL
|
||||
AND t.due_date >= ? AND t.due_date <= ?
|
||||
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
|
||||
ORDER BY t.due_date ASC, t.id ASC`
|
||||
)
|
||||
.all(row.user_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
|
||||
|
||||
const events = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
due_date: t.due_date,
|
||||
group_name: t.group_name || null,
|
||||
prefix: 'T'
|
||||
}));
|
||||
|
||||
const { body, etag } = await buildIcsCalendar('Tareas sin responsable (mis grupos)', events);
|
||||
|
||||
// 304 si ETag coincide
|
||||
const inm = request.headers.get('if-none-match');
|
||||
if (inm && inm === etag) {
|
||||
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
|
||||
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8',
|
||||
'cache-control': 'public, max-age=300',
|
||||
ETag: etag
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,98 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { sha256Hex } from '$lib/server/crypto';
|
||||
import { icsHorizonMonths } from '$lib/server/env';
|
||||
import { buildIcsCalendar } from '$lib/server/ics';
|
||||
|
||||
function toIsoSql(d = new Date()): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
function ymdUTC(date: Date): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function addMonthsUTC(date: Date, months: number): Date {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
return d;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, request }) => {
|
||||
const db = await getDb();
|
||||
const token = params.token || '';
|
||||
if (!token) return new Response('Not Found', { status: 404 });
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, type, user_id, group_id, revoked_at
|
||||
FROM calendar_tokens
|
||||
WHERE token_hash = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(tokenHash) as any;
|
||||
|
||||
if (!row) return new Response('Not Found', { status: 404 });
|
||||
if (row.revoked_at) return new Response('Gone', { status: 410 });
|
||||
if (String(row.type) !== 'group' || !row.group_id) return new Response('Not Found', { status: 404 });
|
||||
|
||||
// Validar estado del grupo (activo y no archivado); en caso contrario, tratar como feed caducado
|
||||
const gRow = db
|
||||
.prepare(`SELECT COALESCE(active,1) as active, COALESCE(archived,0) as archived, COALESCE(is_community,0) as is_community FROM groups WHERE id = ?`)
|
||||
.get(row.group_id) as any;
|
||||
if (!gRow || Number(gRow.active || 0) !== 1 || Number(gRow.archived || 0) === 1 || Number(gRow.is_community || 0) === 1) {
|
||||
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
|
||||
return new Response('Gone', { status: 410 });
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const startYmd = ymdUTC(today);
|
||||
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
|
||||
|
||||
const tasks = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.description, t.due_date, g.name AS group_name
|
||||
FROM tasks t
|
||||
LEFT JOIN groups g ON g.id = t.group_id
|
||||
WHERE t.group_id = ?
|
||||
AND COALESCE(t.completed, 0) = 0
|
||||
AND t.due_date IS NOT NULL
|
||||
AND t.due_date >= ? AND t.due_date <= ?
|
||||
AND NOT EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id)
|
||||
ORDER BY t.due_date ASC, t.id ASC`
|
||||
)
|
||||
.all(row.group_id, startYmd, endYmd) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
|
||||
|
||||
const events = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
due_date: t.due_date,
|
||||
group_name: t.group_name || null,
|
||||
prefix: 'T'
|
||||
}));
|
||||
|
||||
const { body, etag } = await buildIcsCalendar('Tareas sin responsable (grupo)', events);
|
||||
|
||||
// 304 si ETag coincide
|
||||
const inm = request.headers.get('if-none-match');
|
||||
if (inm && inm === etag) {
|
||||
// Actualizar last_used_at aunque sea 304
|
||||
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
|
||||
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8',
|
||||
'cache-control': 'public, max-age=300',
|
||||
ETag: etag
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,91 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { sha256Hex } from '$lib/server/crypto';
|
||||
import { icsHorizonMonths } from '$lib/server/env';
|
||||
import { buildIcsCalendar } from '$lib/server/ics';
|
||||
|
||||
function toIsoSql(d = new Date()): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
function ymdUTC(date: Date): string {
|
||||
const yyyy = String(date.getUTCFullYear()).padStart(4, '0');
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function addMonthsUTC(date: Date, months: number): Date {
|
||||
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
return d;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ params, request }) => {
|
||||
const db = await getDb();
|
||||
const token = params.token || '';
|
||||
if (!token) return new Response('Not Found', { status: 404 });
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT id, type, user_id, group_id, revoked_at
|
||||
FROM calendar_tokens
|
||||
WHERE token_hash = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(tokenHash) as any;
|
||||
|
||||
if (!row) return new Response('Not Found', { status: 404 });
|
||||
if (row.revoked_at) return new Response('Gone', { status: 410 });
|
||||
if (String(row.type) !== 'personal') return new Response('Not Found', { status: 404 });
|
||||
|
||||
const today = new Date();
|
||||
const startYmd = ymdUTC(today);
|
||||
const endYmd = ymdUTC(addMonthsUTC(today, icsHorizonMonths));
|
||||
|
||||
// "Mis tareas": asignadas al usuario; incluir privadas (group_id IS NULL) y de grupos donde esté activo y allowed.
|
||||
const tasks = db
|
||||
.prepare(
|
||||
`SELECT t.id, t.description, t.due_date, g.name AS group_name
|
||||
FROM tasks t
|
||||
LEFT JOIN groups g ON g.id = t.group_id
|
||||
LEFT JOIN group_members gm ON gm.group_id = t.group_id AND gm.user_id = ? AND gm.is_active = 1
|
||||
LEFT JOIN allowed_groups ag ON ag.group_id = t.group_id AND ag.status = 'allowed'
|
||||
WHERE COALESCE(t.completed, 0) = 0
|
||||
AND t.due_date IS NOT NULL
|
||||
AND t.due_date >= ? AND t.due_date <= ?
|
||||
AND EXISTS (SELECT 1 FROM task_assignments a WHERE a.task_id = t.id AND a.user_id = ?)
|
||||
AND (t.group_id IS NULL OR (gm.user_id IS NOT NULL AND ag.group_id IS NOT NULL AND COALESCE(g.active,1)=1 AND COALESCE(g.is_community,0)=0 AND COALESCE(g.archived,0)=0))
|
||||
ORDER BY t.due_date ASC, t.id ASC`
|
||||
)
|
||||
.all(row.user_id, startYmd, endYmd, row.user_id) as Array<{ id: number; description: string; due_date: string; group_name: string | null }>;
|
||||
|
||||
const events = tasks.map((t) => ({
|
||||
id: t.id,
|
||||
description: t.description,
|
||||
due_date: t.due_date,
|
||||
group_name: t.group_name || null,
|
||||
prefix: 'T'
|
||||
}));
|
||||
|
||||
const { body, etag } = await buildIcsCalendar('Mis tareas', events);
|
||||
|
||||
// 304 si ETag coincide
|
||||
const inm = request.headers.get('if-none-match');
|
||||
if (inm && inm === etag) {
|
||||
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
|
||||
return new Response(null, { status: 304, headers: { ETag: etag, 'Cache-Control': 'public, max-age=300' } });
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE calendar_tokens SET last_used_at = ? WHERE id = ?`).run(toIsoSql(), row.id);
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/calendar; charset=utf-8',
|
||||
'cache-control': 'public, max-age=300',
|
||||
ETag: etag
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,176 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getDb } from '$lib/server/db';
|
||||
import { sha256Hex, randomTokenBase64Url } from '$lib/server/crypto';
|
||||
import { sessionIdleTtlMs, isProd, isDev, DEV_BYPASS_AUTH } from '$lib/server/env';
|
||||
|
||||
function toIsoSql(d: Date): string {
|
||||
return d.toISOString().replace('T', ' ').replace('Z', '');
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
// GET: página intermedia que requiere una interacción mínima y establece una cookie "login_intent" vía JS.
|
||||
// Evita que bots de previsualización canjeen el token antes de que el usuario haga clic.
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
if (isDev() && DEV_BYPASS_AUTH) {
|
||||
throw redirect(303, '/app');
|
||||
}
|
||||
const token = event.url.searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
console.warn('[web/login] Solicitud sin token');
|
||||
return new Response('Falta el token', { status: 400 });
|
||||
}
|
||||
|
||||
// Nonce para "gate de JS"
|
||||
const nonce = randomTokenBase64Url(18);
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Acceder</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, 'Apple Color Emoji', 'Segoe UI Emoji'; padding: 2rem; }
|
||||
.card { max-width: 480px; margin: 0 auto; border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; }
|
||||
button[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Acceso seguro</h1>
|
||||
<p>Para continuar, pulsa “Continuar”. Si no funciona, asegúrate de abrir este enlace en tu navegador.</p>
|
||||
<form method="POST" action="/login">
|
||||
<input type="hidden" name="token" value="${escapeHtml(token)}" />
|
||||
<input type="hidden" id="nonceInput" name="nonce" value="${escapeHtml(nonce)}" />
|
||||
<button id="continueBtn" type="submit" disabled>Continuar</button>
|
||||
</form>
|
||||
<noscript>
|
||||
<p><strong>JavaScript está deshabilitado.</strong> Actívalo para continuar.</p>
|
||||
</noscript>
|
||||
</div>
|
||||
<script>
|
||||
// Establecer cookie de intención con el nonce y habilitar el botón.
|
||||
try {
|
||||
var nonce = ${JSON.stringify(nonce)};
|
||||
var cookie = 'login_intent=' + encodeURIComponent(nonce) + '; Path=/; Max-Age=600; SameSite=Lax';
|
||||
if (location.protocol === 'https:') cookie += '; Secure';
|
||||
document.cookie = cookie;
|
||||
var btn = document.getElementById('continueBtn');
|
||||
if (btn) btn.removeAttribute('disabled');
|
||||
} catch {}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store, max-age=0'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// POST: canje real del token (uso único). Crea sesión y redirige a /app.
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
if (isDev() && DEV_BYPASS_AUTH) {
|
||||
throw redirect(303, '/app');
|
||||
}
|
||||
const form = await event.request.formData();
|
||||
const token = String(form.get('token') || '').trim();
|
||||
if (!token) {
|
||||
console.warn('[web/login] POST sin token');
|
||||
return new Response('Falta el token', { status: 400 });
|
||||
}
|
||||
|
||||
// Validación del "gate de JS": cookie + nonce deben coincidir
|
||||
const nonce = String(form.get('nonce') || '').trim();
|
||||
const loginIntent = event.cookies.get('login_intent') || '';
|
||||
if (!nonce || !loginIntent || nonce !== loginIntent) {
|
||||
console.warn('[web/login] Falta o no cuadra login_intent; posible previsualizador sin JS.');
|
||||
return new Response('Solicitud inválida', { status: 400 });
|
||||
}
|
||||
|
||||
const tokenHash = await sha256Hex(token);
|
||||
const db = await getDb();
|
||||
|
||||
// Intentar canjear el token (un solo uso, no caducado)
|
||||
const res = db
|
||||
.prepare(
|
||||
`UPDATE web_tokens
|
||||
SET used_at = strftime('%Y-%m-%d %H:%M:%f','now')
|
||||
WHERE token_hash = ?
|
||||
AND used_at IS NULL
|
||||
AND expires_at > strftime('%Y-%m-%d %H:%M:%f','now')`
|
||||
)
|
||||
.run(tokenHash);
|
||||
|
||||
const changes = Number(res?.changes || 0);
|
||||
if (changes < 1) {
|
||||
console.warn('[web/login] Token no canjeado (0 cambios). Posible caducado/ya usado/no existe.');
|
||||
return new Response('Enlace inválido o caducado', { status: 400 });
|
||||
}
|
||||
|
||||
// Recuperar el user_id asociado
|
||||
const row = db
|
||||
.prepare(`SELECT user_id FROM web_tokens WHERE token_hash = ?`)
|
||||
.get(tokenHash) as { user_id: string } | null;
|
||||
|
||||
const userId = row?.user_id?.trim();
|
||||
if (!userId) {
|
||||
return new Response('Token canjeado pero usuario no encontrado', { status: 500 });
|
||||
}
|
||||
|
||||
// Crear sesión
|
||||
const sessionToken = randomTokenBase64Url(32);
|
||||
const sessionHash = await sha256Hex(sessionToken);
|
||||
const sessionId = randomTokenBase64Url(16);
|
||||
const expiresAtIso = toIsoSql(new Date(Date.now() + sessionIdleTtlMs));
|
||||
|
||||
// Datos de agente e IP (best-effort)
|
||||
const userAgent = event.request.headers.get('user-agent') || null;
|
||||
let ip: string | null = null;
|
||||
try {
|
||||
// SvelteKit 2: getClientAddress en adapters compatibles
|
||||
// @ts-ignore
|
||||
if (typeof event.getClientAddress === 'function') {
|
||||
// @ts-ignore
|
||||
ip = event.getClientAddress() || null;
|
||||
}
|
||||
} catch { }
|
||||
if (!ip) {
|
||||
const fwd = event.request.headers.get('x-forwarded-for');
|
||||
ip = fwd ? fwd.split(',')[0].trim() : null;
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO web_sessions (id, user_id, session_hash, expires_at, user_agent, ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(sessionId, userId, sessionHash, expiresAtIso, userAgent, ip);
|
||||
|
||||
// Cookie de sesión
|
||||
event.cookies.set('sid', sessionToken, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: isProd(),
|
||||
maxAge: Math.floor(sessionIdleTtlMs / 1000)
|
||||
});
|
||||
|
||||
// Eliminar cookie de intento (ya no es necesaria)
|
||||
event.cookies.delete('login_intent', { path: '/' });
|
||||
|
||||
// Redirigir a /app
|
||||
throw redirect(303, '/app');
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 100.6" style="enable-background:new 0 0 122.88 100.6" xml:space="preserve"><style type="text/css">.st0{fill:#272727;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#D8453E;}</style><g><path class="st0" d="M72.58,0c6.8,0,13.3,1.36,19.23,3.81c6.16,2.55,11.7,6.29,16.33,10.92l0,0c4.63,4.63,8.37,10.17,10.92,16.34 c2.46,5.93,3.81,12.43,3.81,19.23c0,6.8-1.36,13.3-3.81,19.23c-2.55,6.16-6.29,11.7-10.92,16.33l0,0 c-4.63,4.63-10.17,8.37-16.34,10.92c-5.93,2.46-12.43,3.81-19.23,3.81c-6.8,0-13.3-1.36-19.23-3.81 c-6.15-2.55-11.69-6.28-16.33-10.92l-0.01-0.01c-4.64-4.64-8.37-10.17-10.92-16.33c-0.79-1.91-1.47-3.87-2.02-5.89 c1.05,0.1,2.12,0.15,3.2,0.15c2.05,0,4.05-0.19,6-0.54c0.32,0.97,0.67,1.93,1.06,2.87c2.09,5.05,5.17,9.6,8.99,13.43 c3.82,3.82,8.38,6.9,13.43,8.99c4.87,2.02,10.21,3.13,15.83,3.13c5.62,0,10.96-1.11,15.83-3.13c5.05-2.09,9.6-5.17,13.43-8.99 c3.82-3.82,6.9-8.38,8.99-13.43c2.02-4.87,3.13-10.21,3.13-15.83c0-5.62-1.11-10.96-3.13-15.83c-2.09-5.05-5.17-9.6-8.99-13.43 c-3.82-3.82-8.38-6.9-13.43-8.99c-4.87-2.02-10.21-3.13-15.83-3.13c-5.62,0-10.96,1.11-15.83,3.13c-0.44,0.18-0.87,0.37-1.3,0.56 c-1.65-2.61-3.66-4.97-5.95-7.02c1.25-0.65,2.53-1.24,3.84-1.79C59.28,1.36,65.78,0,72.58,0L72.58,0z M66.8,26.39 c0-1.23,0.5-2.35,1.31-3.16c0.81-0.81,1.93-1.31,3.16-1.31c1.23,0,2.35,0.5,3.16,1.31c0.81,0.81,1.31,1.93,1.31,3.16v23.47 l17.54,10.4c1.05,0.62,1.76,1.62,2.05,2.73c0.28,1.1,0.15,2.31-0.47,3.37l0,0.01l0,0c-0.62,1.05-1.62,1.76-2.73,2.05 c-1.1,0.28-2.31,0.15-3.37-0.47l-0.01,0l0,0L69.1,56.29c-0.67-0.38-1.24-0.92-1.64-1.57c-0.42-0.68-0.66-1.48-0.66-2.32V26.39 L66.8,26.39z"/><path class="st1" d="M27.27,3.18c15.06,0,27.27,12.21,27.27,27.27c0,15.06-12.21,27.27-27.27,27.27C12.21,57.73,0,45.52,0,30.45 C0,15.39,12.21,3.18,27.27,3.18L27.27,3.18z M24.35,41.34h5.82v5.16h-5.82V41.34L24.35,41.34L24.35,41.34z M30.17,37.77h-5.82 c-0.58-7.07-1.8-11.56-1.8-18.63c0-2.61,2.12-4.72,4.72-4.72c2.61,0,4.72,2.12,4.72,4.72C32,26.2,30.76,30.7,30.17,37.77 L30.17,37.77L30.17,37.77L30.17,37.77z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 106.86 122.88" style="enable-background:new 0 0 106.86 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M39.62,64.58c-1.46,0-2.64-1.41-2.64-3.14c0-1.74,1.18-3.14,2.64-3.14h34.89c1.46,0,2.64,1.41,2.64,3.14 c0,1.74-1.18,3.14-2.64,3.14H39.62L39.62,64.58z M46.77,116.58c1.74,0,3.15,1.41,3.15,3.15c0,1.74-1.41,3.15-3.15,3.15H7.33 c-2.02,0-3.85-0.82-5.18-2.15C0.82,119.4,0,117.57,0,115.55V7.33c0-2.02,0.82-3.85,2.15-5.18C3.48,0.82,5.31,0,7.33,0h90.02 c2.02,0,3.85,0.83,5.18,2.15c1.33,1.33,2.15,3.16,2.15,5.18v50.14c0,1.74-1.41,3.15-3.15,3.15c-1.74,0-3.15-1.41-3.15-3.15V7.33 c0-0.28-0.12-0.54-0.31-0.72c-0.19-0.19-0.44-0.31-0.72-0.31H7.33c-0.28,0-0.54,0.12-0.73,0.3C6.42,6.8,6.3,7.05,6.3,7.33v108.21 c0,0.28,0.12,0.54,0.3,0.72c0.19,0.19,0.45,0.31,0.73,0.31H46.77L46.77,116.58z M98.7,74.34c-0.51-0.49-1.1-0.72-1.78-0.71 c-0.68,0.01-1.26,0.27-1.74,0.78l-3.91,4.07l10.97,10.59l3.95-4.11c0.47-0.48,0.67-1.1,0.66-1.78c-0.01-0.67-0.25-1.28-0.73-1.74 L98.7,74.34L98.7,74.34z M78.21,114.01c-1.45,0.46-2.89,0.94-4.33,1.41c-1.45,0.48-2.89,0.97-4.33,1.45 c-3.41,1.12-5.32,1.74-5.72,1.85c-0.39,0.12-0.16-1.48,0.7-4.81l2.71-10.45l0,0l20.55-21.38l10.96,10.55L78.21,114.01L78.21,114.01 z M39.62,86.95c-1.46,0-2.65-1.43-2.65-3.19c0-1.76,1.19-3.19,2.65-3.19h17.19c1.46,0,2.65,1.43,2.65,3.19 c0,1.76-1.19,3.19-2.65,3.19H39.62L39.62,86.95z M39.62,42.26c-1.46,0-2.64-1.41-2.64-3.14c0-1.74,1.18-3.14,2.64-3.14h34.89 c1.46,0,2.64,1.41,2.64,3.14c0,1.74-1.18,3.14-2.64,3.14H39.62L39.62,42.26z M24.48,79.46c2.06,0,3.72,1.67,3.72,3.72 c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72C20.76,81.13,22.43,79.46,24.48,79.46L24.48,79.46z M24.48,57.44 c2.06,0,3.72,1.67,3.72,3.72c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72C20.76,59.11,22.43,57.44,24.48,57.44 L24.48,57.44z M24.48,35.42c2.06,0,3.72,1.67,3.72,3.72c0,2.06-1.67,3.72-3.72,3.72c-2.06,0-3.72-1.67-3.72-3.72 C20.76,37.08,22.43,35.42,24.48,35.42L24.48,35.42z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 108.01 122.88"><defs><style>.cls-1{fill-rule:evenodd;}</style></defs><title>emergency-exit</title><path class="cls-1" d="M.5,0H15a.51.51,0,0,1,.5.5V83.38L35.16,82h.22l.24,0c2.07-.14,3.65-.26,4.73-1.23l1.86-2.17a1.12,1.12,0,0,1,1.49-.18l9.35,6.28a1.15,1.15,0,0,1,.49,1c0,.55-.19.7-.61,1.08A11.28,11.28,0,0,0,51.78,88a27.27,27.27,0,0,1-3,3.1,15.84,15.84,0,0,1-3.68,2.45c-2.8,1.36-5.45,1.54-8.59,1.76l-.24,0-.21,0L15.5,96.77v25.61a.52.52,0,0,1-.5.5H.5a.51.51,0,0,1-.5-.5V.5A.5.5,0,0,1,.5,0ZM46,59.91l9-19.12-.89-.25a12.43,12.43,0,0,0-4.77-.82c-1.9.28-3.68,1.42-5.67,2.7-.83.53-1.69,1.09-2.62,1.63-.7.33-1.51.86-2.19,1.25l-8.7,5a1.11,1.11,0,0,1-1.51-.42l-5.48-9.64a1.1,1.1,0,0,1,.42-1.51c3.43-2,7.42-4,10.75-6.14,4-2.49,7.27-4.48,11.06-5.42s8-.8,13.89,1c2.12.59,4.55,1.48,6.55,2.2,1,.35,1.8.66,2.44.87,9.86,3.29,13.19,9.66,15.78,14.6,1.12,2.13,2.09,4,3.34,5,.51.42,1.67.27,3,.09a21.62,21.62,0,0,1,2.64-.23c4.32-.41,8.66-.66,13-1a1.1,1.1,0,0,1,1.18,1L108,61.86A1.11,1.11,0,0,1,107,63L95,63.9c-5.33.38-9.19.66-15-2.47l-.12-.07a23.23,23.23,0,0,1-7.21-8.5l0,0L65.73,68.4a63.9,63.9,0,0,0,5.85,5.32c6,5,11,9.21,9.38,20.43a23.89,23.89,0,0,1-.65,2.93c-.27,1-.56,1.9-.87,2.84-2.29,6.54-4.22,13.5-6.29,20.13a1.1,1.1,0,0,1-1,.81l-11.66.78a1,1,0,0,1-.39,0,1.12,1.12,0,0,1-.75-1.38c2.45-8.12,5-16.25,7.39-24.38a29,29,0,0,0,.87-3,7,7,0,0,0,.08-2.65l0-.24a4.16,4.16,0,0,0-.73-2.22,53.23,53.23,0,0,0-8.76-5.57c-3.75-2.07-7.41-4.08-10.25-7a12.15,12.15,0,0,1-3.59-7.36A14.76,14.76,0,0,1,46,59.91ZM80.07,6.13a12.29,12.29,0,0,1,13.1,11.39v0a12.29,12.29,0,0,1-24.52,1.72v0A12.3,12.3,0,0,1,80,6.13ZM3.34,35H6.69V51.09H3.34V35Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 91.99" style="enable-background:new 0 0 122.88 91.99" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M45.13,35.29h-0.04c-7.01-0.79-16.42,0.01-20.78,0C17.04,35.6,9.47,41.91,5.02,51.3 c-2.61,5.51-3.3,9.66-3.73,15.55C0.42,72.79-0.03,78.67,0,84.47c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2 c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.39,2.78h31.49l-0.42-3.1l0.61-36.67 c3.2-1.29,5.96-1.89,8.39-1.99c-0.12,0.25-0.25,0.5-0.37,0.75c-2.61,5.51-3.3,9.66-3.73,15.55c-0.86,5.93-1.32,11.81-1.29,17.61 c1.43,9.03,12.88,6.35,13.85,0l1.39-18.2c0.21-2.75,0.4-4.61,1.51-7.23c0.52-1.23,1.15-2.28,1.89-3.15l0.69,33.25l-0.46,3.24h31.62 l-0.48-3.55l0.49-28.62v0.56l0.1-4.87c0.74,0.87,1.36,1.92,1.89,3.15c1.12,2.62,1.3,4.48,1.51,7.23l1.39,18.2 c1.34,8.68,13.85,8.85,13.85,0c0.03-5.81-0.42-11.68-1.29-17.61c-0.43-5.89-1.12-10.04-3.73-15.55 c-4.57-9.65-10.48-14.76-19.45-15.81c-5.53-0.45-14.82,0.06-20.36-0.1c-1.38,0.19-2.74,0.47-4.06,0.87 c-3.45-0.48-8.01-1.07-12.56-1.09C54.76,34.77,48.15,35.91,45.13,35.29L45.13,35.29z M88.3,0c9.01,0,16.32,7.31,16.32,16.32 c0,9.01-7.31,16.32-16.32,16.32c-9.01,0-16.32-7.31-16.32-16.32C71.98,7.31,79.29,0,88.3,0L88.3,0z M34.56,0 c9.01,0,16.32,7.31,16.32,16.32c0,9.01-7.31,16.32-16.32,16.32s-16.32-7.31-16.32-16.32C18.24,7.31,25.55,0,34.56,0L34.56,0z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 121.2 122.88" style="enable-background:new 0 0 121.2 122.88" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M66.17,24.52c9.78,12.13,14.55,26.46,13.93,39.58c-4.52-11.08-11.54-22.31-20.85-32.65l-3.12,3.12 c-0.35,0.35-0.93,0.35-1.28,0l-9.87-9.87c-0.35-0.35-0.35-0.93,0-1.28l3.03-3.03C37.4,11.2,25.96,4.37,14.75,0.13 c13.22-0.99,27.81,3.57,40.2,13.31l1.36-1.36c0.35-0.35,0.93-0.35,1.28,0l9.87,9.87c0.35,0.35,0.35,0.93,0,1.28L66.17,24.52 L66.17,24.52z M49.32,58.69v-4.05l19.11,2.04L49.32,58.69L49.32,58.69z M57.83,74.36l-2.87-2.87l14.96-12.07L57.83,74.36 L57.83,74.36z M111.77,35.18l2.32,5.02l-24.85,8.4L111.77,35.18L111.77,35.18z M92.26,20.63l5.19,1.91L85.82,46.05L92.26,20.63 L92.26,20.63z M102.7,57.6l18.5,11.47v53.81H25.73c19.44-19.44,46.04-25.61,61.42-52.24C92.99,60.52,91.01,49.7,102.7,57.6 L102.7,57.6z M44.6,27.81l7.99,7.99L9.64,78.76c-2.2,2.2-5.8,2.2-7.99,0l0,0c-2.2-2.2-2.2-5.8,0-7.99L44.6,27.81L44.6,27.81z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 99.56" style="enable-background:new 0 0 122.88 99.56" xml:space="preserve"><style type="text/css">.st0{fill:#393939;} .st1{fill-rule:evenodd;clip-rule:evenodd;fill:#38AE48;}</style><g><path class="st0" d="M73.1,0c6.73,0,13.16,1.34,19.03,3.78c6.09,2.52,11.57,6.22,16.16,10.81c4.59,4.58,8.28,10.06,10.81,16.17 c2.43,5.87,3.78,12.3,3.78,19.03c0,6.73-1.34,13.16-3.78,19.03c-2.52,6.09-6.22,11.58-10.81,16.16 c-4.58,4.59-10.06,8.28-16.17,10.81c-5.87,2.43-12.3,3.78-19.03,3.78c-6.73,0-13.16-1.34-19.03-3.77 c-6.09-2.52-11.57-6.22-16.16-10.81l-0.01-0.01c-4.59-4.59-8.29-10.07-10.81-16.16c-0.78-1.89-1.45-3.83-2-5.82 c1.04,0.1,2.1,0.15,3.17,0.15c2.03,0,4.01-0.18,5.94-0.53c0.32,0.96,0.67,1.91,1.05,2.84c2.07,5,5.11,9.51,8.9,13.29 c3.78,3.78,8.29,6.82,13.29,8.9c4.81,1.99,10.11,3.1,15.66,3.1c5.56,0,10.85-1.1,15.66-3.1c5-2.07,9.51-5.11,13.29-8.9 c3.78-3.78,6.82-8.29,8.9-13.29c1.99-4.81,3.1-10.11,3.1-15.66c0-5.56-1.1-10.85-3.1-15.66c-2.07-5-5.11-9.51-8.9-13.29 c-3.78-3.78-8.29-6.82-13.29-8.9c-4.81-1.99-10.11-3.1-15.66-3.1c-5.56,0-10.85,1.1-15.66,3.1c-0.43,0.18-0.86,0.37-1.28,0.56 c-1.64-2.58-3.62-4.92-5.89-6.95c1.24-0.64,2.51-1.23,3.8-1.77C59.94,1.34,66.37,0,73.1,0L73.1,0z M67.38,26.12 c0-1.22,0.5-2.33,1.3-3.13c0.8-0.8,1.9-1.3,3.12-1.3c1.22,0,2.33,0.5,3.13,1.3c0.8,0.8,1.3,1.91,1.3,3.13v23.22l17.35,10.29 c1.04,0.62,1.74,1.6,2.03,2.7c0.28,1.09,0.15,2.29-0.47,3.34c-0.62,1.04-1.6,1.74-2.7,2.03c-1.09,0.28-2.29,0.15-3.33-0.47 L69.65,55.71c-0.67-0.37-1.22-0.91-1.62-1.55c-0.41-0.67-0.65-1.46-0.65-2.3V26.12L67.38,26.12z"/><path class="st1" d="M26.99,2.56c14.91,0,26.99,12.08,26.99,26.99c0,14.91-12.08,26.99-26.99,26.99C12.08,56.54,0,44.45,0,29.55 C0,14.64,12.08,2.56,26.99,2.56L26.99,2.56z M15.05,30.27c0.36-2.1,2.76-3.27,4.65-2.13c0.17,0.1,0.34,0.22,0.49,0.36l0.02,0.01 c0.85,0.81,1.8,1.66,2.74,2.5l0.81,0.73l9.59-10.06c0.57-0.6,0.99-0.99,1.85-1.18c2.94-0.65,5.01,2.95,2.93,5.15L26.17,38.19 c-1.13,1.2-3.14,1.31-4.35,0.16c-0.69-0.64-1.45-1.3-2.21-1.96c-1.32-1.15-2.67-2.32-3.77-3.48 C15.18,32.25,14.89,31.17,15.05,30.27L15.05,30.27z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="105.765px" height="122.88px" viewBox="0 0 105.765 122.88" enable-background="new 0 0 105.765 122.88" xml:space="preserve"><g><path d="M82.872,90.81c-2.983-8.16-7.707-14.175-13.283-18.06c-3.772-2.629-7.914-4.284-12.133-4.97 c-4.236-0.686-8.583-0.408-12.747,0.828C35.573,71.323,27.33,78.716,22.903,90.81H82.872L82.872,90.81z M20.618,27.21h64.535 c0.346-2.922,1.154-13.713,1.119-16.995H19.497C19.462,13.498,20.27,24.288,20.618,27.21L20.618,27.21L20.618,27.21z M0.91,112.665 h9.567C10.222,85.12,22.648,68.03,38.027,61.466C22.637,54.9,10.205,37.79,10.478,10.214l-9.567,0c-0.501,0-0.909-0.46-0.909-1.025 L0,1.024C0,0.46,0.409,0,0.91,0h103.944c0.5,0,0.91,0.46,0.91,1.024v8.164c0,0.563-0.41,1.024-0.91,1.024h-9.57 c0.225,23.214-8.581,39.038-20.546,47.376c-2.188,1.522-4.543,2.832-6.994,3.873c2.446,1.049,4.81,2.354,6.992,3.88 c11.955,8.332,20.756,24.139,20.546,47.321l9.572,0.001c0.5,0,0.91,0.463,0.91,1.026v8.162c0,0.564-0.41,1.027-0.91,1.027H0.91 c-0.501,0-0.909-0.463-0.909-1.026v-8.162C0.001,113.128,0.41,112.665,0.91,112.665L0.91,112.665L0.91,112.665z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 406.6"><path d="M334.1 1.64a202.504 202.504 0 0 1 135.16 77.02c68.84 88.6 52.82 216.19-35.78 285.03-.08.05-.14.11-.22.18-88.57 68.82-216.15 52.81-284.97-35.76-.04-.06-.09-.12-.14-.17A204.822 204.822 0 0 1 125.31 291a168.69 168.69 0 0 0 37.79-5.42 172.61 172.61 0 0 0 13.55 20.29c56.7 72.81 161.67 85.86 234.46 29.15 72.8-56.69 85.84-161.66 29.15-234.46-40.28-51.71-107.08-75.09-170.82-59.79a171.08 171.08 0 0 0-21.88-31.29c2.46-.8 4.95-1.51 7.46-2.21 25.77-7.13 52.69-9.03 79.19-5.63h-.11zM0 129.16v-15.4C3.97 50.8 56.26.95 120.21.92h.05c66.58-.01 120.55 53.93 120.59 120.51.03 66.58-53.93 120.56-120.51 120.59C56.33 242.04 3.97 192.17 0 129.16zm106.56 31.56h27.62v24.45h-27.62v-24.45zm27.6-14.21h-27.6c-2.75-33.56-8.53-32.84-8.53-66.35 0-12.37 10.03-22.39 22.39-22.39 12.36 0 22.4 10.02 22.4 22.39 0 33.49-5.85 32.83-8.66 66.35zm163.46-42c1.24-9.88 10.24-16.88 20.09-15.64h.04c9.82 1.32 16.73 10.32 15.46 20.13l-11.7 94.09 65.06 50.55c7.85 6.1 9.3 17.4 3.2 25.28a18.011 18.011 0 0 1-11.95 6.82c-4.73.62-9.51-.68-13.26-3.62l-72.82-56.61a17.818 17.818 0 0 1-5.79-7.08 18.336 18.336 0 0 1-1.46-9.67l13.13-104.2v-.05z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122.88 112.9"><title>time-period</title><path d="M35.69,101.21a24.08,24.08,0,0,0-4.23-11.35c-2.27-3.17-5.22-5.33-8.32-5.33s-6.06,2.16-8.33,5.33a24.08,24.08,0,0,0-4.23,11.35Zm78.39-73.63a4.17,4.17,0,0,0-7.37,3.81,4.68,4.68,0,0,0,.37.7,44,44,0,0,1,3.6,6.74,4.17,4.17,0,0,0,7.94-2.29,4.32,4.32,0,0,0-.3-1,52.05,52.05,0,0,0-4.24-7.93ZM107.14,16.5a4.63,4.63,0,0,1-3.23,5.18L91.54,25.46a4.63,4.63,0,1,1-2.69-8.86L90,16.24A47,47,0,0,0,22.46,44.49H13.84A55.33,55.33,0,0,1,94.7,9.33l-1.16-3A4.64,4.64,0,1,1,102.22,3l4.62,12.09a4.81,4.81,0,0,1,.3,1.42ZM67.6,104.55a53.52,53.52,0,0,0,9.43-.87,4.17,4.17,0,0,1,1,8.25,61.44,61.44,0,0,1-7.38.94c-1.31.06-3,0-4.34,0a55.19,55.19,0,0,1-10.91-1.33V103a46.85,46.85,0,0,0,12.15,1.59Zm23.25-6a4.17,4.17,0,1,0,4.09,7.26,55.27,55.27,0,0,0,7.46-5.06,4.17,4.17,0,0,0-3.89-7.21,4.07,4.07,0,0,0-1.34.73,47.39,47.39,0,0,1-6.32,4.28Zm16.42-15.64a4.16,4.16,0,1,0,7.06,4.41,55.51,55.51,0,0,0,4.15-8,4.17,4.17,0,0,0-7.15-4.14,4.11,4.11,0,0,0-.54.93,46,46,0,0,1-3.52,6.79Zm7.13-21.62a4.17,4.17,0,0,0,8.16,1.46,3.91,3.91,0,0,0,.15-.83,56.09,56.09,0,0,0,0-9,4.16,4.16,0,1,0-8.3.69,47.78,47.78,0,0,1,0,7.66ZM59.12,35a4.29,4.29,0,0,1,8.57,0V61.09l17.84,7.85a4.28,4.28,0,1,1-3.44,7.83L61.91,67.9a4.29,4.29,0,0,1-2.79-4V35ZM12.59,70.51h21.1a20.92,20.92,0,0,0,2-7H10.56a20.7,20.7,0,0,0,2,7ZM2.47,105.83a2.09,2.09,0,1,1,0-4.1H5.55a28.67,28.67,0,0,1,5.13-14.44,19.38,19.38,0,0,1,6.1-5.67,18.41,18.41,0,0,1-6.17-5.21,24.83,24.83,0,0,1-5.07-14H2.61a2.09,2.09,0,1,1,0-4.1H43.93a2.09,2.09,0,1,1,0,4.1h-3.2a24.83,24.83,0,0,1-5.07,14,18.41,18.41,0,0,1-6.17,5.21,19.38,19.38,0,0,1,6.1,5.67,28.67,28.67,0,0,1,5.13,14.44H43.8a2.09,2.09,0,1,1,0,4.1H2.47Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,21 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter({ precompress: false }),
|
||||
csrf: {
|
||||
trustedOrigins: ['*']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isDev = mode === 'development';
|
||||
|
||||
return {
|
||||
plugins: [sveltekit()],
|
||||
resolve: {
|
||||
// En desarrollo, alias para usar better-sqlite3 (Vite/HMR no entiende 'bun:sqlite')
|
||||
alias: isDev ? { 'bun:sqlite': 'better-sqlite3' } : {}
|
||||
},
|
||||
ssr: {
|
||||
// En dev, externalizar better-sqlite3 (CJS nativo) para que se cargue vía require;
|
||||
// en producción, externalizar 'bun:sqlite' y que lo resuelva Bun en runtime.
|
||||
external: isDev ? ['better-sqlite3'] : ['bun:sqlite']
|
||||
},
|
||||
optimizeDeps: {
|
||||
// Evitar prebundling de drivers nativos
|
||||
exclude: ['bun:sqlite', 'better-sqlite3']
|
||||
},
|
||||
// Permitir host remoto en desarrollo
|
||||
server: isDev ? { allowedHosts: ['server.brobert.net'] } : undefined
|
||||
};
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
workspaces = ["apps/*"]
|
||||