diff --git a/package-lock.json b/package-lock.json index 33ede45..191848c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,11 +32,14 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@types/js-cookie": "^3.0.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "js-cookie": "^3.0.5", "lucide-react": "^0.553.0", "next": "16.0.1", + "radix-ui": "^1.4.3", "react": "19.2.0", "react-dom": "19.2.0", "tailwind-merge": "^3.4.0" @@ -46,6 +49,7 @@ "@types/node": "^20.19.25", "@types/react": "^19", "@types/react-dom": "^19", + "baseline-browser-mapping": "^2.10.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5" @@ -992,6 +996,106 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accessible-icon": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -1015,6 +1119,86 @@ } } }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", @@ -1119,6 +1303,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -1284,6 +1496,88 @@ } } }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -1406,6 +1700,138 @@ } } }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", @@ -1582,6 +2008,62 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -1644,6 +2126,67 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", @@ -1690,6 +2233,39 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -1708,6 +2284,35 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -1738,6 +2343,146 @@ } } }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -1860,6 +2605,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -1875,6 +2638,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", @@ -2220,6 +2998,12 @@ "tailwindcss": "4.1.17" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.25", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", @@ -2268,6 +3052,19 @@ "node": ">=10" } }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001754", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", @@ -2415,6 +3212,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/lightningcss": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -2828,6 +3634,147 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", diff --git a/package.json b/package.json index 4e09088..dacb107 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,14 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@types/js-cookie": "^3.0.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "js-cookie": "^3.0.5", "lucide-react": "^0.553.0", "next": "16.0.1", + "radix-ui": "^1.4.3", "react": "19.2.0", "react-dom": "19.2.0", "tailwind-merge": "^3.4.0" @@ -46,6 +49,7 @@ "@types/node": "^20.19.25", "@types/react": "^19", "@types/react-dom": "^19", + "baseline-browser-mapping": "^2.10.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5" diff --git a/src/app/globals.css b/src/app/globals.css index 10dc118..3e7c8bc 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -46,72 +46,99 @@ } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --radius: 0.75rem; + + --background: oklch(0.97 0.01 240); + --foreground: oklch(0.22 0.02 240); + + --card: oklch(0.99 0.005 240); + --card-foreground: oklch(0.22 0.02 240); + --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --popover-foreground: oklch(0.22 0.02 240); + + /* Primary = architectural blue */ + --primary: oklch(0.55 0.16 250); + --primary-foreground: oklch(0.98 0.01 250); + + --secondary: oklch(0.93 0.02 240); + --secondary-foreground: oklch(0.28 0.02 240); + + --muted: oklch(0.94 0.015 240); + --muted-foreground: oklch(0.55 0.02 240); + + /* Accent = subtle violet for graph highlights */ + --accent: oklch(0.92 0.03 270); + --accent-foreground: oklch(0.30 0.04 270); + + --destructive: oklch(0.62 0.22 25); + + --border: oklch(0.90 0.015 240); + --input: oklch(0.90 0.015 240); + --ring: oklch(0.55 0.16 250); + + /* Charts (clean + modern) */ + --chart-1: oklch(0.55 0.16 250); + --chart-2: oklch(0.60 0.14 200); + --chart-3: oklch(0.62 0.18 280); + --chart-4: oklch(0.70 0.15 140); + --chart-5: oklch(0.65 0.20 30); + + --sidebar: oklch(0.95 0.01 240); + --sidebar-foreground: oklch(0.25 0.02 240); + --sidebar-primary: oklch(0.55 0.16 250); + --sidebar-primary-foreground: oklch(0.98 0.01 250); + --sidebar-accent: oklch(0.92 0.02 250); + --sidebar-accent-foreground: oklch(0.30 0.02 250); + --sidebar-border: oklch(0.88 0.015 240); + --sidebar-ring: oklch(0.55 0.16 250); } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --background: oklch(0.17 0.02 250); + --foreground: oklch(0.94 0.01 240); + + --card: oklch(0.21 0.02 250); + --card-foreground: oklch(0.94 0.01 240); + + --popover: oklch(0.23 0.02 250); + --popover-foreground: oklch(0.94 0.01 240); + + /* Strong but not neon */ + --primary: oklch(0.65 0.17 250); + --primary-foreground: oklch(0.12 0.02 250); + + --secondary: oklch(0.27 0.02 250); + --secondary-foreground: oklch(0.94 0.01 240); + + --muted: oklch(0.25 0.02 250); + --muted-foreground: oklch(0.70 0.02 240); + + /* Accent for graph edges / highlights */ + --accent: oklch(0.60 0.18 280); + --accent-foreground: oklch(0.96 0.01 240); + + --destructive: oklch(0.68 0.20 25); + + --border: oklch(0.30 0.02 250); + --input: oklch(0.30 0.02 250); + --ring: oklch(0.65 0.17 250); + + /* Chart colors (balanced + readable on dark canvas) */ + --chart-1: oklch(0.65 0.17 250); + --chart-2: oklch(0.70 0.14 180); + --chart-3: oklch(0.72 0.18 300); + --chart-4: oklch(0.75 0.15 140); + --chart-5: oklch(0.72 0.20 40); + + --sidebar: oklch(0.19 0.02 250); + --sidebar-foreground: oklch(0.92 0.01 240); + --sidebar-primary: oklch(0.65 0.17 250); + --sidebar-primary-foreground: oklch(0.12 0.02 250); + --sidebar-accent: oklch(0.27 0.02 250); + --sidebar-accent-foreground: oklch(0.92 0.01 240); + --sidebar-border: oklch(0.30 0.02 250); + --sidebar-ring: oklch(0.65 0.17 250); } @layer base { @@ -204,4 +231,9 @@ h3.ce-header { .ce-inline-tool:hover { color: var(--popover-foreground) !important; background-color: var(--accent) !important; -} \ No newline at end of file +} + +.ce-block, .ce-block__content { + width: 100% !important; + max-width: 100% !important; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 11234a9..921e0d5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Drift - Productivity App", + description: "A powerful client-side productivity application", }; export default function RootLayout({ @@ -23,9 +23,9 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index e2dd8d3..598f5de 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); + redirect("/workspace"); } diff --git a/src/app/workspace/page.tsx b/src/app/workspace/page.tsx index 5e945c9..7615d84 100644 --- a/src/app/workspace/page.tsx +++ b/src/app/workspace/page.tsx @@ -2,25 +2,154 @@ import Router from "@/components/Router"; import AppSidebar, { SidebarTrigger } from "@/components/sidebar/AppSidebar"; -import useWorkspace from "@/hooks/workspace"; +import { useWorkspace } from "@/hooks/workspace"; import { ChevronRight } from "lucide-react"; +import Cookies from "js-cookie"; +import { useEffect, useState } from "react"; +import { Route } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { + createDefaultWorkspace, + saveWorkspaceToLocalStorage, +} from "@/lib/workspace"; export default function Workspace() { - const workspace = useWorkspace(); + const { + workspace, + loadWorkspace, + updateEntity, + setWorkspace, + addEntity, + addGroup, + renameEntity, + deleteEntity, + removeNode, + renameGroup, + } = useWorkspace(); + + const [route, setRoute] = useState({}); + + /** + * Initialize workspace from cookie on mount + */ + useEffect(() => { + const routeString = Cookies.get("route"); + if (!routeString) return; + const route = JSON.parse(routeString) as Route; + setRoute(route); + }, []); + + /** + * Load workspace when currentWorkspace changes. + * + * `loadWorkspace` is intentionally omitted from the dependency array + * because it is recreated on every render by the `useWorkspace` hook. + * Including it would cause this effect to run repeatedly and trigger + * the "maximum update depth exceeded" error. ESLint warning is disabled + * for this case. + */ + useEffect(() => { + loadWorkspace(route.workspace); + if (route.workspace) { + Cookies.set("route", JSON.stringify(route)); + } else { + Cookies.remove("route"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [route]); + + /** + * Load route (entity) from localStorage when workspace loads + */ + useEffect(() => { + if (!workspace) return; + + try { + const savedRoute = localStorage.getItem(`route_${workspace.id}`); + + if (savedRoute) { + const parsedRoute: Route = JSON.parse(savedRoute); + setRoute(parsedRoute); + } else { + // Default to first entity if no route saved + const firstEntityId = Object.keys(workspace.entities)[0]; + if (firstEntityId) { + setRoute({ workspace: workspace.id, entity: firstEntityId }); + } + } + } catch (err) { + console.error("Failed to load route:", err); + const firstEntityId = Object.keys(workspace.entities)[0]; + if (firstEntityId) { + setRoute({ workspace: workspace.id, entity: firstEntityId }); + } + } + }, [workspace?.id]); + + /** + * Save route whenever it changes + */ + useEffect(() => { + if (!workspace || !route.entity) return; + + try { + localStorage.setItem( + `route_${workspace.id}`, + JSON.stringify({ workspace: workspace.id, entity: route.entity }), + ); + } catch (err) { + console.error("Failed to save route:", err); + } + }, [route, workspace?.id]); + + if (!workspace) { + return ( +
{ + const w = createDefaultWorkspace(); + saveWorkspaceToLocalStorage(w); + setRoute({ workspace: w.id }); + }} + > +
+

Welcome to Drift

+

+ Your personal productivity workspace +

+ +
+
+ ); + } return ( - +
-
- {workspace.name} - - {workspace.entities[workspace.route].name} +
+ {workspace.name} + + + {workspace.entities[route.entity || ""]?.name || "Select an item"} +
- +
); } diff --git a/src/components/Router.tsx b/src/components/Router.tsx index 41a4a99..9d3ee67 100644 --- a/src/components/Router.tsx +++ b/src/components/Router.tsx @@ -1,44 +1,122 @@ "use client"; -import { Workspace } from "@/hooks/workspace"; -import { Task } from "@/lib/types"; +import { + Entity, + EntityId, + Route, + Task, + TaskId, + TodoEntity, + Workspace, +} from "@/lib/types"; import TaskView from "./view/TaskView"; import DocView from "./view/DocView"; +import { OutputData } from "@editorjs/editorjs"; const id = (prefix = "t") => `${prefix}_${Math.random().toString(36).slice(2, 9)}`; -export default function Router({ workspace }: { workspace: Workspace }) { - const entity = workspace.entities[workspace.route]; - switch (entity.kind) { - case "todo": - const createTask = (status: string) => { +export default function Router({ + route, + workspace, + updateEntity, +}: { + route: Route; + workspace: Workspace; + updateEntity: (entityId: EntityId, updater: (e: Entity) => Entity) => void; +}) { + const entityId = route.entity; + const entity = entityId ? workspace.entities[entityId] : null; + + if (!entity || !entityId) return null; + + switch (entity.type) { + case "todo": { + const createTask = (statusId: string) => { + const now = new Date().getTime(); const task: Task = { - id: id(), + id: id("task"), title: "New task", - status: status, + statusId, + tagIds: [], + // initialize empty rich document notes + doc: { version: "2.28.0", time: now, blocks: [] }, + createdAt: now, + updatedAt: now, }; - entity.setTasks((c: Task[]) => [...c, task]); + + updateEntity(entityId, (e) => ({ + ...e, + tasks: e.type === "todo" ? { ...e.tasks, [task.id]: task } : {}, + })); + }; + + const setTasks = (tasks: Record) => { + updateEntity(entityId, (e) => ({ + ...e, + tasks: e.type === "todo" ? tasks : {}, + })); + }; + + const setTags = (tags: Record) => { + updateEntity(entityId, (e) => ({ + ...e, + tags: e.type === "todo" ? tags : {}, + })); + }; + + const setStatuses = (statuses: any[]) => { + updateEntity(entityId, (e) => { + if (e.type === "todo") { + return { + ...e, + config: { + ...e.config, + statuses, + }, + }; + } + return e; + }); }; return ( ); - case "doc": + } + + case "doc": { + const setDoc = (doc: OutputData) => { + updateEntity(entityId, (e) => ({ + ...e, + content: doc, + })); + }; + return ( -
- +
+

{entity.name}

+
); + } + + default: + return null; } } diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 0993702..0b77f91 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -1,3 +1,7 @@ +"use client"; + +import * as React from "react"; + import { Sidebar, SidebarContent, @@ -8,74 +12,401 @@ import { SidebarProvider, useSidebar, } from "@/components/ui/sidebar"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + import EntitySwitch from "./Entity"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; + import { ChevronDown } from "lucide-react"; -import { Workspace } from "@/hooks/workspace"; +import { Route, SetState, Workspace } from "@/lib/types"; +import { + listWorkspaces, + saveWorkspaceToLocalStorage, + createDefaultWorkspace, +} from "@/lib/workspace"; +import { openFileDialog } from "@/lib/import"; + +// Simple ID generator +const id = (prefix = "t") => + `${prefix}_${Math.random().toString(36).slice(2, 9)}`; + +type AppSidebarProps = { + children: React.ReactNode; + workspace: Workspace; + setRoute: SetState; + /** optional setter for the current workspace, used for rename */ + setWorkspace: React.Dispatch>; + addEntity: ( + type: "todo" | "doc", + parentGroupId: string | null, + name?: string, + ) => string | undefined; + addGroup: (name: string | undefined, parentGroupId: string | null) => string; + renameEntity: (entityId: string, newName: string) => void; + deleteEntity: (entityId: string) => void; + removeNode: (id: string) => void; + renameGroup: (id: string, name: string) => void; +}; export default function AppSidebar({ children, workspace, -}: Readonly<{ - children: React.ReactNode; - workspace: Workspace; -}>) { + setRoute, + setWorkspace, + addEntity, + addGroup, + renameEntity, + deleteEntity, + removeNode, + renameGroup, +}: AppSidebarProps) { + // actions are passed from parent `useWorkspace` instance + + const [availableWorkspaces, setAvailableWorkspaces] = React.useState< + Workspace[] + >([]); + + const [isRenameOpen, setIsRenameOpen] = React.useState(false); + const [proposedName, setProposedName] = React.useState(workspace.name); + const [isWorkspaceCreateOpen, setIsWorkspaceCreateOpen] = + React.useState(false); + const [newWorkspaceName, setNewWorkspaceName] = React.useState(""); + const [isCreateOpen, setIsCreateOpen] = React.useState(false); + const [createMode, setCreateMode] = React.useState<"list" | "doc" | "group">( + "list", + ); + const [createValue, setCreateValue] = React.useState(""); + + React.useEffect(() => { + setProposedName(workspace.name); + }, [workspace.name]); + + // reload list when component mounts or whenever the current workspace changes + React.useEffect(() => { + setAvailableWorkspaces(listWorkspaces()); + }, [workspace]); + + const switchWorkspace = (id: string) => { + setRoute({ workspace: id }); + }; + + const importWorkspace = async () => { + try { + const { content } = await openFileDialog(".json"); + const parsed = JSON.parse(content); + if (parsed && typeof parsed.id === "string") { + saveWorkspaceToLocalStorage(parsed); + setAvailableWorkspaces(listWorkspaces()); + switchWorkspace(parsed.id); + } else { + throw new Error("invalid shape"); + } + } catch (err) { + console.error("Failed to import workspace:", err); + window.alert("Import failed: invalid file"); + } + }; + + const exportWorkspace = () => { + try { + const text = JSON.stringify(workspace, null, 2); + const blob = new Blob([text], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `workspace-${workspace.name}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error("Export failed", err); + window.alert("Failed to download workspace"); + } + }; + + const renameWorkspace = (name: string) => { + if (!name || name === workspace.name) return; + + const updated: Workspace = { + ...workspace, + name, + updatedAt: Date.now(), + }; + saveWorkspaceToLocalStorage(updated); + setAvailableWorkspaces(listWorkspaces()); + if (setWorkspace) { + setWorkspace(updated); + } + }; + return ( + {/* Header */} - + + + - Acme Inc - Acme Corp. + {/* list all known workspaces */} + {[ + availableWorkspaces.find((w) => w.id === workspace.id), + ...availableWorkspaces.filter((w) => w.id !== workspace.id), + ].map((w) => ( + w && switchWorkspace(w?.id)} + > + {w?.name} + {w?.id === workspace.id ? " (current)" : ""} + + ))} + + { + setCreateMode("list"); + setCreateValue("New list"); + setIsCreateOpen(true); + }} + > + New list (root) + + { + setCreateMode("doc"); + setCreateValue("New doc"); + setIsCreateOpen(true); + }} + > + New doc (root) + + { + setCreateMode("group"); + setCreateValue("New group"); + setIsCreateOpen(true); + }} + > + New group (root) + + + setIsRenameOpen(true)}> + Rename Workspace + + + Import Workspace + + + Export Workspace + + { + setNewWorkspaceName("New workspace"); + setIsWorkspaceCreateOpen(true); + }} + > + Create workspace + + { + localStorage.removeItem(workspace.id); + }} + variant="destructive" + > + Delete workspace + + + {/* Sidebar Content */} - {workspace.entityLayout.map((entity, i) => ( - + {workspace.layout.map((node) => ( + ))} + -
{children}
+ +
{children}
+ + {/* rename dialog */} + + + + Rename workspace + + Provide a new name for the workspace. + + + setProposedName(e.target.value)} + className="w-full" + /> + + + + + + + {/* create workspace dialog */} + + + + Create workspace + + Provide a name for the workspace. + + + setNewWorkspaceName(e.target.value)} + className="w-full" + /> + + + + + + + {/* create dialog */} + + + + + {createMode === "group" + ? "Create group" + : createMode === "doc" + ? "Create document" + : "Create list"} + + + {createMode === "group" && "Provide a name for the new group."} + {createMode === "doc" && "Provide a name for the new document."} + {createMode === "list" && "Provide a name for the new list."} + + + setCreateValue(e.target.value)} + className="w-full" + /> + + + + + +
); } -export function SidebarTrigger({ - className, - ...props -}: React.ComponentProps<"div">) { +export function SidebarTrigger(props: React.ComponentProps<"div">) { const { toggleSidebar } = useSidebar(); return (
{ - toggleSidebar(); - }} + onClick={toggleSidebar} {...props} /> ); diff --git a/src/components/sidebar/Entity.tsx b/src/components/sidebar/Entity.tsx index 0ac8a64..bf9310a 100644 --- a/src/components/sidebar/Entity.tsx +++ b/src/components/sidebar/Entity.tsx @@ -1,4 +1,8 @@ -import { CollapsibleTrigger } from "@radix-ui/react-collapsible"; +"use client"; + +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; + import { SidebarGroup, SidebarGroupContent, @@ -7,63 +11,254 @@ import { SidebarMenuButton, SidebarMenuItem, } from "../ui/sidebar"; -import { Collapsible, CollapsibleContent } from "../ui/collapsible"; -import { ChevronDown } from "lucide-react"; -import { Workspace } from "@/hooks/workspace"; -export type SidebarEntity = - | { - kind: "group"; - name: string; - children: SidebarEntity[]; - } - | { - kind?: "item"; - id: string; - }; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "../ui/collapsible"; -export default function EntitySwitch({ - entity, - workspace, -}: { - entity: SidebarEntity; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "../ui/dialog"; + +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; + +import { Route, Workspace, LayoutNode } from "@/lib/types"; + +type DialogMode = "rename" | "add-item" | "add-group"; + +type Props = { + node: LayoutNode; workspace: Workspace; -}) { - if (entity.kind === "group") { - return ( - - - - - - {entity.name} - - - - - - - - {entity.children?.map((entity, i) => ( - - ))} - - - - - + setRoute: (route: Route) => void; + addEntity: ( + type: "todo" | "doc", + parentGroupId: string | null, + name?: string, + ) => string | undefined; + addGroup: (name: string, parentGroupId: string | null) => string; + renameEntity: (entityId: string, newName: string) => void; + deleteEntity: (entityId: string) => void; + removeNode: (id: string) => void; + renameGroup: (id: string, name: string) => void; +}; + +export default function EntitySwitch(props: Props) { + const { + node, + workspace, + setRoute, + addEntity, + addGroup, + renameEntity, + deleteEntity, + removeNode, + renameGroup, + } = props; + + const [dialogValue, setDialogValue] = useState(""); + const [dialogMode, setDialogMode] = useState(); + + const parentId = node.type === "group" ? node.id : null; + + const openDialog = (mode: DialogMode, initial = "") => { + setDialogMode(mode); + setDialogValue( + initial || + (mode === "add-item" + ? "New list" + : mode === "add-group" + ? "New group" + : ""), ); - } else { + }; + + const closeDialog = () => setDialogMode(undefined); + + const handleSubmit = () => { + const value = dialogValue.trim(); + if (!value) return; + + switch (dialogMode) { + case "rename": + if (node.type === "group") renameGroup(node.id, value); + else if (node.type === "item") renameEntity(node.entityId, value); + break; + + case "add-item": + addEntity("todo", parentId, value); + break; + + case "add-group": + addGroup(value, parentId); + break; + } + + closeDialog(); + }; + + const confirmDelete = (message: string, action: () => void) => { + if (window.confirm(message)) action(); + }; + + const dialogTitle = dialogMode?.startsWith("rename") + ? "Rename" + : dialogMode === "add-item" + ? "Add new list" + : "Add new group"; + + const dialog = ( + !o && closeDialog()}> + + + {dialogTitle} + + {(dialogMode === "add-item" || dialogMode === "add-group") && ( + Enter a name below. + )} + + + setDialogValue(e.target.value)} + /> + + + + + + + + ); + + /* ================= GROUP ================= */ + + if (node.type === "group") { return ( - - { - workspace.setRoute(entity.id); - }} - > - {workspace.entities[entity.id].name} - - + <> + + + + + + + + {node.name} + + + + + + + openDialog("add-item")}> + Add list + + + openDialog("add-group")}> + Add group + + + + + openDialog("rename", node.name)} + > + Rename + + + + confirmDelete("Delete this group and its children?", () => + removeNode(node.id), + ) + } + > + Delete + + + + + + + + + {node.children.map((child) => ( + + ))} + + + + + + {dialog} + ); } + + /* ================= ITEM ================= */ + + const entity = workspace.entities[node.entityId]; + if (!entity) return null; + + return ( + <> + + + + + setRoute({ + workspace: workspace.id, + entity: node.entityId, + }) + } + > + {entity.name} + + + + + openDialog("rename", entity.name)}> + Rename + + + + confirmDelete("Delete this list?", () => + deleteEntity(node.entityId), + ) + } + > + Delete + + + + + {dialog} + + ); } diff --git a/src/components/ui/context-menu.tsx b/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..183bdc3 --- /dev/null +++ b/src/components/ui/context-menu.tsx @@ -0,0 +1,252 @@ +"use client"; + +import * as React from "react"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { ContextMenu as ContextMenuPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/src/components/view/DocView.tsx b/src/components/view/DocView.tsx index 6528f63..2e33bcc 100644 --- a/src/components/view/DocView.tsx +++ b/src/components/view/DocView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import Header from "@editorjs/header"; import List from "@editorjs/list"; @@ -11,6 +11,21 @@ import Delimiter from "@editorjs/delimiter"; import Warning from "@editorjs/warning"; import InlineCode from "@editorjs/inline-code"; import { OutputData } from "@editorjs/editorjs"; +import { + downloadFile, + exportDocAsJson, + exportDocAsMarkdown, + exportDocAsHtml, +} from "@/lib/export"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { FileDown } from "lucide-react"; function toMarkdown(data: OutputData): string { return data.blocks @@ -55,11 +70,48 @@ function toMarkdown(data: OutputData): string { export default function DocView({ doc, setDoc, + docName = "Document", }: { doc: OutputData; setDoc: (data: OutputData) => void; + docName?: string; }) { const holderRef = useRef(null); + const [isExporting, setIsExporting] = useState(false); + + const handleExport = (format: "json" | "markdown" | "html") => { + const docEntity = { + id: "", + type: "doc" as const, + name: docName, + content: doc, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + let content = ""; + let filename = `${docName.replace(/\s+/g, "_")}_${Date.now()}`; + + switch (format) { + case "json": + content = exportDocAsJson(docEntity); + filename += ".json"; + break; + case "markdown": + content = exportDocAsMarkdown(docEntity); + filename += ".md"; + break; + case "html": + content = exportDocAsHtml(docEntity); + filename += ".html"; + break; + } + + downloadFile( + content, + filename, + format === "html" ? "text/html" : "text/plain", + ); + }; useEffect(() => { let editor: any; @@ -118,6 +170,10 @@ export default function DocView({ }, data: doc, async onChange() { + if (!editor || !editor.save) { + console.warn("unable to save, editor.save is null"); + return; + } const savedData = await editor.save(); console.log("Document data:", toMarkdown(savedData)); setDoc(savedData); @@ -131,5 +187,31 @@ export default function DocView({ }; }, []); - return
; + return ( +
+
+ + + + + + handleExport("markdown")}> + Export as Markdown + + handleExport("html")}> + Export as HTML + + + handleExport("json")}> + Export as JSON + + + +
+
+
+ ); } diff --git a/src/components/view/TaskView.tsx b/src/components/view/TaskView.tsx index e3c334e..adffc7a 100644 --- a/src/components/view/TaskView.tsx +++ b/src/components/view/TaskView.tsx @@ -1,45 +1,161 @@ "use client"; -import { SetState, Task } from "@/lib/types"; +import { Tag, TagId, Task, TaskId, Status } from "@/lib/types"; import { Color } from "@/lib/ui/color"; import BoardView from "./board/BoardView"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { TableView } from "./table/TableView"; +import EditView from "./edit/EditView"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from "@/components/ui/dropdown-menu"; +import { FileDown } from "lucide-react"; +import { + downloadFile, + exportTodoAsJson, + exportTodoAsCsv, + exportTodoAsMarkdown, +} from "@/lib/export"; +import { useEffect, useState } from "react"; export type TaskViewProps = { - tags: Record; - tasks: Task[]; - setTasks: SetState; - createTask: (status: string) => void; + tags: Record; + + tasks: Record; + + statuses: Status[]; + + /** Update entire tasks record */ + setTasks: (tasks: Record) => void; + + /** Update tags */ + setTags: (tags: Record) => void; + + /** Update statuses */ + setStatuses: (statuses: Status[]) => void; + + /** Create a new task with a status */ + createTask: (statusId: string) => void; + + /** Entity name for export */ + entityName?: string; }; export default function TaskView({ tags, tasks, + statuses, setTasks, + setTags, + setStatuses, createTask, + entityName = "Tasks", }: TaskViewProps) { + const [isExporting, setIsExporting] = useState(false); + + const handleExport = (format: "json" | "csv" | "markdown") => { + const todoEntity = { + id: "", + type: "todo" as const, + name: entityName, + config: { statuses }, + tags, + tasks, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + let content = ""; + let filename = `${entityName.replace(/\s+/g, "_")}_${Date.now()}`; + + switch (format) { + case "json": + content = exportTodoAsJson(todoEntity); + filename += ".json"; + break; + case "csv": + content = exportTodoAsCsv(todoEntity); + filename += ".csv"; + break; + case "markdown": + content = exportTodoAsMarkdown(todoEntity); + filename += ".md"; + break; + } + + downloadFile(content, filename, "text/plain"); + }; return ( -
- - - Board - Table - - +
+
+ +
+ + Board + Table + Edit + + + + + + + + handleExport("csv")}> + Export as CSV + + handleExport("markdown")}> + Export as Markdown + + + handleExport("json")}> + Export as JSON + + + +
+ + - + + + + + +
diff --git a/src/components/view/board/BoardView.tsx b/src/components/view/board/BoardView.tsx index 3418f57..8cef5de 100644 --- a/src/components/view/board/BoardView.tsx +++ b/src/components/view/board/BoardView.tsx @@ -3,101 +3,333 @@ import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; -import { PlusIcon } from "lucide-react"; +import { PlusIcon, Trash2 } from "lucide-react"; import BoardTask from "./Task"; import { TaskViewProps } from "../TaskView"; - -const statusLabels: Record = { - todo: "To do", - inprogress: "In progress", - done: "Done", -}; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { useState, useEffect, useRef, useCallback } from "react"; export default function BoardView({ tags, tasks, setTasks, createTask, + statuses, }: TaskViewProps) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [isDraggingSelection, setIsDraggingSelection] = useState(false); + const [selectionBox, setSelectionBox] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + } | null>(null); + const boardRef = useRef(null); + const taskCardRefs = useRef>({}); + const mouseDownPos = useRef<{ x: number; y: number } | null>(null); + function onDragEnd(result: any) { const { draggableId, source, destination } = result; if (!destination) return; - setTasks((tasks) => { - const taskIndex = tasks.findIndex((task) => task.id === draggableId); - const task = tasks[taskIndex]; - tasks.splice(taskIndex, 1); + const task = Object.values(tasks).find((t) => t.id === draggableId); + if (!task) return; - const destinationGroupStartIndex = tasks.findIndex( - (t) => t.status === destination.droppableId - ); - - const insertIndex = - destinationGroupStartIndex === -1 - ? tasks.length - : destinationGroupStartIndex + destination.index; - - tasks.splice(insertIndex, 0, { + setTasks({ + ...tasks, + [task.id]: { ...task, - status: destination.droppableId, - }); - - return tasks; + statusId: destination.droppableId, + updatedAt: Date.now(), + }, }); } + const deleteSelected = useCallback(() => { + if (selectedIds.size === 0) return; + const updated = { ...tasks }; + selectedIds.forEach((id) => delete updated[id]); + setTasks(updated); + setSelectedIds(new Set()); + }, [selectedIds, tasks, setTasks]); + + // Ctrl+A to select all, Backspace/Delete to delete selected + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "a" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + setSelectedIds(new Set(Object.keys(tasks))); + } + if (e.key === "Backspace" || e.key === "Delete") { + // Don't fire if user is typing in an input/textarea + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) + return; + deleteSelected(); + } + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [tasks, deleteSelected]); + + // Mouse drag selection + useEffect(() => { + function handleMouseDown(e: MouseEvent) { + // Only start selection if clicking on the board background (not a card) + const target = e.target as HTMLElement; + const isCard = target.closest("[data-task-card]"); + if (isCard) return; + if (e.button !== 0) return; + + mouseDownPos.current = { x: e.clientX, y: e.clientY }; + } + + function handleMouseMove(e: MouseEvent) { + if (!mouseDownPos.current) return; + const dx = Math.abs(e.clientX - mouseDownPos.current.x); + const dy = Math.abs(e.clientY - mouseDownPos.current.y); + if (dx > 4 || dy > 4) { + setIsDraggingSelection(true); + setSelectionBox({ + startX: mouseDownPos.current.x, + startY: mouseDownPos.current.y, + endX: e.clientX, + endY: e.clientY, + }); + } + } + + function handleMouseUp(e: MouseEvent) { + if (isDraggingSelection && selectionBox) { + // Find all task cards that intersect with the selection box + const minX = Math.min(selectionBox.startX, selectionBox.endX); + const maxX = Math.max(selectionBox.startX, selectionBox.endX); + const minY = Math.min(selectionBox.startY, selectionBox.endY); + const maxY = Math.max(selectionBox.startY, selectionBox.endY); + + const newSelected = new Set(); + Object.entries(taskCardRefs.current).forEach(([id, el]) => { + if (!el) return; + const rect = el.getBoundingClientRect(); + if ( + rect.left < maxX && + rect.right > minX && + rect.top < maxY && + rect.bottom > minY + ) { + newSelected.add(id); + } + }); + + if (e.shiftKey) { + setSelectedIds((prev) => new Set([...prev, ...newSelected])); + } else { + setSelectedIds(newSelected); + } + } else if (!isDraggingSelection) { + // Click on empty area deselects + const target = e.target as HTMLElement; + const isCard = target.closest("[data-task-card]"); + if (!isCard) { + setSelectedIds(new Set()); + } + } + + mouseDownPos.current = null; + setIsDraggingSelection(false); + setSelectionBox(null); + } + + window.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDraggingSelection, selectionBox]); + + function handleTaskClick(e: React.MouseEvent, taskId: string) { + if (e.ctrlKey || e.metaKey) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(taskId)) next.delete(taskId); + else next.add(taskId); + return next; + }); + } else if (e.shiftKey) { + setSelectedIds((prev) => new Set([...prev, taskId])); + } + // Normal click handled by SheetTrigger (task edit) if not multi-select + } + + const tasksArray = Object.values(tasks); + const tasksByStatus = statuses.map((status) => ({ + status, + tasks: tasksArray + .filter((t) => t.statusId === status.id) + .sort((a, b) => a.createdAt - b.createdAt), + })); + + const selRect = selectionBox + ? { + left: Math.min(selectionBox.startX, selectionBox.endX), + top: Math.min(selectionBox.startY, selectionBox.endY), + width: Math.abs(selectionBox.endX - selectionBox.startX), + height: Math.abs(selectionBox.endY - selectionBox.startY), + } + : null; + return ( -
+
+ {/* Selection box overlay */} + {isDraggingSelection && selRect && ( +
+ )} + + {/* Delete bar when items selected */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} task{selectedIds.size !== 1 ? "s" : ""} selected + + + — press Backspace to delete + + + +
+ )} + -
- {Object.keys(statusLabels).map((status) => ( - +
+ {tasksByStatus.map(({ status, tasks: statusTasks }) => ( + {(provided) => ( - {statusLabels[status]} + {status.name} - +
- {tasks - .filter((task) => task.status === status) - .map((task, index) => ( - - {(provided) => ( - - setTasks((cols) => - cols.map((c) => (c.id === t.id ? t : c)) - ) - } - provided={provided} - task={task} - tags={tags} - /> - )} - - ))} + {statusTasks.map((task, index) => ( + + {(provided) => ( + + +
{ + provided.innerRef(el); + taskCardRefs.current[task.id] = el; + }} + {...provided.draggableProps} + {...provided.dragHandleProps} + data-task-card={task.id} + onClick={(e) => handleTaskClick(e, task.id)} + > + + setTasks({ + ...tasks, + [t.id]: t, + }) + } + provided={provided} + task={task} + tags={tags} + isSelected={selectedIds.has(task.id)} + onDelete={(id) => { + const updated = { ...tasks }; + delete updated[id]; + setTasks(updated); + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }} + /> +
+
+ + + { + const updated = { ...tasks }; + delete updated[task.id]; + setTasks(updated); + setSelectedIds((prev) => { + const next = new Set(prev); + next.delete(task.id); + return next; + }); + }} + > + + Delete + + +
+ )} +
+ ))} {provided.placeholder} - {tasks.filter((task) => task.status === status).length === - 0 && ( + {statusTasks.length === 0 && (

No tasks

diff --git a/src/components/view/board/Task.tsx b/src/components/view/board/Task.tsx index 6a5d96a..d662510 100644 --- a/src/components/view/board/Task.tsx +++ b/src/components/view/board/Task.tsx @@ -7,60 +7,88 @@ import { CardTitle, } from "@/components/ui/card"; import { SheetTrigger } from "@/components/ui/sheet"; -import { Task } from "@/lib/types"; +import { Task, Tag, TagId } from "@/lib/types"; import { Color, getColor } from "@/lib/ui/color"; import { DraggableProvided } from "@hello-pangea/dnd"; import TaskEdit from "../edit/TaskEdit"; import { Button } from "@/components/ui/button"; -import { NotepadText } from "lucide-react"; +import { NotepadText, Trash2 } from "lucide-react"; import DocDialog from "../edit/DocDialog"; +import { cn } from "@/lib/utils"; export default function BoardTask({ task, provided, setTask, tags, + onDelete, + isSelected, }: { task: Task; setTask: (t: Task) => void; provided: DraggableProvided; - tags: Record; + tags: Record; + onDelete?: (taskId: string) => void; + isSelected?: boolean; }) { return ( - - {task.title} - - - + + {task.title} +
+ + + + {onDelete && ( + + )} +
- {task.description} + {task.description && ( + + {task.description} + + )}
- - {task.tags?.map((tag, i) => ( + + {task.tagIds?.map((tagId) => ( - {tag} + {tags[tagId]?.name || tagId} ))} @@ -68,4 +96,4 @@ export default function BoardTask({
); -} +} \ No newline at end of file diff --git a/src/components/view/edit/DocDialog.tsx b/src/components/view/edit/DocDialog.tsx index e4cb881..47e6f5e 100644 --- a/src/components/view/edit/DocDialog.tsx +++ b/src/components/view/edit/DocDialog.tsx @@ -10,8 +10,8 @@ import { import { ScrollArea } from "@/components/ui/scroll-area"; import DocView from "../DocView"; import { Badge } from "@/components/ui/badge"; -import { Task } from "@/lib/types"; -import { Color, getColor } from "@/lib/ui/color"; +import { Task, Tag, TagId } from "@/lib/types"; +import { getColor } from "@/lib/ui/color"; import { useState } from "react"; import { OutputData } from "@editorjs/editorjs"; @@ -23,10 +23,19 @@ export default function DocDialog({ }: { task: Task; setTask: (t: Task) => void; - tags: Record; + tags: Record; children: React.ReactNode; }) { - const [doc, setDoc] = useState({ blocks: [] }); + // if there is an existing document on the task use it, otherwise start with a blank one + const doc = task.doc || { version: "2.28.0", time: Date.now(), blocks: [] }; + + const handleDocChange = (newDoc: OutputData) => { + setTask({ + ...task, + doc: newDoc, + updatedAt: Date.now(), + }); + }; return ( @@ -41,20 +50,24 @@ export default function DocDialog({ {task.description}
- {task.tags?.map((tag, i) => ( + {task.tagIds?.map((tagId) => ( - {tag} + {tags[tagId]?.name || tagId} ))}
- +
diff --git a/src/components/view/edit/EditView.tsx b/src/components/view/edit/EditView.tsx new file mode 100644 index 0000000..a9b8b4a --- /dev/null +++ b/src/components/view/edit/EditView.tsx @@ -0,0 +1,318 @@ +"use client"; + +import { Tag, TagId, Status } from "@/lib/types"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { X, Plus, GripVertical } from "lucide-react"; + +export type EditViewProps = { + tags: Record; + statuses: Status[]; + setTags: (tags: Record) => void; + setStatuses: (statuses: Status[]) => void; +}; + +const COLOR_OPTIONS = [ + "red", + "orange", + "yellow", + "green", + "blue", + "indigo", + "purple", + "pink", + "gray", +]; + +export default function EditView({ + tags, + statuses, + setTags, + setStatuses, +}: EditViewProps) { + const [newTagName, setNewTagName] = useState(""); + const [newTagColor, setNewTagColor] = useState("blue"); + const [newStatusName, setNewStatusName] = useState(""); + const [newStatusColor, setNewStatusColor] = useState("blue"); + + const handleAddTag = () => { + if (newTagName.trim()) { + const newTagId = `tag-${Date.now()}`; + setTags({ + ...tags, + [newTagId]: { + id: newTagId, + name: newTagName, + color: newTagColor, + }, + }); + setNewTagName(""); + setNewTagColor("blue"); + } + }; + + const handleDeleteTag = (tagId: TagId) => { + const newTags = { ...tags }; + delete newTags[tagId]; + setTags(newTags); + }; + + const handleAddStatus = () => { + if (newStatusName.trim()) { + const newStatus: Status = { + id: `status-${Date.now()}`, + name: newStatusName, + color: newStatusColor, + order: statuses.length, + }; + setStatuses([...statuses, newStatus]); + setNewStatusName(""); + setNewStatusColor("blue"); + } + }; + + const handleDeleteStatus = (statusId: string) => { + setStatuses(statuses.filter((s) => s.id !== statusId)); + }; + + const handleUpdateStatusName = (statusId: string, newName: string) => { + setStatuses( + statuses.map((s) => (s.id === statusId ? { ...s, name: newName } : s)), + ); + }; + + const handleUpdateStatusColor = (statusId: string, newColor: string) => { + setStatuses( + statuses.map((s) => (s.id === statusId ? { ...s, color: newColor } : s)), + ); + }; + + const handleUpdateTagName = (tagId: TagId, newName: string) => { + setTags({ + ...tags, + [tagId]: { ...tags[tagId], name: newName }, + }); + }; + + const handleUpdateTagColor = (tagId: TagId, newColor: string) => { + setTags({ + ...tags, + [tagId]: { ...tags[tagId], color: newColor }, + }); + }; + + return ( +
+ {/* Tags Section */} +
+

Tags

+ + {Object.entries(tags).map(([tagId, tag]) => ( +
+ + +
+ + + + handleUpdateTagName(tagId, e.target.value)} + placeholder="Tag name" + className="flex-1" + /> + + +
+ ))} + + + +
+ + +
+ + + + setNewTagName(e.target.value)} + placeholder="New tag name" + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleAddTag(); + } + }} + /> + + +
+ +
+ + {/* Statuses Section */} +
+

Statuses

+ + {statuses.map((status) => ( +
+ + + + +
+ + + + + handleUpdateStatusName(status.id, e.target.value) + } + placeholder="Status name" + className="flex-1" + /> + + +
+ ))} + + + +
+ + +
+ + + + setNewStatusName(e.target.value)} + placeholder="New status name" + className="flex-1" + onKeyDown={(e) => { + if (e.key === "Enter") { + handleAddStatus(); + } + }} + /> + + +
+ +
+
+ ); +} diff --git a/src/components/view/edit/TaskEdit.tsx b/src/components/view/edit/TaskEdit.tsx index 0bb65ff..45abb0b 100644 --- a/src/components/view/edit/TaskEdit.tsx +++ b/src/components/view/edit/TaskEdit.tsx @@ -8,23 +8,9 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet"; -import { Task } from "@/lib/types"; +import { Task, Tag, TagId } from "@/lib/types"; import { Color, getColor } from "@/lib/ui/color"; -import { DraggableProvided } from "@hello-pangea/dnd"; import { Button } from "../../ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTrigger, -} from "../../ui/dialog"; -import DocView from "../DocView"; -import { useState } from "react"; -import { OutputData } from "@editorjs/editorjs"; -import { DialogTitle } from "@radix-ui/react-dialog"; -import { ScrollArea } from "../../ui/scroll-area"; -import { Badge } from "../../ui/badge"; import DocDialog from "./DocDialog"; export default function TaskEdit({ @@ -35,7 +21,7 @@ export default function TaskEdit({ }: { task: Task; setTask: (t: Task) => void; - tags: Record; + tags: Record; children: React.ReactNode; }) { return ( @@ -52,32 +38,48 @@ export default function TaskEdit({
setTask({ ...task, title: e.target.value })} + value={task.title} + onChange={(e) => + setTask({ + ...task, + title: e.target.value, + updatedAt: Date.now(), + }) + } />
- setTask({ ...task, description: e.target.value }) + setTask({ + ...task, + description: e.target.value, + updatedAt: Date.now(), + }) } />
- + ({ label: tag.name, value: id, style: { - iconColor: getColor(tag.color).color, - badgeColor: getColor(tag.color).backgroundColor, + iconColor: getColor(tag.color || "#808080").color, + badgeColor: getColor(tag.color || "#808080").backgroundColor, }, }))} - onValueChange={(tags) => setTask({ ...task, tags })} - defaultValue={task.tags} + onValueChange={(tagIds) => + setTask({ + ...task, + tagIds: tagIds as TagId[], + updatedAt: Date.now(), + }) + } + defaultValue={task.tagIds} />
diff --git a/src/components/view/table/TableView.tsx b/src/components/view/table/TableView.tsx index 282e88a..248030d 100644 --- a/src/components/view/table/TableView.tsx +++ b/src/components/view/table/TableView.tsx @@ -15,6 +15,10 @@ import { getColor } from "@/lib/ui/color"; import TableTask from "./Task"; export function TableView({ tasks, setTasks, tags }: TaskViewProps) { + const tasksArray = Object.values(tasks).sort( + (a, b) => b.createdAt - a.createdAt, + ); + return ( @@ -26,7 +30,7 @@ export function TableView({ tasks, setTasks, tags }: TaskViewProps) { - {tasks.length === 0 ? ( + {tasksArray.length === 0 ? ( ) : ( - tasks.map((task) => ( + tasksArray.map((task) => ( - setTasks((tasks) => tasks.map((c) => (c.id === t.id ? t : c))) + setTasks({ + ...tasks, + [t.id]: t, + }) } tags={tags} /> diff --git a/src/components/view/table/Task.tsx b/src/components/view/table/Task.tsx index 089fd18..3682d62 100644 --- a/src/components/view/table/Task.tsx +++ b/src/components/view/table/Task.tsx @@ -1,50 +1,62 @@ import { Badge } from "@/components/ui/badge"; import { SheetTrigger } from "@/components/ui/sheet"; import { TableCell, TableRow } from "@/components/ui/table"; -import { Task } from "@/lib/types"; +import { Task, Tag, TagId } from "@/lib/types"; import { Color, getColor } from "@/lib/ui/color"; import TaskEdit from "../edit/TaskEdit"; +import { Button } from "@/components/ui/button"; +import { Trash2 } from "lucide-react"; export default function TableTask({ task, setTask, tags, + onDelete, }: { task: Task; setTask: (t: Task) => void; - tags: Record; + tags: Record; + onDelete?: (taskId: string) => void; }) { return ( - + {task.title} {task.description || "-"} - - {task.status} - + {task.statusId} - - {task.tags?.map((tag, i) => ( - +
+ {task.tagIds?.map((tagId) => ( + + {tags[tagId]?.name || tagId} + + ))} +
+ {onDelete && ( + + )}
diff --git a/src/hooks/workspace.ts b/src/hooks/workspace.ts index 112a109..2551d45 100644 --- a/src/hooks/workspace.ts +++ b/src/hooks/workspace.ts @@ -1,98 +1,159 @@ -import { SidebarEntity } from "@/components/sidebar/Entity"; -import { SetState, Task } from "@/lib/types"; -import { OutputData } from "@editorjs/editorjs"; +import { Entity, EntityId, LayoutNode, Workspace } from "@/lib/types"; +import { + loadWorkspaceFromLocalStorage, + saveWorkspaceToLocalStorage, + addLayoutItem, + addLayoutGroup, + removeLayoutNode, + renameLayoutGroup, +} from "@/lib/workspace"; import { useEffect, useState } from "react"; -export type Entity = - | { - name: string; - kind: "todo"; - tasks: Task[]; - setTasks: SetState; +export function useWorkspace() { + const [workspace, setWorkspace] = useState(); + + const updateEntity = (entityId: EntityId, updater: (e: Entity) => Entity) => { + setWorkspace((prev) => { + if (!prev) return prev; + const current = prev.entities[entityId]; + if (!current) return prev; + return { + ...prev, + entities: { + ...prev.entities, + [entityId]: updater(current), + }, + updatedAt: Date.now(), + }; + }); + }; + + const renameEntity = (entityId: EntityId, newName: string) => { + updateEntity(entityId, (e) => ({ ...e, name: newName })); + }; + + const deleteEntity = (entityId: EntityId) => { + setWorkspace((prev) => { + if (!prev) return prev; + const { [entityId]: _, ...rest } = prev.entities; + // also remove from layout + const { layout: newLayout } = removeLayoutNode(prev.layout, entityId); + return { + ...prev, + entities: rest, + layout: newLayout, + updatedAt: Date.now(), + }; + }); + }; + + const addEntity = ( + type: "todo" | "doc", + parentGroupId: string | null, + name?: string, + ) => { + const now = Date.now(); + const id = crypto.randomUUID(); + let entity: Entity; + if (type === "todo") { + entity = { + id, + type: "todo", + name: name || "New list", + config: { statuses: [] }, + tags: {}, + tasks: {}, + createdAt: now, + updatedAt: now, + } as any; + } else { + entity = { + id, + type: "doc", + name: name || "New doc", + content: { version: "2.28.0", time: now, blocks: [] }, + createdAt: now, + updatedAt: now, + } as any; } - | { - name: string; - kind: "doc"; - doc: OutputData; - setDoc: SetState; - }; + setWorkspace((prev) => { + if (!prev) return prev; + const newEntities = { ...prev.entities, [id]: entity }; + const newLayout = addLayoutItem(prev.layout, parentGroupId, { + type: "item", + entityId: id, + }); + return { + ...prev, + entities: newEntities, + layout: newLayout, + updatedAt: Date.now(), + }; + }); + return id; + }; -export interface Workspace { - name: string; - entityLayout: SidebarEntity[]; - entities: Record; + const addGroup = (name = "New group", parentGroupId: string | null) => { + const id = crypto.randomUUID(); + const group: LayoutNode = { type: "group", id, name, children: [] }; + setWorkspace((prev) => { + if (!prev) return prev; + const newLayout = addLayoutGroup(prev.layout, parentGroupId, group); + return { ...prev, layout: newLayout, updatedAt: Date.now() }; + }); + return id; + }; - route: string; - setRoute: SetState; -} + const removeNode = (id: string) => { + setWorkspace((prev) => { + if (!prev) return prev; + const { layout: newLayout, removedEntityIds } = removeLayoutNode( + prev.layout, + id, + ); + const newEntities = { ...prev.entities } as Record; + removedEntityIds.forEach((eid) => delete newEntities[eid]); + return { + ...prev, + entities: newEntities, + layout: newLayout, + updatedAt: Date.now(), + }; + }); + }; -export default function useWorkspace(): Workspace { - const [route, setRoute] = useState("alpha_tasks"); - const [tasks, setTasks] = useState([]); - const [doc, setDoc] = useState({ - blocks: [], - }); - const [doc2, setDoc2] = useState({ - blocks: [], - }); + const renameGroup = (id: string, newName: string) => { + setWorkspace((prev) => { + if (!prev) return prev; + const newLayout = renameLayoutGroup(prev.layout, id, newName); + return { ...prev, layout: newLayout, updatedAt: Date.now() }; + }); + }; + + const loadWorkspace = (workspaceId: string | undefined) => { + if (!workspaceId) { + return setWorkspace(undefined); + } + + const w = loadWorkspaceFromLocalStorage(workspaceId); + setWorkspace(w); + return w; + }; useEffect(() => { - setTasks([ - { id: "1", title: "Buy groceries", status: "todo" }, - { id: "2", title: "Plan sprint", status: "todo" }, - { - id: "3", - title: "Build App", - description: "A simple description", - tags: ["website", "api", "app"], - status: "inprogress", - }, - ]); - }, []); + if (workspace) saveWorkspaceToLocalStorage(workspace); + }, [workspace]); return { - name: "Personal Workspace", - entities: { - alpha_tasks: { - name: "Alpha tasks", - kind: "todo", - tasks, - setTasks, - }, - beta_tasks: { - name: "Beta tasks", - kind: "todo", - tasks, - setTasks, - }, - - alpha_doc: { - name: "Alpha docs", - kind: "doc", - doc: doc, - setDoc: setDoc, - }, - beta_doc: { - name: "Beta docs", - kind: "doc", - doc: doc2, - setDoc: setDoc2, - }, - }, - entityLayout: [ - { - kind: "group", - name: "Project alpha", - children: [{ id: "alpha_tasks" }, { id: "alpha_doc" }], - }, - { - kind: "group", - name: "Project beta", - children: [{ id: "beta_tasks" }, { id: "beta_doc" }], - }, - ], - - route, - setRoute, + workspace, + loadWorkspace, + updateEntity, + renameEntity, + deleteEntity, + addEntity, + addGroup, + removeNode, + renameGroup, + setWorkspace, }; } diff --git a/src/lib/export.ts b/src/lib/export.ts new file mode 100644 index 0000000..a2f0780 --- /dev/null +++ b/src/lib/export.ts @@ -0,0 +1,303 @@ +import { Workspace, DocEntity, TodoEntity, Entity } from "./types"; +import { OutputData } from "@editorjs/editorjs"; + +/** + * Convert EditorJS OutputData to Markdown + */ +export function docToMarkdown(content: OutputData): string { + return content.blocks + .map((block) => { + switch (block.type) { + case "header": + return `${"#".repeat((block.data as any).level)} ${(block.data as any).text}`; + + case "paragraph": + return `${(block.data as any).text}\n`; + + case "list": + if ((block.data as any).style === "ordered") { + return ((block.data as any).items as Array<{ content: string }>) + .map((item, i) => `${i + 1}. ${item.content}`) + .join("\n"); + } else { + return ((block.data as any).items as Array<{ content: string }>) + .map((item) => `- ${item.content}`) + .join("\n"); + } + + case "code": + return `\`\`\`\n${(block.data as any).code}\n\`\`\``; + + case "quote": + return `> ${(block.data as any).text}`; + + case "delimiter": + return `---`; + + case "image": + return `![${(block.data as any).caption || ""}](${(block.data as any).file.url})`; + + default: + return ""; + } + }) + .join("\n\n"); +} + +/** + * Convert EditorJS OutputData to HTML + */ +export function docToHtml(content: OutputData): string { + const htmlBlocks = content.blocks.map((block) => { + switch (block.type) { + case "header": + return `${(block.data as any).text}`; + + case "paragraph": + return `

${(block.data as any).text}

`; + + case "list": + const items = ((block.data as any).items as Array<{ content: string }>) + .map((item) => `
  • ${item.content}
  • `) + .join("\n"); + + if ((block.data as any).style === "ordered") { + return `
      \n${items}\n
    `; + } else { + return `
      \n${items}\n
    `; + } + + case "code": + return `
    ${(block.data as any).code}
    `; + + case "quote": + return `
    ${(block.data as any).text}
    `; + + case "delimiter": + return `
    `; + + case "image": + return `${(block.data as any).caption || `; + + default: + return ""; + } + }); + + return ` + + + + + + + +${htmlBlocks.join("\n")} + +`; +} + +/** + * Export a Doc entity as JSON + */ +export function exportDocAsJson(doc: DocEntity): string { + return JSON.stringify( + { + name: doc.name, + type: "doc", + content: doc.content, + createdAt: new Date(doc.createdAt).toISOString(), + updatedAt: new Date(doc.updatedAt).toISOString(), + }, + null, + 2, + ); +} + +/** + * Export a Doc entity as Markdown + */ +export function exportDocAsMarkdown(doc: DocEntity): string { + const header = `# ${doc.name}\n\nCreated: ${new Date(doc.createdAt).toISOString()}\nUpdated: ${new Date(doc.updatedAt).toISOString()}\n\n---\n\n`; + const content = docToMarkdown(doc.content); + return header + content; +} + +/** + * Export a Doc entity as HTML + */ +export function exportDocAsHtml(doc: DocEntity): string { + const html = docToHtml(doc.content); + const headerHtml = `

    ${doc.name}

    Created: ${new Date(doc.createdAt).toISOString()}\nUpdated: ${new Date(doc.updatedAt).toISOString()}


    `; + + // Insert header before the first body element + return html + .replace("", `${doc.name}\n`) + .replace("", `\n${headerHtml}\n`); +} + +/** + * Export a Todo entity as JSON + */ +export function exportTodoAsJson(todo: TodoEntity): string { + return JSON.stringify( + { + name: todo.name, + type: "todo", + config: todo.config, + tags: todo.tags, + tasks: todo.tasks, + createdAt: new Date(todo.createdAt).toISOString(), + updatedAt: new Date(todo.updatedAt).toISOString(), + }, + null, + 2, + ); +} + +/** + * Export a Todo entity as CSV + */ +export function exportTodoAsCsv(todo: TodoEntity): string { + const headers = [ + "Title", + "Description", + "Doc", + "Status", + "Tags", + "Created", + "Updated", + ]; + const rows = Object.values(todo.tasks).map((task) => [ + task.title, + task.description || "", + task.doc ? JSON.stringify(task.doc) : "", + todo.config.statuses.find((s) => s.id === task.statusId)?.name || "", + task.tagIds.map((id) => todo.tags[id]?.name || "").join(";"), + new Date(task.createdAt).toISOString(), + new Date(task.updatedAt).toISOString(), + ]); + + const csvContent = [ + headers.join(","), + ...rows.map((row) => + row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","), + ), + ].join("\n"); + + return csvContent; +} + +/** + * Export a Todo entity as Markdown + */ +export function exportTodoAsMarkdown(todo: TodoEntity): string { + let markdown = `# ${todo.name}\n\nCreated: ${new Date(todo.createdAt).toISOString()}\nUpdated: ${new Date(todo.updatedAt).toISOString()}\n\n`; + + // Group tasks by status + const tasksByStatus: Record = {}; + for (const status of todo.config.statuses) { + tasksByStatus[status.id] = {}; + } + + for (const [taskId, task] of Object.entries(todo.tasks)) { + const statusId = task.statusId; + if (!tasksByStatus[statusId]) { + tasksByStatus[statusId] = {}; + } + tasksByStatus[statusId][taskId] = task; + } + + // Add tasks by status + for (const status of todo.config.statuses) { + markdown += `## ${status.name}\n\n`; + const tasks = tasksByStatus[status.id]; + if (Object.keys(tasks).length === 0) { + markdown += "- (No tasks)\n\n"; + } else { + for (const task of Object.values(tasks)) { + const tags = task.tagIds + .map((id) => todo.tags[id]?.name || "") + .filter((n) => n) + .join(", "); + markdown += `- **${task.title}**${tags ? ` (${tags})` : ""}`; + if (task.description) { + markdown += `\n - ${task.description}`; + } + if (task.doc) { + markdown += `\n\n${docToMarkdown(task.doc)}`; + } + markdown += "\n"; + } + } + markdown += "\n"; + } + + return markdown; +} + +/** + * Export entire workspace as JSON + */ +export function exportWorkspaceAsJson(workspace: Workspace): string { + return JSON.stringify(workspace, null, 2); +} + +/** + * Trigger download of a file + */ +export function downloadFile( + content: string, + filename: string, + mimeType: string = "text/plain", +): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Export any entity based on its type + */ +export function exportEntity( + entity: Entity, + format: "json" | "markdown" | "html" | "csv", +): string { + if (entity.type === "doc") { + switch (format) { + case "json": + return exportDocAsJson(entity); + case "markdown": + return exportDocAsMarkdown(entity); + case "html": + return exportDocAsHtml(entity); + default: + return exportDocAsJson(entity); + } + } else if (entity.type === "todo") { + switch (format) { + case "json": + return exportTodoAsJson(entity); + case "markdown": + return exportTodoAsMarkdown(entity); + case "csv": + return exportTodoAsCsv(entity); + default: + return exportTodoAsJson(entity); + } + } + + return ""; +} diff --git a/src/lib/import.ts b/src/lib/import.ts new file mode 100644 index 0000000..6b58225 --- /dev/null +++ b/src/lib/import.ts @@ -0,0 +1,199 @@ +import { Workspace, DocEntity, TodoEntity } from "./types"; +import { OutputData } from "@editorjs/editorjs"; + +/** + * Parse JSON file content back to Workspace + */ +export function importWorkspaceFromJson(jsonContent: string): Workspace { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.id !== "string" || + typeof parsed.name !== "string" + ) { + throw new Error("Invalid workspace JSON structure"); + } + + return parsed as Workspace; + } catch (err) { + throw new Error(`Failed to parse workspace JSON: ${err}`); + } +} + +/** + * Parse JSON file content back to DocEntity + */ +export function importDocFromJson(jsonContent: string): DocEntity { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.name !== "string" || + parsed.type !== "doc" || + typeof parsed.content !== "object" + ) { + throw new Error("Invalid doc JSON structure"); + } + + return parsed as DocEntity; + } catch (err) { + throw new Error(`Failed to parse doc JSON: ${err}`); + } +} + +/** + * Parse JSON file content back to TodoEntity + */ +export function importTodoFromJson(jsonContent: string): TodoEntity { + try { + const parsed = JSON.parse(jsonContent); + + // Basic validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.name !== "string" || + parsed.type !== "todo" || + typeof parsed.config !== "object" + ) { + throw new Error("Invalid todo JSON structure"); + } + + return parsed as TodoEntity; + } catch (err) { + throw new Error(`Failed to parse todo JSON: ${err}`); + } +} + +/** + * Parse CSV file content to extract tasks + */ +export function importTodoFromCsv( + csvContent: string, + todoEntity: TodoEntity, +): { + tasks: Record; + errors: string[]; +} { + const lines = csvContent.trim().split("\n"); + const tasks: Record = {}; + const errors: string[] = []; + + if (lines.length < 2) { + errors.push("CSV file must have at least a header row"); + return { tasks, errors }; + } + + const headers = lines[0].split(",").map((h) => h.trim().toLowerCase()); + const titleIndex = headers.indexOf("title"); + const descriptionIndex = headers.indexOf("description"); + const statusIndex = headers.indexOf("status"); + const tagsIndex = headers.indexOf("tags"); + const docIndex = headers.indexOf("doc"); + + if (titleIndex === -1) { + errors.push("CSV must have a 'Title' column"); + return { tasks, errors }; + } + + for (let i = 1; i < lines.length; i++) { + try { + const line = lines[i]; + if (!line.trim()) continue; + + const values = line.split(",").map((v) => v.trim().replace(/^"|"$/g, "")); + + const title = values[titleIndex]; + if (!title) { + errors.push(`Row ${i + 1}: Missing title`); + continue; + } + + // Find status by name + let statusId = todoEntity.config.statuses[0].id; + if (statusIndex !== -1 && values[statusIndex]) { + const status = todoEntity.config.statuses.find( + (s) => s.name.toLowerCase() === values[statusIndex].toLowerCase(), + ); + if (status) { + statusId = status.id; + } + } + + // Parse tags + let tagIds: string[] = []; + if (tagsIndex !== -1 && values[tagsIndex]) { + const tagNames = values[tagsIndex].split(";").map((t) => t.trim()); + tagIds = tagNames + .map((name) => { + const tag = Object.entries(todoEntity.tags).find( + ([, t]) => t.name.toLowerCase() === name.toLowerCase(), + ); + return tag ? tag[0] : null; + }) + .filter((id) => id !== null) as string[]; + } + + const taskId = `task_${Math.random().toString(36).slice(2, 9)}`; + tasks[taskId] = { + id: taskId, + title, + description: descriptionIndex !== -1 ? values[descriptionIndex] : "", + doc: + docIndex !== -1 && values[docIndex] + ? JSON.parse(values[docIndex]) + : undefined, + statusId, + tagIds, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } catch (err) { + errors.push(`Row ${i + 1}: ${err}`); + } + } + + return { tasks, errors }; +} + +/** + * Open file picker and read file content + */ +export function openFileDialog( + accept: string = ".json", +): Promise<{ content: string; filename: string }> { + return new Promise((resolve, reject) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = accept; + + input.onchange = (e: any) => { + const file = e.target.files?.[0]; + if (!file) { + reject(new Error("No file selected")); + return; + } + + const reader = new FileReader(); + reader.onload = (event: any) => { + try { + const content = event.target?.result as string; + resolve({ content, filename: file.name }); + } catch (err) { + reject(err); + } + }; + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsText(file); + }; + + input.click(); + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 6d7c086..c1e0f3b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,11 +1,92 @@ -import { Color } from "./ui/color"; - -export type Task = { - id: string; - title: string; - status: string; - description?: string; - tags?: string[]; -}; +import { OutputData } from "@editorjs/editorjs"; export type SetState = React.Dispatch>; + +export type EntityId = string; +export type TagId = string; +export type TaskId = string; + +export interface Route { + workspace?: string; + entity?: EntityId; +} + +export interface Workspace { + id: string; + name: string; + + entities: Record; + layout: LayoutNode[]; + + createdAt: number; + updatedAt: number; +} + +export type LayoutNode = + | { + type: "group"; + id: string; + name: string; + children: LayoutNode[]; + } + | { + type: "item"; + entityId: EntityId; + }; + +export type Entity = TodoEntity | DocEntity; + +export interface TodoEntity { + id: EntityId; + type: "todo"; + name: string; + + config: { + statuses: Status[]; + }; + + tags: Record; + tasks: Record; + + createdAt: number; + updatedAt: number; +} + +export interface Status { + id: string; + name: string; + color?: string; + order: number; +} + +export interface Tag { + id: TagId; + name: string; + color?: string; +} + +export interface Task { + id: TaskId; + title: string; + description?: string; + + /** rich notes/documentation for the task */ + doc?: OutputData; + + statusId: string; + tagIds: TagId[]; + + createdAt: number; + updatedAt: number; +} + +export interface DocEntity { + id: EntityId; + type: "doc"; + name: string; + + content: OutputData; + + createdAt: number; + updatedAt: number; +} diff --git a/src/lib/ui/color.ts b/src/lib/ui/color.ts index d628397..6611575 100644 --- a/src/lib/ui/color.ts +++ b/src/lib/ui/color.ts @@ -8,7 +8,8 @@ export type Color = | "blue" | "purple" | "pink" - | "red"; + | "red" + | string; interface ColorStyle { backgroundColor: string; @@ -59,5 +60,5 @@ export function getColor(color: Color): ColorStyle { }, }; - return colors[color]; + return colors[color] || color; } diff --git a/src/lib/workspace.ts b/src/lib/workspace.ts new file mode 100644 index 0000000..6206186 --- /dev/null +++ b/src/lib/workspace.ts @@ -0,0 +1,271 @@ +import { Workspace, TodoEntity, DocEntity, Status, LayoutNode } from "./types"; + +export function createDefaultWorkspace(): Workspace { + const now = Date.now(); + + const todoId = crypto.randomUUID(); + const docId = crypto.randomUUID(); + + const defaultStatuses: Status[] = [ + { id: "status_todo", name: "To Do", order: 0, color: "#808080" }, + { + id: "status_inprogress", + name: "In Progress", + order: 1, + color: "#0ea5e9", + }, + { id: "status_done", name: "Done", order: 2, color: "#10b981" }, + ]; + + const todoEntity: TodoEntity = { + id: todoId, + type: "todo", + name: "Tasks", + config: { statuses: defaultStatuses }, + tags: {}, + tasks: {}, + createdAt: now, + updatedAt: now, + }; + + const docEntity: DocEntity = { + id: docId, + type: "doc", + name: "Notes", + content: { version: "2.28.0", time: now, blocks: [] }, + createdAt: now, + updatedAt: now, + }; + + return { + id: crypto.randomUUID(), + name: "My Workspace", + entities: { + [todoId]: todoEntity, + [docId]: docEntity, + }, + layout: [ + { + type: "group", + id: crypto.randomUUID(), + name: "Productivity", + children: [ + { entityId: todoId, type: "item" }, + { entityId: docId, type: "item" }, + ], + }, + ], + createdAt: now, + updatedAt: now, + }; +} + +export function loadWorkspaceFromLocalStorage(id: string): Workspace { + try { + const raw = localStorage.getItem(id); + + if (!raw) { + return createDefaultWorkspace(); + } + + const parsed = JSON.parse(raw); + + // Basic shape validation + if ( + typeof parsed !== "object" || + parsed === null || + typeof parsed.id !== "string" + ) { + console.warn("Invalid workspace shape. Resetting."); + return createDefaultWorkspace(); + } + + return parsed as Workspace; + } catch (err) { + console.error("Failed to load workspace:", err); + return createDefaultWorkspace(); + } +} + +export function saveWorkspaceToLocalStorage(workspace: Workspace): void { + try { + const serialized = JSON.stringify(workspace); + // keep using raw id for backwards compatibility + localStorage.setItem(workspace.id, serialized); + } catch (err) { + console.error("Failed to save workspace:", err); + } +} + +/** + * Scan localStorage and return all entries that appear to be workspaces. + * We ignore keys starting with "route_" which are used for routing state. + */ +export function listWorkspaces(): Workspace[] { + const workspaces: Workspace[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key) continue; + if (key.startsWith("route_")) continue; + + const raw = localStorage.getItem(key); + if (!raw) continue; + + try { + const parsed = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + typeof parsed.id === "string" && + typeof parsed.name === "string" && + parsed.entities && + parsed.layout + ) { + workspaces.push(parsed as Workspace); + } + } catch { + // ignore non-json values + } + } + return workspaces; +} + +// layout helpers ------------------------------------------------------------------- + +type RemoveResult = { layout: LayoutNode[]; removedEntityIds: string[] }; + +function collectEntityIds(node: LayoutNode): string[] { + if (node.type === "item") return [node.entityId]; + const ids: string[] = []; + for (const child of node.children) { + ids.push(...collectEntityIds(child)); + } + return ids; +} + +export function removeLayoutNode( + layout: LayoutNode[], + id: string, +): RemoveResult { + let removed: string[] = []; + + function recurse(nodes: LayoutNode[]): LayoutNode[] { + return nodes + .map((node) => { + if (node.type === "group") { + if (node.id === id) { + removed.push(...collectEntityIds(node)); + return null; + } + const children = recurse(node.children); + if (children.length !== node.children.length) { + // some children removed + } + return { ...node, children } as LayoutNode; + } else { + if (node.entityId === id) { + removed.push(node.entityId); + return null; + } + return node; + } + }) + .filter(Boolean) as LayoutNode[]; + } + + const newLayout = recurse(layout); + return { layout: newLayout, removedEntityIds: removed }; +} + +export function renameLayoutGroup( + layout: LayoutNode[], + id: string, + newName: string, +): LayoutNode[] { + return layout.map((node) => { + if (node.type === "group") { + if (node.id === id) { + return { ...node, name: newName }; + } + return { + ...node, + children: renameLayoutGroup(node.children, id, newName), + }; + } + return node; + }); +} + +export function addLayoutGroup( + layout: LayoutNode[], + parentId: string | null, + group: LayoutNode, +): LayoutNode[] { + if (!parentId) { + return [...layout, group]; + } + return layout.map((node) => { + if (node.type === "group") { + if (node.id === parentId) { + return { ...node, children: [...node.children, group] }; + } + return { + ...node, + children: addLayoutGroup(node.children, parentId, group), + }; + } + return node; + }); +} + +export function addLayoutItem( + layout: LayoutNode[], + parentId: string | null, + item: LayoutNode, +): LayoutNode[] { + if (!parentId) { + return [...layout, item]; + } + return layout.map((node) => { + if (node.type === "group") { + if (node.id === parentId) { + return { ...node, children: [...node.children, item] }; + } + return { + ...node, + children: addLayoutItem(node.children, parentId, item), + }; + } + return node; + }); +} + +// entity creation helpers ---------------------------------------------------------- + +export function createTodoEntity(name = "New list"): TodoEntity { + const id = crypto.randomUUID(); + const now = Date.now(); + return { + id, + type: "todo", + name, + config: { statuses: [] }, + tags: {}, + tasks: {}, + createdAt: now, + updatedAt: now, + }; +} + +export function createDocEntity(name = "New doc"): DocEntity { + const id = crypto.randomUUID(); + const now = Date.now(); + return { + id, + type: "doc", + name, + content: { version: "2.28.0", time: now, blocks: [] }, + createdAt: now, + updatedAt: now, + }; +}