Mega push vol1
This commit is contained in:
31
package-lock.json
generated
31
package-lock.json
generated
@@ -13,7 +13,8 @@
|
|||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-placeholder": "^3.19.0",
|
"@tiptap/extension-placeholder": "^3.19.0",
|
||||||
"@tiptap/pm": "^3.19.0",
|
"@tiptap/pm": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0"
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
|
"lucide-svelte": "^0.563.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.2",
|
||||||
@@ -479,7 +480,6 @@
|
|||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
@@ -490,7 +490,6 @@
|
|||||||
"version": "2.3.5",
|
"version": "2.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/gen-mapping": "^0.3.5",
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
@@ -501,7 +500,6 @@
|
|||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -511,14 +509,12 @@
|
|||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
"version": "0.3.31",
|
"version": "0.3.31",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/resolve-uri": "^3.1.0",
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
@@ -1101,7 +1097,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
|
||||||
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
|
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"acorn": "^8.9.0"
|
"acorn": "^8.9.0"
|
||||||
@@ -1937,7 +1932,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/linkify-it": {
|
"node_modules/@types/linkify-it": {
|
||||||
@@ -2166,7 +2160,6 @@
|
|||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2186,7 +2179,6 @@
|
|||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
|
||||||
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -2206,7 +2198,6 @@
|
|||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -2242,7 +2233,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -2308,7 +2298,6 @@
|
|||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
|
||||||
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
@@ -2402,14 +2391,12 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
|
||||||
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/esrap": {
|
"node_modules/esrap": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
|
||||||
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
|
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||||
@@ -2837,14 +2824,21 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-svelte": {
|
||||||
|
"version": "0.563.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.563.0.tgz",
|
||||||
|
"integrity": "sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
@@ -3449,7 +3443,6 @@
|
|||||||
"version": "5.49.1",
|
"version": "5.49.1",
|
||||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
|
||||||
"integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
|
"integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3501,7 +3494,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||||
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
@@ -3878,7 +3870,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@tiptap/core": "^3.19.0",
|
"@tiptap/core": "^3.19.0",
|
||||||
"@tiptap/extension-placeholder": "^3.19.0",
|
"@tiptap/extension-placeholder": "^3.19.0",
|
||||||
"@tiptap/pm": "^3.19.0",
|
"@tiptap/pm": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0"
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
|
"lucide-svelte": "^0.563.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,95 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { Editor } from '@tiptap/core';
|
import { Editor } from "@tiptap/core";
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Placeholder from '@tiptap/extension-placeholder';
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import type { Document } from "$lib/supabase/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
document?: Document | null;
|
||||||
content?: object | null;
|
content?: object | null;
|
||||||
editable?: boolean;
|
editable?: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
onUpdate?: (content: object) => void;
|
onUpdate?: (content: object) => void;
|
||||||
onSave?: () => void;
|
onSave?: (content: object) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
document = null,
|
||||||
content = null,
|
content = null,
|
||||||
editable = true,
|
editable = true,
|
||||||
placeholder = 'Start writing...',
|
placeholder = "Start writing...",
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onSave
|
onSave,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Use document content if provided, otherwise use content prop
|
||||||
|
const initialContent = $derived(document?.content ?? content);
|
||||||
|
|
||||||
let element: HTMLDivElement;
|
let element: HTMLDivElement;
|
||||||
let editor: Editor | null = $state(null);
|
let editor: Editor | null = $state(null);
|
||||||
|
|
||||||
|
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function triggerAutoSave() {
|
||||||
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
|
saveTimeout = setTimeout(() => {
|
||||||
|
if (editor && onSave) {
|
||||||
|
onSave(editor.getJSON());
|
||||||
|
}
|
||||||
|
}, 1000); // Auto-save after 1 second of inactivity
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
editor = new Editor({
|
editor = new Editor({
|
||||||
element,
|
element,
|
||||||
extensions: [
|
extensions: [StarterKit, Placeholder.configure({ placeholder })],
|
||||||
StarterKit,
|
content: (initialContent as object) ?? undefined,
|
||||||
Placeholder.configure({ placeholder })
|
|
||||||
],
|
|
||||||
content: content ?? undefined,
|
|
||||||
editable,
|
editable,
|
||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
onUpdate?.(editor.getJSON());
|
const json = editor.getJSON();
|
||||||
|
onUpdate?.(json);
|
||||||
|
if (editable) triggerAutoSave();
|
||||||
},
|
},
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: 'prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4'
|
class: "prose prose-invert max-w-none focus:outline-none min-h-[200px] p-4",
|
||||||
},
|
},
|
||||||
handleKeyDown: (view, event) => {
|
handleKeyDown: (view, event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
if ((event.ctrlKey || event.metaKey) && event.key === "s") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onSave?.();
|
if (editor && onSave) onSave(editor.getJSON());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
editor?.destroy();
|
editor?.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update editor when document changes
|
||||||
|
$effect(() => {
|
||||||
|
if (editor && initialContent) {
|
||||||
|
const currentContent = JSON.stringify(editor.getJSON());
|
||||||
|
const newContent = JSON.stringify(initialContent);
|
||||||
|
if (currentContent !== newContent) {
|
||||||
|
editor.commands.setContent(initialContent as object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update editable state when prop changes
|
||||||
|
$effect(() => {
|
||||||
|
if (editor) {
|
||||||
|
editor.setEditable(editable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export function setContent(newContent: object | null) {
|
export function setContent(newContent: object | null) {
|
||||||
if (editor && newContent) {
|
if (editor && newContent) {
|
||||||
editor.commands.setContent(newContent);
|
editor.commands.setContent(newContent);
|
||||||
@@ -72,14 +107,22 @@
|
|||||||
|
|
||||||
<div class="bg-surface rounded-xl border border-light/10 overflow-hidden">
|
<div class="bg-surface rounded-xl border border-light/10 overflow-hidden">
|
||||||
{#if editable}
|
{#if editable}
|
||||||
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50">
|
<div
|
||||||
|
class="flex items-center gap-1 px-2 py-1.5 border-b border-light/10 bg-dark/50"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleBold().run()}
|
onclick={() => editor?.chain().focus().toggleBold().run()}
|
||||||
class:text-primary={editor?.isActive('bold')}
|
class:text-primary={editor?.isActive("bold")}
|
||||||
title="Bold (Ctrl+B)"
|
title="Bold (Ctrl+B)"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||||
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -87,10 +130,16 @@
|
|||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
onclick={() => editor?.chain().focus().toggleItalic().run()}
|
||||||
class:text-primary={editor?.isActive('italic')}
|
class:text-primary={editor?.isActive("italic")}
|
||||||
title="Italic (Ctrl+I)"
|
title="Italic (Ctrl+I)"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="19" y1="4" x2="10" y2="4" />
|
<line x1="19" y1="4" x2="10" y2="4" />
|
||||||
<line x1="14" y1="20" x2="5" y2="20" />
|
<line x1="14" y1="20" x2="5" y2="20" />
|
||||||
<line x1="15" y1="4" x2="9" y2="20" />
|
<line x1="15" y1="4" x2="9" y2="20" />
|
||||||
@@ -99,10 +148,16 @@
|
|||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
onclick={() => editor?.chain().focus().toggleStrike().run()}
|
||||||
class:text-primary={editor?.isActive('strike')}
|
class:text-primary={editor?.isActive("strike")}
|
||||||
title="Strikethrough"
|
title="Strikethrough"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<path d="M16 4H9a3 3 0 0 0-2.83 4" />
|
<path d="M16 4H9a3 3 0 0 0-2.83 4" />
|
||||||
<path d="M14 12a4 4 0 0 1 0 8H6" />
|
<path d="M14 12a4 4 0 0 1 0 8H6" />
|
||||||
<line x1="4" y1="12" x2="20" y2="12" />
|
<line x1="4" y1="12" x2="20" y2="12" />
|
||||||
@@ -111,24 +166,27 @@
|
|||||||
<div class="w-px h-5 bg-light/20 mx-1"></div>
|
<div class="w-px h-5 bg-light/20 mx-1"></div>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
onclick={() =>
|
||||||
class:text-primary={editor?.isActive('heading', { level: 1 })}
|
editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
class:text-primary={editor?.isActive("heading", { level: 1 })}
|
||||||
title="Heading 1"
|
title="Heading 1"
|
||||||
>
|
>
|
||||||
<span class="text-xs font-bold">H1</span>
|
<span class="text-xs font-bold">H1</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
onclick={() =>
|
||||||
class:text-primary={editor?.isActive('heading', { level: 2 })}
|
editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
class:text-primary={editor?.isActive("heading", { level: 2 })}
|
||||||
title="Heading 2"
|
title="Heading 2"
|
||||||
>
|
>
|
||||||
<span class="text-xs font-bold">H2</span>
|
<span class="text-xs font-bold">H2</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
onclick={() =>
|
||||||
class:text-primary={editor?.isActive('heading', { level: 3 })}
|
editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
class:text-primary={editor?.isActive("heading", { level: 3 })}
|
||||||
title="Heading 3"
|
title="Heading 3"
|
||||||
>
|
>
|
||||||
<span class="text-xs font-bold">H3</span>
|
<span class="text-xs font-bold">H3</span>
|
||||||
@@ -137,10 +195,16 @@
|
|||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleBulletList().run()}
|
onclick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||||
class:text-primary={editor?.isActive('bulletList')}
|
class:text-primary={editor?.isActive("bulletList")}
|
||||||
title="Bullet List"
|
title="Bullet List"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="8" y1="6" x2="21" y2="6" />
|
<line x1="8" y1="6" x2="21" y2="6" />
|
||||||
<line x1="8" y1="12" x2="21" y2="12" />
|
<line x1="8" y1="12" x2="21" y2="12" />
|
||||||
<line x1="8" y1="18" x2="21" y2="18" />
|
<line x1="8" y1="18" x2="21" y2="18" />
|
||||||
@@ -151,23 +215,32 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleOrderedList().run()}
|
onclick={() =>
|
||||||
class:text-primary={editor?.isActive('orderedList')}
|
editor?.chain().focus().toggleOrderedList().run()}
|
||||||
|
class:text-primary={editor?.isActive("orderedList")}
|
||||||
title="Numbered List"
|
title="Numbered List"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="10" y1="6" x2="21" y2="6" />
|
<line x1="10" y1="6" x2="21" y2="6" />
|
||||||
<line x1="10" y1="12" x2="21" y2="12" />
|
<line x1="10" y1="12" x2="21" y2="12" />
|
||||||
<line x1="10" y1="18" x2="21" y2="18" />
|
<line x1="10" y1="18" x2="21" y2="18" />
|
||||||
<text x="3" y="8" font-size="8" fill="currentColor">1</text>
|
<text x="3" y="8" font-size="8" fill="currentColor">1</text>
|
||||||
<text x="3" y="14" font-size="8" fill="currentColor">2</text>
|
<text x="3" y="14" font-size="8" fill="currentColor">2</text
|
||||||
<text x="3" y="20" font-size="8" fill="currentColor">3</text>
|
>
|
||||||
|
<text x="3" y="20" font-size="8" fill="currentColor">3</text
|
||||||
|
>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
onclick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||||
class:text-primary={editor?.isActive('blockquote')}
|
class:text-primary={editor?.isActive("blockquote")}
|
||||||
title="Quote"
|
title="Quote"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -177,10 +250,16 @@
|
|||||||
<button
|
<button
|
||||||
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
class="p-1.5 rounded hover:bg-light/10 text-light/60 hover:text-light transition-colors"
|
||||||
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
onclick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||||
class:text-primary={editor?.isActive('codeBlock')}
|
class:text-primary={editor?.isActive("codeBlock")}
|
||||||
title="Code Block"
|
title="Code Block"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<polyline points="16,18 22,12 16,6" />
|
<polyline points="16,18 22,12 16,6" />
|
||||||
<polyline points="8,6 2,12 8,18" />
|
<polyline points="8,6 2,12 8,18" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
onSelect: (doc: DocumentWithChildren) => void;
|
onSelect: (doc: DocumentWithChildren) => void;
|
||||||
onAdd?: (parentId: string | null) => void;
|
onAdd?: (parentId: string | null) => void;
|
||||||
onMove?: (docId: string, newParentId: string | null) => void;
|
onMove?: (docId: string, newParentId: string | null) => void;
|
||||||
|
onEdit?: (doc: DocumentWithChildren) => void;
|
||||||
|
onDelete?: (doc: DocumentWithChildren) => void;
|
||||||
level?: number;
|
level?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +18,8 @@
|
|||||||
onSelect,
|
onSelect,
|
||||||
onAdd,
|
onAdd,
|
||||||
onMove,
|
onMove,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
level = 0,
|
level = 0,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -145,9 +149,12 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="flex-1 truncate text-sm">{item.name}</span>
|
<span class="flex-1 truncate text-sm">{item.name}</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
|
||||||
|
>
|
||||||
{#if item.type === "folder" && onAdd}
|
{#if item.type === "folder" && onAdd}
|
||||||
<button
|
<button
|
||||||
class="opacity-0 group-hover:opacity-100 p-1 hover:bg-light/10 rounded transition-opacity"
|
class="p-1 hover:bg-light/10 rounded"
|
||||||
onclick={(e) => handleAdd(e, item.id)}
|
onclick={(e) => handleAdd(e, item.id)}
|
||||||
aria-label="Add to folder"
|
aria-label="Add to folder"
|
||||||
>
|
>
|
||||||
@@ -163,6 +170,55 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if onEdit}
|
||||||
|
<button
|
||||||
|
class="p-1 hover:bg-light/10 rounded"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(item);
|
||||||
|
}}
|
||||||
|
aria-label="Rename"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if onDelete}
|
||||||
|
<button
|
||||||
|
class="p-1 hover:bg-error/20 hover:text-error rounded"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item);
|
||||||
|
}}
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="3,6 5,6 21,6" />
|
||||||
|
<path
|
||||||
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if item.type === "folder" && expandedFolders.has(item.id)}
|
{#if item.type === "folder" && expandedFolders.has(item.id)}
|
||||||
@@ -174,6 +230,8 @@
|
|||||||
{onSelect}
|
{onSelect}
|
||||||
{onAdd}
|
{onAdd}
|
||||||
{onMove}
|
{onMove}
|
||||||
|
{onEdit}
|
||||||
|
{onDelete}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from "svelte";
|
||||||
import { Modal, Button, Input, Textarea } from '$lib/components/ui';
|
import { Modal, Button, Input, Textarea } from "$lib/components/ui";
|
||||||
import type { KanbanCard } from '$lib/supabase/types';
|
import type { KanbanCard } from "$lib/supabase/types";
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from '$lib/supabase/types';
|
import type { Database } from "$lib/supabase/types";
|
||||||
|
|
||||||
interface ChecklistItem {
|
interface ChecklistItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,24 +19,44 @@
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onUpdate: (card: KanbanCard) => void;
|
onUpdate: (card: KanbanCard) => void;
|
||||||
onDelete: (cardId: string) => void;
|
onDelete: (cardId: string) => void;
|
||||||
|
mode?: "edit" | "create";
|
||||||
|
columnId?: string;
|
||||||
|
userId?: string;
|
||||||
|
onCreate?: (card: KanbanCard) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { card, isOpen, onClose, onUpdate, onDelete }: Props = $props();
|
let {
|
||||||
|
card,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onUpdate,
|
||||||
|
onDelete,
|
||||||
|
mode = "edit",
|
||||||
|
columnId,
|
||||||
|
userId,
|
||||||
|
onCreate,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
const supabase = getContext<SupabaseClient<Database>>('supabase');
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||||
|
|
||||||
let title = $state('');
|
let title = $state("");
|
||||||
let description = $state('');
|
let description = $state("");
|
||||||
let checklist = $state<ChecklistItem[]>([]);
|
let checklist = $state<ChecklistItem[]>([]);
|
||||||
let newItemTitle = $state('');
|
let newItemTitle = $state("");
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isSaving = $state(false);
|
let isSaving = $state(false);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (card && isOpen) {
|
if (isOpen) {
|
||||||
|
if (mode === "edit" && card) {
|
||||||
title = card.title;
|
title = card.title;
|
||||||
description = card.description ?? '';
|
description = card.description ?? "";
|
||||||
loadChecklist();
|
loadChecklist();
|
||||||
|
} else if (mode === "create") {
|
||||||
|
title = "";
|
||||||
|
description = "";
|
||||||
|
checklist = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,26 +65,31 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
const { data } = await supabase
|
const { data } = await supabase
|
||||||
.from('checklist_items')
|
.from("checklist_items")
|
||||||
.select('*')
|
.select("*")
|
||||||
.eq('card_id', card.id)
|
.eq("card_id", card.id)
|
||||||
.order('position');
|
.order("position");
|
||||||
|
|
||||||
checklist = (data ?? []) as ChecklistItem[];
|
checklist = (data ?? []) as ChecklistItem[];
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
if (mode === "create") {
|
||||||
|
await handleCreate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('kanban_cards')
|
.from("kanban_cards")
|
||||||
.update({
|
.update({
|
||||||
title,
|
title,
|
||||||
description: description || null
|
description: description || null,
|
||||||
})
|
})
|
||||||
.eq('id', card.id);
|
.eq("id", card.id);
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
onUpdate({ ...card, title, description: description || null });
|
onUpdate({ ...card, title, description: description || null });
|
||||||
@@ -72,75 +97,108 @@
|
|||||||
isSaving = false;
|
isSaving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!title.trim() || !columnId || !userId) return;
|
||||||
|
isSaving = true;
|
||||||
|
|
||||||
|
const { data: column } = await supabase
|
||||||
|
.from("kanban_columns")
|
||||||
|
.select("cards:kanban_cards(count)")
|
||||||
|
.eq("id", columnId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const position = (column as any)?.cards?.[0]?.count ?? 0;
|
||||||
|
|
||||||
|
const { data: newCard, error } = await supabase
|
||||||
|
.from("kanban_cards")
|
||||||
|
.insert({
|
||||||
|
column_id: columnId,
|
||||||
|
title,
|
||||||
|
description: description || null,
|
||||||
|
position,
|
||||||
|
created_by: userId,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!error && newCard) {
|
||||||
|
onCreate?.(newCard as KanbanCard);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
isSaving = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAddItem() {
|
async function handleAddItem() {
|
||||||
if (!card || !newItemTitle.trim()) return;
|
if (!card || !newItemTitle.trim()) return;
|
||||||
|
|
||||||
const position = checklist.length;
|
const position = checklist.length;
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('checklist_items')
|
.from("checklist_items")
|
||||||
.insert({
|
.insert({
|
||||||
card_id: card.id,
|
card_id: card.id,
|
||||||
title: newItemTitle,
|
title: newItemTitle,
|
||||||
position,
|
position,
|
||||||
completed: false
|
completed: false,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (!error && data) {
|
if (!error && data) {
|
||||||
checklist = [...checklist, data as ChecklistItem];
|
checklist = [...checklist, data as ChecklistItem];
|
||||||
newItemTitle = '';
|
newItemTitle = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleItem(item: ChecklistItem) {
|
async function toggleItem(item: ChecklistItem) {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('checklist_items')
|
.from("checklist_items")
|
||||||
.update({ completed: !item.completed })
|
.update({ completed: !item.completed })
|
||||||
.eq('id', item.id);
|
.eq("id", item.id);
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
checklist = checklist.map(i =>
|
checklist = checklist.map((i) =>
|
||||||
i.id === item.id ? { ...i, completed: !i.completed } : i
|
i.id === item.id ? { ...i, completed: !i.completed } : i,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteItem(itemId: string) {
|
async function deleteItem(itemId: string) {
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('checklist_items')
|
.from("checklist_items")
|
||||||
.delete()
|
.delete()
|
||||||
.eq('id', itemId);
|
.eq("id", itemId);
|
||||||
|
|
||||||
if (!error) {
|
if (!error) {
|
||||||
checklist = checklist.filter(i => i.id !== itemId);
|
checklist = checklist.filter((i) => i.id !== itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!card || !confirm('Delete this card?')) return;
|
if (!card || !confirm("Delete this card?")) return;
|
||||||
|
|
||||||
await supabase
|
await supabase.from("kanban_cards").delete().eq("id", card.id);
|
||||||
.from('kanban_cards')
|
|
||||||
.delete()
|
|
||||||
.eq('id', card.id);
|
|
||||||
|
|
||||||
onDelete(card.id);
|
onDelete(card.id);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
const completedCount = $derived(checklist.filter(i => i.completed).length);
|
const completedCount = $derived(
|
||||||
const progress = $derived(checklist.length > 0 ? (completedCount / checklist.length) * 100 : 0);
|
checklist.filter((i) => i.completed).length,
|
||||||
|
);
|
||||||
|
const progress = $derived(
|
||||||
|
checklist.length > 0 ? (completedCount / checklist.length) * 100 : 0,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal {isOpen} {onClose} title="Card Details" size="lg">
|
<Modal
|
||||||
{#if card}
|
{isOpen}
|
||||||
|
{onClose}
|
||||||
|
title={mode === "create" ? "Add Card" : "Card Details"}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{#if mode === "create" || card}
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<Input
|
<Input label="Title" bind:value={title} placeholder="Card title" />
|
||||||
label="Title"
|
|
||||||
bind:value={title}
|
|
||||||
placeholder="Card title"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Description"
|
label="Description"
|
||||||
@@ -151,14 +209,20 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
<label class="text-sm font-medium text-light">Checklist</label>
|
<label class="text-sm font-medium text-light"
|
||||||
|
>Checklist</label
|
||||||
|
>
|
||||||
{#if checklist.length > 0}
|
{#if checklist.length > 0}
|
||||||
<span class="text-xs text-light/50">{completedCount}/{checklist.length}</span>
|
<span class="text-xs text-light/50"
|
||||||
|
>{completedCount}/{checklist.length}</span
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if checklist.length > 0}
|
{#if checklist.length > 0}
|
||||||
<div class="mb-3 h-1.5 bg-light/10 rounded-full overflow-hidden">
|
<div
|
||||||
|
class="mb-3 h-1.5 bg-light/10 rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="h-full bg-success transition-all duration-300"
|
class="h-full bg-success transition-all duration-300"
|
||||||
style="width: {progress}%"
|
style="width: {progress}%"
|
||||||
@@ -174,16 +238,28 @@
|
|||||||
<div class="flex items-center gap-3 group">
|
<div class="flex items-center gap-3 group">
|
||||||
<button
|
<button
|
||||||
class="w-5 h-5 rounded border flex items-center justify-center transition-colors
|
class="w-5 h-5 rounded border flex items-center justify-center transition-colors
|
||||||
{item.completed ? 'bg-success border-success' : 'border-light/30 hover:border-light/50'}"
|
{item.completed
|
||||||
|
? 'bg-success border-success'
|
||||||
|
: 'border-light/30 hover:border-light/50'}"
|
||||||
onclick={() => toggleItem(item)}
|
onclick={() => toggleItem(item)}
|
||||||
>
|
>
|
||||||
{#if item.completed}
|
{#if item.completed}
|
||||||
<svg class="w-3 h-3 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
|
<svg
|
||||||
|
class="w-3 h-3 text-white"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
>
|
||||||
<polyline points="20,6 9,17 4,12" />
|
<polyline points="20,6 9,17 4,12" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<span class="flex-1 text-sm {item.completed ? 'line-through text-light/40' : 'text-light'}">
|
<span
|
||||||
|
class="flex-1 text-sm {item.completed
|
||||||
|
? 'line-through text-light/40'
|
||||||
|
: 'text-light'}"
|
||||||
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -191,7 +267,13 @@
|
|||||||
onclick={() => deleteItem(item.id)}
|
onclick={() => deleteItem(item.id)}
|
||||||
aria-label="Delete item"
|
aria-label="Delete item"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -206,23 +288,38 @@
|
|||||||
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
|
class="flex-1 px-3 py-2 bg-dark border border-light/20 rounded-lg text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary"
|
||||||
placeholder="Add an item..."
|
placeholder="Add an item..."
|
||||||
bind:value={newItemTitle}
|
bind:value={newItemTitle}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleAddItem()}
|
onkeydown={(e) =>
|
||||||
|
e.key === "Enter" && handleAddItem()}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" onclick={handleAddItem} disabled={!newItemTitle.trim()}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={handleAddItem}
|
||||||
|
disabled={!newItemTitle.trim()}
|
||||||
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between pt-3 border-t border-light/10">
|
<div
|
||||||
|
class="flex items-center justify-between pt-3 border-t border-light/10"
|
||||||
|
>
|
||||||
|
{#if mode === "edit"}
|
||||||
<Button variant="danger" onclick={handleDelete}>
|
<Button variant="danger" onclick={handleDelete}>
|
||||||
Delete Card
|
Delete Card
|
||||||
</Button>
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<div></div>
|
||||||
|
{/if}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button variant="ghost" onclick={onClose}>Cancel</Button>
|
<Button variant="ghost" onclick={onClose}>Cancel</Button>
|
||||||
<Button onclick={handleSave} loading={isSaving}>
|
<Button
|
||||||
Save Changes
|
onclick={handleSave}
|
||||||
|
loading={isSaving}
|
||||||
|
disabled={!title.trim()}
|
||||||
|
>
|
||||||
|
{mode === "create" ? "Add Card" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
133
src/lib/components/kanban/KanbanCard.svelte
Normal file
133
src/lib/components/kanban/KanbanCard.svelte
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { KanbanCard as KanbanCardType } from "$lib/supabase/types";
|
||||||
|
import { Badge } from "$lib/components/ui";
|
||||||
|
|
||||||
|
// Extended card type with optional new fields from migration
|
||||||
|
interface ExtendedCard extends KanbanCardType {
|
||||||
|
priority?: "low" | "medium" | "high" | "urgent" | null;
|
||||||
|
assignee_id?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
card: ExtendedCard;
|
||||||
|
isDragging?: boolean;
|
||||||
|
onclick?: () => void;
|
||||||
|
draggable?: boolean;
|
||||||
|
ondragstart?: (e: DragEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
card,
|
||||||
|
isDragging = false,
|
||||||
|
onclick,
|
||||||
|
draggable = true,
|
||||||
|
ondragstart,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function formatDueDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = date.getTime() - now.getTime();
|
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days < 0) return "Overdue";
|
||||||
|
if (days === 0) return "Today";
|
||||||
|
if (days === 1) return "Tomorrow";
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDueDateVariant(
|
||||||
|
dateStr: string | null,
|
||||||
|
): "error" | "warning" | "default" {
|
||||||
|
if (!dateStr) return "default";
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = date.getTime() - now.getTime();
|
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days < 0) return "error";
|
||||||
|
if (days <= 2) return "warning";
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPriorityColor(priority: string | null): string {
|
||||||
|
switch (priority) {
|
||||||
|
case "urgent":
|
||||||
|
return "#E03D00";
|
||||||
|
case "high":
|
||||||
|
return "#FFAB00";
|
||||||
|
case "medium":
|
||||||
|
return "#00A3E0";
|
||||||
|
case "low":
|
||||||
|
return "#33E000";
|
||||||
|
default:
|
||||||
|
return "#E5E6F0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="bg-night rounded-[16px] p-3 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group"
|
||||||
|
class:opacity-50={isDragging}
|
||||||
|
{draggable}
|
||||||
|
{ondragstart}
|
||||||
|
{onclick}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && onclick?.()}
|
||||||
|
role="listitem"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<!-- Priority indicator -->
|
||||||
|
{#if card.priority}
|
||||||
|
<div
|
||||||
|
class="w-full h-1 rounded-full mb-2"
|
||||||
|
style="background-color: {getPriorityColor(card.priority)}"
|
||||||
|
></div>
|
||||||
|
{:else if card.color}
|
||||||
|
<div
|
||||||
|
class="w-full h-1 rounded-full mb-2"
|
||||||
|
style="background-color: {card.color}"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<p class="text-sm font-medium text-light">{card.title}</p>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
{#if card.description}
|
||||||
|
<p class="text-xs text-light/50 mt-1 line-clamp-2">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Footer with metadata -->
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<!-- Due date -->
|
||||||
|
{#if card.due_date}
|
||||||
|
<Badge size="sm" variant={getDueDateVariant(card.due_date)}>
|
||||||
|
<svg
|
||||||
|
class="w-3 h-3 mr-1"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
|
</svg>
|
||||||
|
{formatDueDate(card.due_date)}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Assignee placeholder -->
|
||||||
|
{#if card.assignee_id}
|
||||||
|
<div
|
||||||
|
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xs font-medium"
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
export { default as KanbanBoard } from './KanbanBoard.svelte';
|
||||||
export { default as CardDetailModal } from './CardDetailModal.svelte';
|
export { default as CardDetailModal } from './CardDetailModal.svelte';
|
||||||
|
export { default as KanbanCard } from './KanbanCard.svelte';
|
||||||
|
|||||||
@@ -1,45 +1,49 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success';
|
variant?: "primary" | "secondary" | "ghost" | "danger" | "success";
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
size?: "sm" | "md" | "lg";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
type?: 'button' | 'submit' | 'reset';
|
type?: "button" | "submit" | "reset";
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
onclick?: (e: MouseEvent) => void;
|
onclick?: (e: MouseEvent) => void;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
variant = 'primary',
|
variant = "primary",
|
||||||
size = 'md',
|
size = "md",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
type = 'button',
|
type = "button",
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
onclick,
|
onclick,
|
||||||
children
|
children,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Figma-matched base styles
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
'inline-flex items-center justify-center font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-dark disabled:opacity-50 disabled:cursor-not-allowed';
|
"inline-flex items-center justify-center font-bold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-30 disabled:cursor-not-allowed rounded-[32px]";
|
||||||
|
|
||||||
|
// Figma-matched variant styles
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
primary: 'bg-primary text-white hover:bg-primary/90 focus:ring-primary rounded-xl',
|
primary:
|
||||||
|
"bg-primary text-night hover:brightness-110 active:brightness-90",
|
||||||
secondary:
|
secondary:
|
||||||
'bg-surface text-light border border-light/20 hover:bg-light/5 focus:ring-light/50 rounded-xl',
|
"border-2 border-primary text-primary bg-transparent hover:bg-primary/10 active:bg-primary/20",
|
||||||
ghost: 'bg-transparent text-light hover:bg-light/10 focus:ring-light/50 rounded-xl',
|
ghost: "bg-primary/10 text-primary hover:bg-primary/20 active:bg-primary/30",
|
||||||
danger: 'bg-error text-white hover:bg-error/90 focus:ring-error rounded-xl',
|
danger: "bg-error text-night hover:brightness-110 active:brightness-90",
|
||||||
success: 'bg-success text-white hover:bg-success/90 focus:ring-success rounded-xl'
|
success:
|
||||||
|
"bg-success text-night hover:brightness-110 active:brightness-90",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Figma-matched size styles (px values from Figma)
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
xs: 'px-2 py-1 text-xs gap-1',
|
sm: "px-3 py-1.5 text-sm gap-1.5 min-w-[96px]",
|
||||||
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
md: "px-4 py-2 text-base gap-2 min-w-[128px]",
|
||||||
md: 'px-4 py-2 text-sm gap-2',
|
lg: "px-5 py-3 text-xl gap-2.5 min-w-[128px]",
|
||||||
lg: 'px-6 py-3 text-base gap-2.5'
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: 'default' | 'elevated' | 'outlined';
|
variant?: "default" | "elevated" | "outlined";
|
||||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
padding?: "none" | "sm" | "md" | "lg";
|
||||||
|
class?: string;
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { variant = 'default', padding = 'md', children }: Props = $props();
|
let {
|
||||||
|
variant = "default",
|
||||||
|
padding = "md",
|
||||||
|
class: className = "",
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Figma-matched styles: rounded-[32px], bg-night (#0A121F)
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
default: 'bg-surface',
|
default: "bg-night",
|
||||||
elevated: 'bg-surface shadow-lg shadow-black/20',
|
elevated: "bg-night shadow-lg shadow-black/30",
|
||||||
outlined: 'bg-surface border border-light/10'
|
outlined: "bg-night border border-light/10",
|
||||||
};
|
};
|
||||||
|
|
||||||
const paddingClasses = {
|
const paddingClasses = {
|
||||||
none: '',
|
none: "",
|
||||||
sm: 'p-3',
|
sm: "p-3",
|
||||||
md: 'p-4',
|
md: "p-5",
|
||||||
lg: 'p-6'
|
lg: "p-6",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rounded-2xl {variantClasses[variant]} {paddingClasses[padding]}">
|
<div
|
||||||
|
class="rounded-[32px] {variantClasses[variant]} {paddingClasses[
|
||||||
|
padding
|
||||||
|
]} {className}"
|
||||||
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,20 +27,23 @@
|
|||||||
onkeydown,
|
onkeydown,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let showPassword = $state(false);
|
||||||
const inputId = `input-${crypto.randomUUID().slice(0, 8)}`;
|
const inputId = `input-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
const isPassword = $derived(type === "password");
|
||||||
|
const inputType = $derived(isPassword && showPassword ? "text" : type);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-3">
|
||||||
{#if label}
|
{#if label}
|
||||||
<label for={inputId} class="text-sm font-medium text-light/80">
|
<label for={inputId} class="px-3 font-heading text-xl text-white">
|
||||||
{label}
|
{#if required}<span class="text-error">* </span>{/if}{label}
|
||||||
{#if required}<span class="text-primary">*</span>{/if}
|
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
{type}
|
type={inputType}
|
||||||
bind:value
|
bind:value
|
||||||
{placeholder}
|
{placeholder}
|
||||||
{disabled}
|
{disabled}
|
||||||
@@ -48,19 +51,54 @@
|
|||||||
{autocomplete}
|
{autocomplete}
|
||||||
{oninput}
|
{oninput}
|
||||||
{onkeydown}
|
{onkeydown}
|
||||||
class="w-full px-4 py-2.5 bg-surface text-light rounded-xl border border-light/20
|
class="w-full px-3 py-3 bg-night text-white rounded-[32px] min-w-[192px]
|
||||||
placeholder:text-light/40
|
placeholder:text-white/40
|
||||||
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
|
focus:outline-none focus:ring-2 focus:ring-primary
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
disabled:opacity-30 disabled:cursor-not-allowed
|
||||||
transition-colors"
|
transition-colors"
|
||||||
class:border-error={error}
|
class:ring-1={error}
|
||||||
class:focus:border-error={error}
|
class:ring-error={error}
|
||||||
class:focus:ring-error={error}
|
|
||||||
/>
|
/>
|
||||||
|
{#if isPassword}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-white/40 hover:text-white transition-colors"
|
||||||
|
onclick={() => (showPassword = !showPassword)}
|
||||||
|
>
|
||||||
|
{#if showPassword}
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
||||||
|
/>
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-sm text-error">{error}</p>
|
<p class="text-sm text-error px-3">{error}</p>
|
||||||
{:else if hint}
|
{:else if hint}
|
||||||
<p class="text-sm text-light/50">{hint}</p>
|
<p class="text-sm text-white/50 px-3">{hint}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
67
src/lib/components/ui/Toast.svelte
Normal file
67
src/lib/components/ui/Toast.svelte
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant?: 'success' | 'error' | 'warning' | 'info';
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
variant = 'info',
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
onClose,
|
||||||
|
children
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
success: 'bg-[#33e000]',
|
||||||
|
error: 'bg-error',
|
||||||
|
warning: 'bg-warning',
|
||||||
|
info: 'bg-primary'
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultTitles = {
|
||||||
|
success: 'Success',
|
||||||
|
error: 'Error',
|
||||||
|
warning: 'Warning',
|
||||||
|
info: 'Info'
|
||||||
|
};
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
success: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
|
warning: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||||
|
info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex gap-4 items-start p-4 rounded-[32px] w-full max-w-lg {variantClasses[variant]}">
|
||||||
|
<svg class="w-9 h-9 shrink-0 text-night" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d={icons[variant]} />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col gap-1 text-night">
|
||||||
|
<p class="font-heading text-xl">{title || defaultTitles[variant]}</p>
|
||||||
|
{#if message}
|
||||||
|
<p class="text-base">{message}</p>
|
||||||
|
{/if}
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if onClose}
|
||||||
|
<button
|
||||||
|
class="shrink-0 text-night/50 hover:text-night transition-colors"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -8,3 +8,4 @@ export { default as Card } from './Card.svelte';
|
|||||||
export { default as Modal } from './Modal.svelte';
|
export { default as Modal } from './Modal.svelte';
|
||||||
export { default as Spinner } from './Spinner.svelte';
|
export { default as Spinner } from './Spinner.svelte';
|
||||||
export { default as Toggle } from './Toggle.svelte';
|
export { default as Toggle } from './Toggle.svelte';
|
||||||
|
export { default as Toast } from './Toast.svelte';
|
||||||
|
|||||||
@@ -29,9 +29,27 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
error(403, 'You are not a member of this organization');
|
error(403, 'You are not a member of this organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch team members for sidebar
|
||||||
|
const { data: members } = await locals.supabase
|
||||||
|
.from('org_members')
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
user_id,
|
||||||
|
role,
|
||||||
|
profiles:user_id (
|
||||||
|
id,
|
||||||
|
email,
|
||||||
|
full_name,
|
||||||
|
avatar_url
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('org_id', org.id)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
org,
|
org,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
userRole: membership.role
|
userRole: membership.role,
|
||||||
|
members: members ?? []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,17 +2,32 @@
|
|||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: string;
|
||||||
|
profiles: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
role: string;
|
role: string;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
|
members: Member[];
|
||||||
};
|
};
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data, children }: Props = $props();
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
let sidebarCollapsed = $state(false);
|
||||||
|
|
||||||
const isAdmin = $derived(
|
const isAdmin = $derived(
|
||||||
data.userRole === "owner" || data.userRole === "admin",
|
data.userRole === "owner" || data.userRole === "admin",
|
||||||
);
|
);
|
||||||
@@ -47,28 +62,54 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen bg-dark">
|
<!-- Figma-matched layout: bg-background with gap-4 padding -->
|
||||||
<aside class="w-64 bg-surface border-r border-light/10 flex flex-col">
|
<div class="flex h-screen bg-background p-4 gap-4">
|
||||||
<div class="p-4 border-b border-light/10">
|
<!-- Organization Module -->
|
||||||
<h1 class="text-lg font-semibold text-light truncate">
|
<aside
|
||||||
|
class="{sidebarCollapsed
|
||||||
|
? 'w-20'
|
||||||
|
: 'w-56'} bg-night rounded-[32px] flex flex-col px-3 py-5 transition-all duration-200 overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Org Header -->
|
||||||
|
<div class="flex items-start gap-2 px-1 mb-2">
|
||||||
|
<div
|
||||||
|
class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center text-primary text-xl font-heading shrink-0"
|
||||||
|
>
|
||||||
|
{data.org.name[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{#if !sidebarCollapsed}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h1 class="font-heading text-xl text-light truncate">
|
||||||
{data.org.name}
|
{data.org.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xs text-light/50 capitalize">{data.role}</p>
|
<p class="text-xs text-white capitalize">{data.role}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 p-2 space-y-1">
|
<!-- Nav Items -->
|
||||||
|
<nav class="flex-1 space-y-0.5">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors {isActive(
|
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors {isActive(
|
||||||
item.href,
|
item.href,
|
||||||
)
|
)
|
||||||
? 'bg-primary text-white'
|
? 'bg-primary/20'
|
||||||
: 'text-light/70 hover:bg-light/5 hover:text-light'}"
|
: 'hover:bg-light/5'}"
|
||||||
|
title={sidebarCollapsed ? item.label : undefined}
|
||||||
|
>
|
||||||
|
<!-- Icon circle -->
|
||||||
|
<div
|
||||||
|
class="w-8 h-8 rounded-full {isActive(item.href)
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-light'} flex items-center justify-center shrink-0"
|
||||||
>
|
>
|
||||||
{#if item.icon === "home"}
|
{#if item.icon === "home"}
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5"
|
class="w-4 h-4 {isActive(item.href)
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-night'}"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -81,7 +122,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{:else if item.icon === "file"}
|
{:else if item.icon === "file"}
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5"
|
class="w-4 h-4 {isActive(item.href)
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-night'}"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -94,32 +137,50 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{:else if item.icon === "kanban"}
|
{:else if item.icon === "kanban"}
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5"
|
class="w-4 h-4 {isActive(item.href)
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-night'}"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="3"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
/>
|
||||||
<line x1="9" y1="3" x2="9" y2="21" />
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
<line x1="15" y1="3" x2="15" y2="21" />
|
<line x1="15" y1="3" x2="15" y2="21" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else if item.icon === "calendar"}
|
{:else if item.icon === "calendar"}
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5"
|
class="w-4 h-4 {isActive(item.href)
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-night'}"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
>
|
>
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="4"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
/>
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else if item.icon === "settings"}
|
{:else if item.icon === "settings"}
|
||||||
<svg
|
<svg
|
||||||
class="w-5 h-5"
|
class="w-4 h-4 {isActive(item.href)
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-night'}"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -131,18 +192,63 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
{item.label}
|
</div>
|
||||||
|
{#if !sidebarCollapsed}
|
||||||
|
<span class="font-bold text-light truncate"
|
||||||
|
>{item.label}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="p-4 border-t border-light/10">
|
<!-- Team Members -->
|
||||||
|
{#if !sidebarCollapsed}
|
||||||
|
<div class="mt-4 pt-4 border-t border-light/10">
|
||||||
|
<p class="font-heading text-base text-light mb-2 px-1">Team</p>
|
||||||
|
{#if data.members && data.members.length > 0}
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
{#each data.members.slice(0, 5) as member}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] hover:bg-light/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-5 h-5 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white text-xs font-medium"
|
||||||
|
>
|
||||||
|
{(member.profiles?.full_name ||
|
||||||
|
member.profiles?.email ||
|
||||||
|
"?")[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-sm font-bold text-light truncate flex-1"
|
||||||
|
>
|
||||||
|
{member.profiles?.full_name ||
|
||||||
|
member.profiles?.email?.split("@")[0] ||
|
||||||
|
"User"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-light/40 px-1">
|
||||||
|
No team members found
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Back link -->
|
||||||
|
<div class="mt-auto pt-4">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="flex items-center gap-2 text-sm text-light/50 hover:text-light transition-colors"
|
class="flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] text-light/50 hover:text-light hover:bg-light/5 transition-colors"
|
||||||
|
title={sidebarCollapsed ? "All Organizations" : undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-5 h-5 rounded-full bg-light/20 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4"
|
class="w-3 h-3"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -150,12 +256,16 @@
|
|||||||
>
|
>
|
||||||
<path d="m15 18-6-6 6-6" />
|
<path d="m15 18-6-6 6-6" />
|
||||||
</svg>
|
</svg>
|
||||||
All Organizations
|
</div>
|
||||||
|
{#if !sidebarCollapsed}
|
||||||
|
<span class="text-sm">All Organizations</span>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-1 overflow-auto">
|
<!-- Main Content Area -->
|
||||||
|
<main class="flex-1 bg-night rounded-[32px] overflow-auto">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,55 +1,143 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Card } from '$lib/components/ui';
|
import { Card } from "$lib/components/ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
role: string;
|
role: string;
|
||||||
|
members?: Array<{
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: string;
|
||||||
|
profiles: { full_name: string | null; email: string };
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data }: Props = $props();
|
let { data }: Props = $props();
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{ href: `/${data.org.slug}/documents`, label: 'Documents', description: 'Collaborative docs and files', icon: 'file' },
|
{
|
||||||
{ href: `/${data.org.slug}/kanban`, label: 'Kanban', description: 'Track tasks and projects', icon: 'kanban' },
|
href: `/${data.org.slug}/documents`,
|
||||||
{ href: `/${data.org.slug}/calendar`, label: 'Calendar', description: 'Schedule events and meetings', icon: 'calendar' }
|
label: "Documents",
|
||||||
|
description: "Collaborative docs and files",
|
||||||
|
icon: "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `/${data.org.slug}/kanban`,
|
||||||
|
label: "Kanban",
|
||||||
|
description: "Track tasks and projects",
|
||||||
|
icon: "kanban",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `/${data.org.slug}/calendar`,
|
||||||
|
label: "Calendar",
|
||||||
|
description: "Schedule events and meetings",
|
||||||
|
icon: "calendar",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mock recent activity - will be replaced with real data from activity_log table
|
||||||
|
const recentActivity = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
action: "Created document",
|
||||||
|
entity: "Project Brief",
|
||||||
|
time: "2 hours ago",
|
||||||
|
icon: "file",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
action: "Updated kanban card",
|
||||||
|
entity: "Design Review",
|
||||||
|
time: "4 hours ago",
|
||||||
|
icon: "kanban",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
action: "Added team member",
|
||||||
|
entity: "New Developer",
|
||||||
|
time: "1 day ago",
|
||||||
|
icon: "user",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.org.name} - Overview | Root</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-light">{data.org.name}</h1>
|
<h1 class="text-3xl font-heading text-light">{data.org.name}</h1>
|
||||||
<p class="text-light/50 mt-1">Organization Overview</p>
|
<p class="text-light/50 mt-1">Organization Overview</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
{#each quickLinks as link}
|
{#each quickLinks as link}
|
||||||
<a href={link.href} class="block group">
|
<a href={link.href} class="block group">
|
||||||
<Card class="h-full hover:ring-1 hover:ring-primary/50 transition-all">
|
<Card
|
||||||
|
class="h-full hover:ring-1 hover:ring-primary/50 transition-all"
|
||||||
|
>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors">
|
<div
|
||||||
{#if link.icon === 'file'}
|
class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-4 group-hover:bg-primary/20 transition-colors"
|
||||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
>
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
{#if link.icon === "file"}
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||||
|
/>
|
||||||
<polyline points="14,2 14,8 20,8" />
|
<polyline points="14,2 14,8 20,8" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else if link.icon === 'kanban'}
|
{:else if link.icon === "kanban"}
|
||||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
class="w-6 h-6 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="3"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
/>
|
||||||
<line x1="9" y1="3" x2="9" y2="21" />
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
<line x1="15" y1="3" x2="15" y2="21" />
|
<line x1="15" y1="3" x2="15" y2="21" />
|
||||||
</svg>
|
</svg>
|
||||||
{:else if link.icon === 'calendar'}
|
{:else if link.icon === "calendar"}
|
||||||
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" />
|
class="w-6 h-6 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="4"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
/>
|
||||||
<line x1="16" y1="2" x2="16" y2="6" />
|
<line x1="16" y1="2" x2="16" y2="6" />
|
||||||
<line x1="8" y1="2" x2="8" y2="6" />
|
<line x1="8" y1="2" x2="8" y2="6" />
|
||||||
<line x1="3" y1="10" x2="21" y2="10" />
|
<line x1="3" y1="10" x2="21" y2="10" />
|
||||||
</svg>
|
</svg>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-semibold text-light mb-1">{link.label}</h3>
|
<h3 class="text-lg font-semibold text-light mb-1">
|
||||||
|
{link.label}
|
||||||
|
</h3>
|
||||||
<p class="text-sm text-light/50">{link.description}</p>
|
<p class="text-sm text-light/50">{link.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -58,11 +146,113 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-xl font-semibold text-light mb-4">Recent Activity</h2>
|
<h2 class="text-xl font-heading text-light mb-4">Recent Activity</h2>
|
||||||
<Card>
|
<Card>
|
||||||
<div class="p-6 text-center text-light/50">
|
<div class="divide-y divide-light/10">
|
||||||
<p>No recent activity to show</p>
|
{#each recentActivity as activity}
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 p-4 hover:bg-light/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0"
|
||||||
|
>
|
||||||
|
{#if activity.icon === "file"}
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||||
|
/>
|
||||||
|
<polyline points="14,2 14,8 20,8" />
|
||||||
|
</svg>
|
||||||
|
{:else if activity.icon === "kanban"}
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="3"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
rx="2"
|
||||||
|
/>
|
||||||
|
<line x1="9" y1="3" x2="9" y2="21" />
|
||||||
|
<line x1="15" y1="3" x2="15" y2="21" />
|
||||||
|
</svg>
|
||||||
|
{:else if activity.icon === "user"}
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-primary"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-light font-medium">
|
||||||
|
{activity.action}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-light/50 truncate">
|
||||||
|
{activity.entity}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-light/40 shrink-0"
|
||||||
|
>{activity.time}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Team Stats -->
|
||||||
|
{#if data.members && data.members.length > 0}
|
||||||
|
<section class="mt-8">
|
||||||
|
<h2 class="text-xl font-heading text-light mb-4">Team</h2>
|
||||||
|
<Card>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
{#each data.members.slice(0, 8) as member}
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-primary/50 flex items-center justify-center text-white font-medium"
|
||||||
|
title={member.profiles?.full_name ||
|
||||||
|
member.profiles?.email}
|
||||||
|
>
|
||||||
|
{(member.profiles?.full_name ||
|
||||||
|
member.profiles?.email ||
|
||||||
|
"?")[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if data.members.length > 8}
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-full bg-light/10 flex items-center justify-center text-light/50 text-sm"
|
||||||
|
>
|
||||||
|
+{data.members.length - 8}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-light/50 mt-3">
|
||||||
|
{data.members.length} team member{data.members
|
||||||
|
.length !== 1
|
||||||
|
? "s"
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext, onMount } from "svelte";
|
import { getContext, onMount } from "svelte";
|
||||||
import { Button, Modal, Input, Textarea } from "$lib/components/ui";
|
import { Button, Modal } from "$lib/components/ui";
|
||||||
import { Calendar } from "$lib/components/calendar";
|
import { Calendar } from "$lib/components/calendar";
|
||||||
import { createEvent } from "$lib/api/calendar";
|
|
||||||
import {
|
import {
|
||||||
getCalendarSubscribeUrl,
|
getCalendarSubscribeUrl,
|
||||||
type GoogleCalendarEvent,
|
type GoogleCalendarEvent,
|
||||||
@@ -36,34 +35,11 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const allEvents = $derived([...events, ...googleEvents]);
|
const allEvents = $derived([...events, ...googleEvents]);
|
||||||
let showCreateModal = $state(false);
|
|
||||||
let showEventModal = $state(false);
|
let showEventModal = $state(false);
|
||||||
|
let isDeleting = $state(false);
|
||||||
let selectedEvent = $state<CalendarEvent | null>(null);
|
let selectedEvent = $state<CalendarEvent | null>(null);
|
||||||
let selectedDate = $state<Date | null>(null);
|
function handleDateClick(_date: Date) {
|
||||||
|
// Event creation disabled
|
||||||
let newEvent = $state({
|
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
date: "",
|
|
||||||
startTime: "09:00",
|
|
||||||
endTime: "10:00",
|
|
||||||
allDay: false,
|
|
||||||
color: "#6366f1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const colorOptions = [
|
|
||||||
{ value: "#6366f1", label: "Indigo" },
|
|
||||||
{ value: "#ec4899", label: "Pink" },
|
|
||||||
{ value: "#10b981", label: "Green" },
|
|
||||||
{ value: "#f59e0b", label: "Amber" },
|
|
||||||
{ value: "#ef4444", label: "Red" },
|
|
||||||
{ value: "#8b5cf6", label: "Purple" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function handleDateClick(date: Date) {
|
|
||||||
selectedDate = date;
|
|
||||||
newEvent.date = date.toISOString().split("T")[0];
|
|
||||||
showCreateModal = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEventClick(event: CalendarEvent) {
|
function handleEventClick(event: CalendarEvent) {
|
||||||
@@ -71,46 +47,25 @@
|
|||||||
showEventModal = true;
|
showEventModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateEvent() {
|
async function handleDeleteEvent() {
|
||||||
if (!newEvent.title.trim() || !newEvent.date || !data.user) return;
|
if (!selectedEvent || selectedEvent.id.startsWith("google-")) return;
|
||||||
|
|
||||||
const startTime = newEvent.allDay
|
isDeleting = true;
|
||||||
? `${newEvent.date}T00:00:00`
|
try {
|
||||||
: `${newEvent.date}T${newEvent.startTime}:00`;
|
const { error } = await supabase
|
||||||
const endTime = newEvent.allDay
|
.from("calendar_events")
|
||||||
? `${newEvent.date}T23:59:59`
|
.delete()
|
||||||
: `${newEvent.date}T${newEvent.endTime}:00`;
|
.eq("id", selectedEvent.id);
|
||||||
|
|
||||||
const created = await createEvent(
|
if (!error) {
|
||||||
supabase,
|
events = events.filter((e) => e.id !== selectedEvent?.id);
|
||||||
data.org.id,
|
showEventModal = false;
|
||||||
{
|
selectedEvent = null;
|
||||||
title: newEvent.title,
|
|
||||||
description: newEvent.description || undefined,
|
|
||||||
start_time: startTime,
|
|
||||||
end_time: endTime,
|
|
||||||
all_day: newEvent.allDay,
|
|
||||||
color: newEvent.color,
|
|
||||||
},
|
|
||||||
data.user.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
events = [...events, created];
|
|
||||||
resetForm();
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
function resetForm() {
|
console.error("Failed to delete event:", e);
|
||||||
showCreateModal = false;
|
}
|
||||||
newEvent = {
|
isDeleting = false;
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
date: "",
|
|
||||||
startTime: "09:00",
|
|
||||||
endTime: "10:00",
|
|
||||||
allDay: false,
|
|
||||||
color: "#6366f1",
|
|
||||||
};
|
|
||||||
selectedDate = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEventTime(event: CalendarEvent): string {
|
function formatEventTime(event: CalendarEvent): string {
|
||||||
@@ -213,21 +168,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={() => (showCreateModal = true)}>
|
|
||||||
<svg
|
|
||||||
class="w-4 h-4 mr-2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
New Event
|
|
||||||
</Button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<p class="text-light/50 text-sm mb-4">
|
||||||
|
View events from connected Google Calendar. Event creation coming soon.
|
||||||
|
</p>
|
||||||
|
|
||||||
<Calendar
|
<Calendar
|
||||||
events={allEvents}
|
events={allEvents}
|
||||||
onDateClick={handleDateClick}
|
onDateClick={handleDateClick}
|
||||||
@@ -235,78 +181,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Modal isOpen={showCreateModal} onClose={resetForm} title="Create Event">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<Input
|
|
||||||
label="Title"
|
|
||||||
bind:value={newEvent.title}
|
|
||||||
placeholder="Event title"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Textarea
|
|
||||||
label="Description"
|
|
||||||
bind:value={newEvent.description}
|
|
||||||
placeholder="Optional description"
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input label="Date" type="date" bind:value={newEvent.date} />
|
|
||||||
|
|
||||||
<label class="flex items-center gap-2 text-sm text-light">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={newEvent.allDay}
|
|
||||||
class="rounded"
|
|
||||||
/>
|
|
||||||
All day event
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if !newEvent.allDay}
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<Input
|
|
||||||
label="Start Time"
|
|
||||||
type="time"
|
|
||||||
bind:value={newEvent.startTime}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="End Time"
|
|
||||||
type="time"
|
|
||||||
bind:value={newEvent.endTime}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-light mb-2"
|
|
||||||
>Color</label
|
|
||||||
>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
{#each colorOptions as color}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-8 h-8 rounded-full transition-transform"
|
|
||||||
class:ring-2={newEvent.color === color.value}
|
|
||||||
class:ring-white={newEvent.color === color.value}
|
|
||||||
class:scale-110={newEvent.color === color.value}
|
|
||||||
style="background-color: {color.value}"
|
|
||||||
onclick={() => (newEvent.color = color.value)}
|
|
||||||
aria-label={color.label}
|
|
||||||
></button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
|
||||||
<Button variant="ghost" onclick={resetForm}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
onclick={handleCreateEvent}
|
|
||||||
disabled={!newEvent.title.trim() || !newEvent.date}
|
|
||||||
>Create</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showEventModal}
|
isOpen={showEventModal}
|
||||||
onClose={() => (showEventModal = false)}
|
onClose={() => (showEventModal = false)}
|
||||||
@@ -437,6 +311,19 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete local event -->
|
||||||
|
{#if !selectedEvent.id.startsWith("google-")}
|
||||||
|
<div class="pt-3 border-t border-light/10">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onclick={handleDeleteEvent}
|
||||||
|
loading={isDeleting}
|
||||||
|
>
|
||||||
|
Delete Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
let documents = $state(data.documents);
|
let documents = $state(data.documents);
|
||||||
let selectedDoc = $state<Document | null>(null);
|
let selectedDoc = $state<Document | null>(null);
|
||||||
let showCreateModal = $state(false);
|
let showCreateModal = $state(false);
|
||||||
|
let showEditModal = $state(false);
|
||||||
|
let editingDoc = $state<Document | null>(null);
|
||||||
let newDocName = $state("");
|
let newDocName = $state("");
|
||||||
let newDocType = $state<"folder" | "document">("document");
|
let newDocType = $state<"folder" | "document">("document");
|
||||||
let parentFolderId = $state<string | null>(null);
|
let parentFolderId = $state<string | null>(null);
|
||||||
|
let isEditing = $state(false);
|
||||||
|
|
||||||
const documentTree = $derived(buildDocumentTree(documents));
|
const documentTree = $derived(buildDocumentTree(documents));
|
||||||
|
|
||||||
@@ -99,8 +102,72 @@
|
|||||||
d.id === selectedDoc!.id ? { ...d, content } : d,
|
d.id === selectedDoc!.id ? { ...d, content } : d,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEdit(doc: Document) {
|
||||||
|
editingDoc = doc;
|
||||||
|
newDocName = doc.name;
|
||||||
|
showEditModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRename() {
|
||||||
|
if (!editingDoc || !newDocName.trim()) return;
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.update({ name: newDocName, updated_at: new Date().toISOString() })
|
||||||
|
.eq("id", editingDoc.id);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
documents = documents.map((d) =>
|
||||||
|
d.id === editingDoc!.id ? { ...d, name: newDocName } : d,
|
||||||
|
);
|
||||||
|
if (selectedDoc?.id === editingDoc.id) {
|
||||||
|
selectedDoc = { ...selectedDoc, name: newDocName };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showEditModal = false;
|
||||||
|
editingDoc = null;
|
||||||
|
newDocName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(doc: Document) {
|
||||||
|
const itemType =
|
||||||
|
doc.type === "folder" ? "folder and all its contents" : "document";
|
||||||
|
if (!confirm(`Delete this ${itemType}?`)) return;
|
||||||
|
|
||||||
|
// If deleting a folder, delete all children first
|
||||||
|
if (doc.type === "folder") {
|
||||||
|
const childIds = documents
|
||||||
|
.filter((d) => d.parent_id === doc.id)
|
||||||
|
.map((d) => d.id);
|
||||||
|
if (childIds.length > 0) {
|
||||||
|
await supabase.from("documents").delete().in("id", childIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("documents")
|
||||||
|
.delete()
|
||||||
|
.eq("id", doc.id);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
documents = documents.filter(
|
||||||
|
(d) => d.id !== doc.id && d.parent_id !== doc.id,
|
||||||
|
);
|
||||||
|
if (selectedDoc?.id === doc.id) {
|
||||||
|
selectedDoc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title
|
||||||
|
>{selectedDoc ? `${selectedDoc.name} - ` : ""}Documents - {data.org
|
||||||
|
.name} | Root</title
|
||||||
|
>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<aside class="w-72 border-r border-light/10 flex flex-col">
|
<aside class="w-72 border-r border-light/10 flex flex-col">
|
||||||
<div
|
<div
|
||||||
@@ -109,7 +176,7 @@
|
|||||||
<h2 class="font-semibold text-light">Documents</h2>
|
<h2 class="font-semibold text-light">Documents</h2>
|
||||||
<Button size="sm" onclick={() => (showCreateModal = true)}>
|
<Button size="sm" onclick={() => (showCreateModal = true)}>
|
||||||
<svg
|
<svg
|
||||||
class="w-4 h-4 mr-1"
|
class="w-4 h-4"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -118,7 +185,6 @@
|
|||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
New
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,14 +201,37 @@
|
|||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-1 overflow-hidden">
|
<main class="flex-1 overflow-hidden flex flex-col">
|
||||||
{#if selectedDoc}
|
{#if selectedDoc}
|
||||||
<Editor document={selectedDoc} onSave={handleSave} />
|
<div
|
||||||
|
class="flex items-center justify-between p-4 border-b border-light/10"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-light">
|
||||||
|
{selectedDoc.name}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium transition-colors {isEditing
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-light/10 text-light hover:bg-light/20'}"
|
||||||
|
onclick={() => (isEditing = !isEditing)}
|
||||||
|
>
|
||||||
|
{isEditing ? "Preview" : "Edit"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<Editor
|
||||||
|
document={selectedDoc}
|
||||||
|
onSave={handleSave}
|
||||||
|
editable={isEditing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-full flex items-center justify-center text-light/40">
|
<div class="h-full flex items-center justify-center text-light/40">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
@@ -210,3 +299,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showEditModal}
|
||||||
|
onClose={() => {
|
||||||
|
showEditModal = false;
|
||||||
|
editingDoc = null;
|
||||||
|
newDocName = "";
|
||||||
|
}}
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Name"
|
||||||
|
bind:value={newDocName}
|
||||||
|
placeholder="Enter new name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onclick={() => {
|
||||||
|
showEditModal = false;
|
||||||
|
editingDoc = null;
|
||||||
|
newDocName = "";
|
||||||
|
}}>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button onclick={handleRename} disabled={!newDocName.trim()}
|
||||||
|
>Save</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import {
|
import {
|
||||||
fetchBoardWithColumns,
|
fetchBoardWithColumns,
|
||||||
createBoard,
|
createBoard,
|
||||||
createCard,
|
|
||||||
moveCard,
|
moveCard,
|
||||||
} from "$lib/api/kanban";
|
} from "$lib/api/kanban";
|
||||||
import type {
|
import type {
|
||||||
@@ -31,12 +30,13 @@
|
|||||||
let boards = $state(data.boards);
|
let boards = $state(data.boards);
|
||||||
let selectedBoard = $state<BoardWithColumns | null>(null);
|
let selectedBoard = $state<BoardWithColumns | null>(null);
|
||||||
let showCreateBoardModal = $state(false);
|
let showCreateBoardModal = $state(false);
|
||||||
let showCreateCardModal = $state(false);
|
let showEditBoardModal = $state(false);
|
||||||
let showCardDetailModal = $state(false);
|
let showCardDetailModal = $state(false);
|
||||||
let selectedCard = $state<KanbanCard | null>(null);
|
let selectedCard = $state<KanbanCard | null>(null);
|
||||||
let newBoardName = $state("");
|
let newBoardName = $state("");
|
||||||
let newCardTitle = $state("");
|
let editBoardName = $state("");
|
||||||
let targetColumnId = $state<string | null>(null);
|
let targetColumnId = $state<string | null>(null);
|
||||||
|
let cardModalMode = $state<"edit" | "create">("edit");
|
||||||
|
|
||||||
async function loadBoard(boardId: string) {
|
async function loadBoard(boardId: string) {
|
||||||
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
||||||
@@ -53,36 +53,68 @@
|
|||||||
newBoardName = "";
|
newBoardName = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddCard(columnId: string) {
|
let editingBoardId = $state<string | null>(null);
|
||||||
targetColumnId = columnId;
|
|
||||||
showCreateCardModal = true;
|
function openEditBoardModal(board: KanbanBoardType) {
|
||||||
|
editingBoardId = board.id;
|
||||||
|
editBoardName = board.name;
|
||||||
|
showEditBoardModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreateCard() {
|
async function handleEditBoard() {
|
||||||
if (
|
if (!editingBoardId || !editBoardName.trim()) return;
|
||||||
!newCardTitle.trim() ||
|
|
||||||
!targetColumnId ||
|
|
||||||
!selectedBoard ||
|
|
||||||
!data.user
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const column = selectedBoard.columns.find(
|
const { error } = await supabase
|
||||||
(c) => c.id === targetColumnId,
|
.from("kanban_boards")
|
||||||
|
.update({ name: editBoardName })
|
||||||
|
.eq("id", editingBoardId);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
if (selectedBoard?.id === editingBoardId) {
|
||||||
|
selectedBoard = { ...selectedBoard, name: editBoardName };
|
||||||
|
}
|
||||||
|
boards = boards.map((b) =>
|
||||||
|
b.id === editingBoardId ? { ...b, name: editBoardName } : b,
|
||||||
);
|
);
|
||||||
const position = column?.cards.length ?? 0;
|
}
|
||||||
|
showEditBoardModal = false;
|
||||||
|
editingBoardId = null;
|
||||||
|
}
|
||||||
|
|
||||||
await createCard(
|
async function handleDeleteBoard(e: MouseEvent, board: KanbanBoardType) {
|
||||||
supabase,
|
e.stopPropagation();
|
||||||
targetColumnId,
|
if (!confirm(`Delete "${board.name}" and all its cards?`)) return;
|
||||||
newCardTitle,
|
|
||||||
position,
|
|
||||||
data.user.id,
|
|
||||||
);
|
|
||||||
await loadBoard(selectedBoard.id);
|
|
||||||
|
|
||||||
showCreateCardModal = false;
|
const { error } = await supabase
|
||||||
newCardTitle = "";
|
.from("kanban_boards")
|
||||||
|
.delete()
|
||||||
|
.eq("id", board.id);
|
||||||
|
|
||||||
|
if (!error) {
|
||||||
|
boards = boards.filter((b) => b.id !== board.id);
|
||||||
|
if (selectedBoard?.id === board.id) {
|
||||||
|
selectedBoard = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddCard(columnId: string) {
|
||||||
|
targetColumnId = columnId;
|
||||||
|
selectedCard = null;
|
||||||
|
cardModalMode = "create";
|
||||||
|
showCardDetailModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardCreated(newCard: KanbanCard) {
|
||||||
|
if (!selectedBoard) return;
|
||||||
|
selectedBoard = {
|
||||||
|
...selectedBoard,
|
||||||
|
columns: selectedBoard.columns.map((col) =>
|
||||||
|
col.id === newCard.column_id
|
||||||
|
? { ...col, cards: [...col.cards, newCard] }
|
||||||
|
: col,
|
||||||
|
),
|
||||||
|
};
|
||||||
targetColumnId = null;
|
targetColumnId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +125,35 @@
|
|||||||
) {
|
) {
|
||||||
if (!selectedBoard) return;
|
if (!selectedBoard) return;
|
||||||
|
|
||||||
await moveCard(supabase, cardId, toColumnId, toPosition);
|
// Optimistic UI update - move card immediately
|
||||||
await loadBoard(selectedBoard.id);
|
const fromColumn = selectedBoard.columns.find((c) =>
|
||||||
|
c.cards.some((card) => card.id === cardId),
|
||||||
|
);
|
||||||
|
const toColumn = selectedBoard.columns.find((c) => c.id === toColumnId);
|
||||||
|
|
||||||
|
if (!fromColumn || !toColumn) return;
|
||||||
|
|
||||||
|
const cardIndex = fromColumn.cards.findIndex((c) => c.id === cardId);
|
||||||
|
if (cardIndex === -1) return;
|
||||||
|
|
||||||
|
const [movedCard] = fromColumn.cards.splice(cardIndex, 1);
|
||||||
|
movedCard.column_id = toColumnId;
|
||||||
|
toColumn.cards.splice(toPosition, 0, movedCard);
|
||||||
|
|
||||||
|
// Trigger reactivity
|
||||||
|
selectedBoard = { ...selectedBoard };
|
||||||
|
|
||||||
|
// Persist to database in background
|
||||||
|
moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => {
|
||||||
|
console.error("Failed to persist card move:", err);
|
||||||
|
// Reload to sync state on error
|
||||||
|
loadBoard(selectedBoard!.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCardClick(card: KanbanCard) {
|
function handleCardClick(card: KanbanCard) {
|
||||||
selectedCard = card;
|
selectedCard = card;
|
||||||
|
cardModalMode = "edit";
|
||||||
showCardDetailModal = true;
|
showCardDetailModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +177,13 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title
|
||||||
|
>{selectedBoard ? `${selectedBoard.name} - ` : ""}Kanban - {data.org
|
||||||
|
.name} | Root</title
|
||||||
|
>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<aside class="w-64 border-r border-light/10 flex flex-col">
|
<aside class="w-64 border-r border-light/10 flex flex-col">
|
||||||
<div
|
<div
|
||||||
@@ -149,15 +211,62 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each boards as board}
|
{#each boards as board}
|
||||||
<button
|
<div
|
||||||
class="w-full text-left px-3 py-2 rounded-lg text-sm transition-colors {selectedBoard?.id ===
|
class="group flex items-center gap-1 px-3 py-2 rounded-lg text-sm transition-colors cursor-pointer {selectedBoard?.id ===
|
||||||
board.id
|
board.id
|
||||||
? 'bg-primary text-white'
|
? 'bg-primary text-white'
|
||||||
: 'text-light/70 hover:bg-light/5'}"
|
: 'text-light/70 hover:bg-light/5'}"
|
||||||
onclick={() => loadBoard(board.id)}
|
onclick={() => loadBoard(board.id)}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
{board.name}
|
<span class="flex-1 truncate">{board.name}</span>
|
||||||
|
<div
|
||||||
|
class="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-light/20"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEditBoardModal(board);
|
||||||
|
}}
|
||||||
|
title="Rename"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-error/20 hover:text-error"
|
||||||
|
onclick={(e) => handleDeleteBoard(e, board)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<polyline points="3,6 5,6 21,6" />
|
||||||
|
<path
|
||||||
|
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,23 +331,22 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showCreateCardModal}
|
isOpen={showEditBoardModal}
|
||||||
onClose={() => (showCreateCardModal = false)}
|
onClose={() => (showEditBoardModal = false)}
|
||||||
title="Add Card"
|
title="Edit Board"
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
label="Title"
|
label="Board Name"
|
||||||
bind:value={newCardTitle}
|
bind:value={editBoardName}
|
||||||
placeholder="Card title"
|
placeholder="Board name"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button
|
<Button variant="ghost" onclick={() => (showEditBoardModal = false)}
|
||||||
variant="ghost"
|
>Cancel</Button
|
||||||
onclick={() => (showCreateCardModal = false)}>Cancel</Button
|
|
||||||
>
|
>
|
||||||
<Button onclick={handleCreateCard} disabled={!newCardTitle.trim()}
|
<Button onclick={handleEditBoard} disabled={!editBoardName.trim()}
|
||||||
>Add</Button
|
>Save</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,7 +358,12 @@
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
showCardDetailModal = false;
|
showCardDetailModal = false;
|
||||||
selectedCard = null;
|
selectedCard = null;
|
||||||
|
targetColumnId = null;
|
||||||
}}
|
}}
|
||||||
onUpdate={handleCardUpdate}
|
onUpdate={handleCardUpdate}
|
||||||
onDelete={handleCardDelete}
|
onDelete={handleCardDelete}
|
||||||
|
mode={cardModalMode}
|
||||||
|
columnId={targetColumnId ?? undefined}
|
||||||
|
userId={data.user?.id}
|
||||||
|
onCreate={handleCardCreated}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -609,6 +609,7 @@
|
|||||||
<Card>
|
<Card>
|
||||||
<div class="divide-y divide-light/10">
|
<div class="divide-y divide-light/10">
|
||||||
{#each members as member}
|
{#each members as member}
|
||||||
|
{@const profile = member.profiles}
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
|
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
|
||||||
>
|
>
|
||||||
@@ -616,16 +617,18 @@
|
|||||||
<div
|
<div
|
||||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
||||||
>
|
>
|
||||||
{(member.profiles.full_name ||
|
{(profile?.full_name ||
|
||||||
member.profiles.email ||
|
profile?.email ||
|
||||||
"?")[0].toUpperCase()}
|
"?")[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-light font-medium">
|
<p class="text-light font-medium">
|
||||||
{member.profiles.full_name || "No name"}
|
{profile?.full_name ||
|
||||||
|
profile?.email ||
|
||||||
|
"Unknown User"}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-light/50">
|
<p class="text-sm text-light/50">
|
||||||
{member.profiles.email}
|
{profile?.email || "No email"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,32 +1,55 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* Colors - Dark theme */
|
/* Colors - Figma Design System */
|
||||||
--color-dark: #0a0a0f;
|
--color-background: #05090f;
|
||||||
--color-surface: #14141f;
|
--color-night: #0A121F;
|
||||||
--color-light: #f0f0f5;
|
--color-dark: #14243E;
|
||||||
|
--color-surface: #0A121F;
|
||||||
|
--color-light: #E5E6F0;
|
||||||
|
--color-text: #FFFFFF;
|
||||||
|
--color-text-muted: rgba(229, 230, 240, 0.5);
|
||||||
|
|
||||||
/* Brand */
|
/* Brand - Primary */
|
||||||
--color-primary: #6366f1;
|
--color-primary: #00A3E0;
|
||||||
--color-primary-hover: #4f46e5;
|
--color-primary-hover: #33b5e6;
|
||||||
|
|
||||||
/* Status */
|
/* Status Colors */
|
||||||
--color-success: #22c55e;
|
--color-success: #33E000;
|
||||||
--color-warning: #f59e0b;
|
--color-warning: #FFAB00;
|
||||||
--color-error: #ef4444;
|
--color-error: #E03D00;
|
||||||
--color-info: #3b82f6;
|
--color-info: #00A3E0;
|
||||||
|
|
||||||
/* Font */
|
/* Typography - Figma Fonts */
|
||||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
--font-heading: 'Tilt Warp', sans-serif;
|
||||||
|
--font-body: 'Work Sans', sans-serif;
|
||||||
|
--font-sans: 'Work Sans', system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
|
/* Border Radius - Figma Design */
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-lg: 24px;
|
||||||
|
--radius-xl: 32px;
|
||||||
|
--radius-pill: 32px;
|
||||||
|
--radius-circle: 128px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
body {
|
html, body {
|
||||||
background-color: var(--color-dark);
|
background-color: var(--color-background);
|
||||||
color: var(--color-light);
|
color: var(--color-light);
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar styling */
|
/* Scrollbar styling */
|
||||||
@@ -36,14 +59,105 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: var(--color-dark);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-light) / 0.2;
|
background: var(--color-night);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-pill);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-light) / 0.3;
|
background: var(--color-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus styles */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background-color: rgba(0, 163, 224, 0.3);
|
||||||
|
color: var(--color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prose/Markdown styles */
|
||||||
|
.prose {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose strong {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose code {
|
||||||
|
background: var(--color-night);
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre {
|
||||||
|
background: var(--color-night);
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose blockquote {
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
padding-left: 1em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul, .prose ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose li {
|
||||||
|
margin: 0.25em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose h1, .prose h2, .prose h3, .prose h4 {
|
||||||
|
color: var(--color-light);
|
||||||
|
margin: 0.75em 0 0.5em;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-dark);
|
||||||
|
margin: 1em 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,20 @@
|
|||||||
Card,
|
Card,
|
||||||
Modal,
|
Modal,
|
||||||
Spinner,
|
Spinner,
|
||||||
Toggle
|
Toggle,
|
||||||
} from '$lib/components/ui';
|
Toast,
|
||||||
|
} from "$lib/components/ui";
|
||||||
|
|
||||||
let inputValue = $state('');
|
let inputValue = $state("");
|
||||||
let textareaValue = $state('');
|
let textareaValue = $state("");
|
||||||
let selectValue = $state('');
|
let selectValue = $state("");
|
||||||
let toggleChecked = $state(false);
|
let toggleChecked = $state(false);
|
||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
|
|
||||||
const selectOptions = [
|
const selectOptions = [
|
||||||
{ value: 'option1', label: 'Option 1' },
|
{ value: "option1", label: "Option 1" },
|
||||||
{ value: 'option2', label: 'Option 2' },
|
{ value: "option2", label: "Option 2" },
|
||||||
{ value: 'option3', label: 'Option 3' }
|
{ value: "option3", label: "Option 3" },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -31,66 +32,95 @@
|
|||||||
|
|
||||||
<div class="min-h-screen bg-dark p-8">
|
<div class="min-h-screen bg-dark p-8">
|
||||||
<div class="max-w-6xl mx-auto space-y-12">
|
<div class="max-w-6xl mx-auto space-y-12">
|
||||||
|
<!-- Back Button -->
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center gap-2 text-light/60 hover:text-light transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back to Home
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="text-center space-y-4">
|
<header class="text-center space-y-4">
|
||||||
<h1 class="text-4xl font-bold text-light">Root Style Guide</h1>
|
<h1 class="text-4xl font-bold text-light">Root Style Guide</h1>
|
||||||
<p class="text-light/60">All UI components and their variants</p>
|
<p class="text-light/60">All UI components and their variants</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Colors -->
|
<!-- Colors - Figma Design System -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Colors</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Colors
|
||||||
|
</h2>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="w-full h-20 rounded-xl bg-dark border border-light/20"></div>
|
<div
|
||||||
|
class="w-full h-20 rounded-[32px] bg-background border border-light/20"
|
||||||
|
></div>
|
||||||
|
<p class="text-sm text-light/60">Background</p>
|
||||||
|
<code class="text-xs text-light/40">#05090F</code>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="w-full h-20 rounded-[32px] bg-night"></div>
|
||||||
|
<p class="text-sm text-light/60">Night</p>
|
||||||
|
<code class="text-xs text-light/40">#0A121F</code>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="w-full h-20 rounded-[32px] bg-dark"></div>
|
||||||
<p class="text-sm text-light/60">Dark</p>
|
<p class="text-sm text-light/60">Dark</p>
|
||||||
<code class="text-xs text-light/40">#0a0a0f</code>
|
<code class="text-xs text-light/40">#14243E</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="w-full h-20 rounded-xl bg-surface"></div>
|
<div class="w-full h-20 rounded-[32px] bg-light"></div>
|
||||||
<p class="text-sm text-light/60">Surface</p>
|
|
||||||
<code class="text-xs text-light/40">#14141f</code>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="w-full h-20 rounded-xl bg-light"></div>
|
|
||||||
<p class="text-sm text-light/60">Light</p>
|
<p class="text-sm text-light/60">Light</p>
|
||||||
<code class="text-xs text-light/40">#f0f0f5</code>
|
<code class="text-xs text-light/40">#E5E6F0</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="w-full h-20 rounded-xl bg-primary"></div>
|
<div class="w-full h-20 rounded-[32px] bg-primary"></div>
|
||||||
<p class="text-sm text-light/60">Primary</p>
|
<p class="text-sm text-light/60">Primary</p>
|
||||||
<code class="text-xs text-light/40">#6366f1</code>
|
<code class="text-xs text-light/40">#00A3E0</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="w-full h-20 rounded-xl bg-success"></div>
|
<div class="w-full h-20 rounded-[32px] bg-success"></div>
|
||||||
<p class="text-sm text-light/60">Success</p>
|
<p class="text-sm text-light/60">Success</p>
|
||||||
<code class="text-xs text-light/40">#22c55e</code>
|
<code class="text-xs text-light/40">#33E000</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="w-full h-20 rounded-xl bg-warning"></div>
|
<div class="w-full h-20 rounded-[32px] bg-warning"></div>
|
||||||
<p class="text-sm text-light/60">Warning</p>
|
<p class="text-sm text-light/60">Warning</p>
|
||||||
<code class="text-xs text-light/40">#f59e0b</code>
|
<code class="text-xs text-light/40">#FFAB00</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="w-full h-20 rounded-xl bg-error"></div>
|
<div class="w-full h-20 rounded-[32px] bg-error"></div>
|
||||||
<p class="text-sm text-light/60">Error</p>
|
<p class="text-sm text-light/60">Error</p>
|
||||||
<code class="text-xs text-light/40">#ef4444</code>
|
<code class="text-xs text-light/40">#E03D00</code>
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="w-full h-20 rounded-xl bg-info"></div>
|
|
||||||
<p class="text-sm text-light/60">Info</p>
|
|
||||||
<code class="text-xs text-light/40">#3b82f6</code>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Buttons</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Buttons
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Variants</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Variants
|
||||||
|
</h3>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Button variant="primary">Primary</Button>
|
<Button variant="primary">Primary</Button>
|
||||||
<Button variant="secondary">Secondary</Button>
|
<Button variant="secondary">Secondary</Button>
|
||||||
@@ -101,9 +131,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Sizes
|
||||||
|
</h3>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Button size="xs">Extra Small</Button>
|
|
||||||
<Button size="sm">Small</Button>
|
<Button size="sm">Small</Button>
|
||||||
<Button size="md">Medium</Button>
|
<Button size="md">Medium</Button>
|
||||||
<Button size="lg">Large</Button>
|
<Button size="lg">Large</Button>
|
||||||
@@ -111,7 +142,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">States</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
States
|
||||||
|
</h3>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Button>Normal</Button>
|
<Button>Normal</Button>
|
||||||
<Button disabled>Disabled</Button>
|
<Button disabled>Disabled</Button>
|
||||||
@@ -120,7 +153,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Full Width</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Full Width
|
||||||
|
</h3>
|
||||||
<div class="max-w-sm">
|
<div class="max-w-sm">
|
||||||
<Button fullWidth>Full Width Button</Button>
|
<Button fullWidth>Full Width Button</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,45 +165,103 @@
|
|||||||
|
|
||||||
<!-- Inputs -->
|
<!-- Inputs -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Inputs</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Inputs
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
<Input label="Default Input" placeholder="Enter text..." bind:value={inputValue} />
|
<Input
|
||||||
<Input label="Required Field" placeholder="Required..." required />
|
label="Default Input"
|
||||||
<Input label="With Hint" placeholder="Email..." hint="We'll never share your email" />
|
placeholder="Enter text..."
|
||||||
<Input label="With Error" placeholder="Password..." error="Password is too short" />
|
bind:value={inputValue}
|
||||||
<Input label="Disabled" placeholder="Can't edit this" disabled />
|
/>
|
||||||
<Input type="password" label="Password" placeholder="••••••••" />
|
<Input
|
||||||
|
label="Required Field"
|
||||||
|
placeholder="Required..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="With Hint"
|
||||||
|
placeholder="Email..."
|
||||||
|
hint="We'll never share your email"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="With Error"
|
||||||
|
placeholder="Password..."
|
||||||
|
error="Password is too short"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Disabled"
|
||||||
|
placeholder="Can't edit this"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
label="Password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Textarea -->
|
<!-- Textarea -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Textarea</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Textarea
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
<Textarea label="Default Textarea" placeholder="Enter description..." bind:value={textareaValue} />
|
<Textarea
|
||||||
<Textarea label="With Error" placeholder="Description..." error="Description is required" />
|
label="Default Textarea"
|
||||||
|
placeholder="Enter description..."
|
||||||
|
bind:value={textareaValue}
|
||||||
|
/>
|
||||||
|
<Textarea
|
||||||
|
label="With Error"
|
||||||
|
placeholder="Description..."
|
||||||
|
error="Description is required"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Select -->
|
<!-- Select -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Select</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
<Select label="Default Select" options={selectOptions} bind:value={selectValue} />
|
<Select
|
||||||
<Select label="With Error" options={selectOptions} error="Please select an option" />
|
label="Default Select"
|
||||||
|
options={selectOptions}
|
||||||
|
bind:value={selectValue}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="With Error"
|
||||||
|
options={selectOptions}
|
||||||
|
error="Please select an option"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Avatars -->
|
<!-- Avatars -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Avatars</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Avatars
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Sizes
|
||||||
|
</h3>
|
||||||
<div class="flex items-end gap-4">
|
<div class="flex items-end gap-4">
|
||||||
<Avatar name="John Doe" size="xs" />
|
<Avatar name="John Doe" size="xs" />
|
||||||
<Avatar name="John Doe" size="sm" />
|
<Avatar name="John Doe" size="sm" />
|
||||||
@@ -180,17 +273,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">With Status</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
With Status
|
||||||
|
</h3>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Avatar name="Online User" size="lg" status="online" />
|
<Avatar name="Online User" size="lg" status="online" />
|
||||||
<Avatar name="Away User" size="lg" status="away" />
|
<Avatar name="Away User" size="lg" status="away" />
|
||||||
<Avatar name="Busy User" size="lg" status="busy" />
|
<Avatar name="Busy User" size="lg" status="busy" />
|
||||||
<Avatar name="Offline User" size="lg" status="offline" />
|
<Avatar
|
||||||
|
name="Offline User"
|
||||||
|
size="lg"
|
||||||
|
status="offline"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Different Names (Color Generation)</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Different Names (Color Generation)
|
||||||
|
</h3>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Avatar name="Alice" size="lg" />
|
<Avatar name="Alice" size="lg" />
|
||||||
<Avatar name="Bob" size="lg" />
|
<Avatar name="Bob" size="lg" />
|
||||||
@@ -204,11 +305,17 @@
|
|||||||
|
|
||||||
<!-- Badges -->
|
<!-- Badges -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Badges</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Badges
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Variants</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Variants
|
||||||
|
</h3>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Badge variant="default">Default</Badge>
|
<Badge variant="default">Default</Badge>
|
||||||
<Badge variant="primary">Primary</Badge>
|
<Badge variant="primary">Primary</Badge>
|
||||||
@@ -220,7 +327,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Sizes
|
||||||
|
</h3>
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Badge size="sm">Small</Badge>
|
<Badge size="sm">Small</Badge>
|
||||||
<Badge size="md">Medium</Badge>
|
<Badge size="md">Medium</Badge>
|
||||||
@@ -232,31 +341,47 @@
|
|||||||
|
|
||||||
<!-- Cards -->
|
<!-- Cards -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Cards</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Cards
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="grid md:grid-cols-3 gap-6">
|
<div class="grid md:grid-cols-3 gap-6">
|
||||||
<Card variant="default">
|
<Card variant="default">
|
||||||
<h3 class="font-semibold text-light mb-2">Default Card</h3>
|
<h3 class="font-semibold text-light mb-2">Default Card</h3>
|
||||||
<p class="text-light/60 text-sm">This is a default card with medium padding.</p>
|
<p class="text-light/60 text-sm">
|
||||||
|
This is a default card with medium padding.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card variant="elevated">
|
<Card variant="elevated">
|
||||||
<h3 class="font-semibold text-light mb-2">Elevated Card</h3>
|
<h3 class="font-semibold text-light mb-2">Elevated Card</h3>
|
||||||
<p class="text-light/60 text-sm">This card has a shadow for elevation.</p>
|
<p class="text-light/60 text-sm">
|
||||||
|
This card has a shadow for elevation.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card variant="outlined">
|
<Card variant="outlined">
|
||||||
<h3 class="font-semibold text-light mb-2">Outlined Card</h3>
|
<h3 class="font-semibold text-light mb-2">Outlined Card</h3>
|
||||||
<p class="text-light/60 text-sm">This card has a subtle border.</p>
|
<p class="text-light/60 text-sm">
|
||||||
|
This card has a subtle border.
|
||||||
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Toggle -->
|
<!-- Toggle -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Toggle</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Toggle
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Sizes
|
||||||
|
</h3>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Toggle size="sm" />
|
<Toggle size="sm" />
|
||||||
@@ -274,7 +399,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">States</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
States
|
||||||
|
</h3>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Toggle />
|
<Toggle />
|
||||||
@@ -295,11 +422,17 @@
|
|||||||
|
|
||||||
<!-- Spinners -->
|
<!-- Spinners -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Spinners</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Spinners
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Sizes</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Sizes
|
||||||
|
</h3>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
<Spinner size="md" />
|
<Spinner size="md" />
|
||||||
@@ -308,7 +441,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">Colors</h3>
|
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||||
|
Colors
|
||||||
|
</h3>
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<Spinner color="primary" />
|
<Spinner color="primary" />
|
||||||
<Spinner color="light" />
|
<Spinner color="light" />
|
||||||
@@ -322,50 +457,112 @@
|
|||||||
|
|
||||||
<!-- Modal -->
|
<!-- Modal -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Modal</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Modal
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button onclick={() => (modalOpen = true)}>Open Modal</Button>
|
<Button onclick={() => (modalOpen = true)}>Open Modal</Button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Modal isOpen={modalOpen} onClose={() => (modalOpen = false)} title="Example Modal">
|
<Modal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => (modalOpen = false)}
|
||||||
|
title="Example Modal"
|
||||||
|
>
|
||||||
<p class="text-light/70 mb-4">
|
<p class="text-light/70 mb-4">
|
||||||
This is an example modal dialog. You can put any content here.
|
This is an example modal dialog. You can put any content here.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3 justify-end">
|
<div class="flex gap-3 justify-end">
|
||||||
<Button variant="secondary" onclick={() => (modalOpen = false)}>Cancel</Button>
|
<Button variant="secondary" onclick={() => (modalOpen = false)}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
<Button onclick={() => (modalOpen = false)}>Confirm</Button>
|
<Button onclick={() => (modalOpen = false)}>Confirm</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- Typography -->
|
<!-- Typography -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-2xl font-semibold text-light border-b border-light/10 pb-2">Typography</h2>
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Typography
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h1 class="text-4xl font-bold text-light">Heading 1 (4xl bold)</h1>
|
<h1 class="text-4xl font-bold text-light">
|
||||||
<h2 class="text-3xl font-bold text-light">Heading 2 (3xl bold)</h2>
|
Heading 1 (4xl bold)
|
||||||
<h3 class="text-2xl font-semibold text-light">Heading 3 (2xl semibold)</h3>
|
</h1>
|
||||||
<h4 class="text-xl font-semibold text-light">Heading 4 (xl semibold)</h4>
|
<h2 class="text-3xl font-bold text-light">
|
||||||
<h5 class="text-lg font-medium text-light">Heading 5 (lg medium)</h5>
|
Heading 2 (3xl bold)
|
||||||
<h6 class="text-base font-medium text-light">Heading 6 (base medium)</h6>
|
</h2>
|
||||||
|
<h3 class="text-2xl font-semibold text-light">
|
||||||
|
Heading 3 (2xl semibold)
|
||||||
|
</h3>
|
||||||
|
<h4 class="text-xl font-semibold text-light">
|
||||||
|
Heading 4 (xl semibold)
|
||||||
|
</h4>
|
||||||
|
<h5 class="text-lg font-medium text-light">
|
||||||
|
Heading 5 (lg medium)
|
||||||
|
</h5>
|
||||||
|
<h6 class="text-base font-medium text-light">
|
||||||
|
Heading 6 (base medium)
|
||||||
|
</h6>
|
||||||
<p class="text-base text-light/80">
|
<p class="text-base text-light/80">
|
||||||
Body text (base, 80% opacity) - Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
Body text (base, 80% opacity) - Lorem ipsum dolor sit amet,
|
||||||
Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
|
consectetur adipiscing elit. Sed do eiusmod tempor
|
||||||
|
incididunt ut labore et dolore magna aliqua.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-light/60">
|
<p class="text-sm text-light/60">
|
||||||
Small text (sm, 60% opacity) - Used for secondary information and hints.
|
Small text (sm, 60% opacity) - Used for secondary
|
||||||
|
information and hints.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-light/40">
|
<p class="text-xs text-light/40">
|
||||||
Extra small text (xs, 40% opacity) - Used for metadata and timestamps.
|
Extra small text (xs, 40% opacity) - Used for metadata and
|
||||||
|
timestamps.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Toasts -->
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h2
|
||||||
|
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||||
|
>
|
||||||
|
Toasts
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<Toast
|
||||||
|
variant="success"
|
||||||
|
title="Success"
|
||||||
|
message="This is a success toast and will be dismissed after 5 seconds."
|
||||||
|
/>
|
||||||
|
<Toast
|
||||||
|
variant="error"
|
||||||
|
title="Error"
|
||||||
|
message="This is an error toast and must be dismissed by the user."
|
||||||
|
/>
|
||||||
|
<Toast
|
||||||
|
variant="warning"
|
||||||
|
title="Warning"
|
||||||
|
message="This is a warning toast and must be dismissed by the user."
|
||||||
|
/>
|
||||||
|
<Toast
|
||||||
|
variant="info"
|
||||||
|
title="Info"
|
||||||
|
message="This is an info toast and must be dismissed by the user."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="text-center py-8 border-t border-light/10">
|
<footer class="text-center py-8 border-t border-light/10">
|
||||||
<p class="text-light/40 text-sm">Root Organization Platform - Style Guide</p>
|
<p class="text-light/40 text-sm">
|
||||||
|
Root Organization Platform - Style Guide
|
||||||
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
3
supabase/migrations/007_org_theme.sql
Normal file
3
supabase/migrations/007_org_theme.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add theme color and icon to organizations
|
||||||
|
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS theme_color TEXT DEFAULT '#00a3e0';
|
||||||
|
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS icon_url TEXT;
|
||||||
42
supabase/migrations/008_kanban_enhancements.sql
Normal file
42
supabase/migrations/008_kanban_enhancements.sql
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
-- Add assignee, due date, and checklist support to kanban cards
|
||||||
|
|
||||||
|
-- Add assignee_id to kanban_cards (references profiles)
|
||||||
|
ALTER TABLE kanban_cards ADD COLUMN IF NOT EXISTS assignee_id UUID REFERENCES profiles(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Add due_date for SLA/deadline tracking
|
||||||
|
ALTER TABLE kanban_cards ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Add priority field
|
||||||
|
ALTER TABLE kanban_cards ADD COLUMN IF NOT EXISTS priority TEXT CHECK (priority IN ('low', 'medium', 'high', 'urgent')) DEFAULT 'medium';
|
||||||
|
|
||||||
|
-- Create checklist items table
|
||||||
|
CREATE TABLE IF NOT EXISTS kanban_checklist_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
card_id UUID NOT NULL REFERENCES kanban_cards(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
completed BOOLEAN DEFAULT false,
|
||||||
|
position INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enable RLS on checklist items
|
||||||
|
ALTER TABLE kanban_checklist_items ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Checklist items inherit access from the card's board -> org
|
||||||
|
CREATE POLICY "Checklist items inherit card access" ON kanban_checklist_items
|
||||||
|
FOR ALL USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM kanban_cards c
|
||||||
|
JOIN kanban_columns col ON c.column_id = col.id
|
||||||
|
JOIN kanban_boards b ON col.board_id = b.id
|
||||||
|
JOIN org_members m ON b.org_id = m.org_id
|
||||||
|
WHERE c.id = kanban_checklist_items.card_id
|
||||||
|
AND m.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create index for faster lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kanban_cards_assignee ON kanban_cards(assignee_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kanban_cards_due_date ON kanban_cards(due_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_checklist_items_card ON kanban_checklist_items(card_id);
|
||||||
57
supabase/migrations/009_activity_tracking.sql
Normal file
57
supabase/migrations/009_activity_tracking.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-- Activity tracking for recent activity feed
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
action TEXT NOT NULL, -- 'create', 'update', 'delete', 'move', 'assign', etc.
|
||||||
|
entity_type TEXT NOT NULL, -- 'document', 'kanban_card', 'kanban_board', 'member', etc.
|
||||||
|
entity_id UUID,
|
||||||
|
entity_name TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Users can view activity for orgs they belong to
|
||||||
|
CREATE POLICY "Users can view org activity" ON activity_log
|
||||||
|
FOR SELECT USING (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM org_members m
|
||||||
|
WHERE m.org_id = activity_log.org_id
|
||||||
|
AND m.user_id = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Users can insert their own activity
|
||||||
|
CREATE POLICY "Users can insert own activity" ON activity_log
|
||||||
|
FOR INSERT WITH CHECK (user_id = auth.uid());
|
||||||
|
|
||||||
|
-- Indexes for faster queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_log_org ON activity_log(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_log_created ON activity_log(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_log_entity ON activity_log(entity_type, entity_id);
|
||||||
|
|
||||||
|
-- User preferences table for themes and settings
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE UNIQUE,
|
||||||
|
theme TEXT DEFAULT 'dark' CHECK (theme IN ('dark', 'light', 'system')),
|
||||||
|
accent_color TEXT DEFAULT '#00A3E0',
|
||||||
|
use_org_theme BOOLEAN DEFAULT true, -- Whether org theme overrides user theme
|
||||||
|
sidebar_collapsed BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enable RLS
|
||||||
|
ALTER TABLE user_preferences ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Users can manage their own preferences
|
||||||
|
CREATE POLICY "Users can manage own preferences" ON user_preferences
|
||||||
|
FOR ALL USING (user_id = auth.uid());
|
||||||
|
|
||||||
|
-- Index
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
|
||||||
Reference in New Issue
Block a user