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 (
-
-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
-
- );
+ 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 */}
+
+ {/* create workspace dialog */}
+
+ {/* create dialog */}
+
);
}
-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 = (
+
+ );
+
+ /* ================= 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 (
+
+
+
+
+
+
+ Export
+
+
+
+ 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
+
+
+
+
+
+
+ Export
+
+
+
+ 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
+
+
+
+ Delete
+
+ setSelectedIds(new Set())}
+ >
+ Clear
+
+
+ )}
+
-
- {Object.keys(statusLabels).map((status) => (
-
+
+ {tasksByStatus.map(({ status, tasks: statusTasks }) => (
+
{(provided) => (
- {statusLabels[status]}
+ {status.name}
createTask(status)}
+ onClick={() => createTask(status.id)}
>
-
+
- {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}
-
- {
- e.stopPropagation();
- }}
- >
-
-
-
+
+ {task.title}
+
+
+ {
+ e.stopPropagation();
+ }}
+ >
+
+
+
+ {onDelete && (
+ {
+ e.stopPropagation();
+ onDelete(task.id);
+ }}
+ >
+
+
+ )}
+
- {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 (
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]) => (
+
+
+
+
+
+
+
+ {COLOR_OPTIONS.map((color) => (
+ handleUpdateTagColor(tagId, color)}
+ />
+ ))}
+
+
+
+
+
handleUpdateTagName(tagId, e.target.value)}
+ placeholder="Tag name"
+ className="flex-1"
+ />
+
+
handleDeleteTag(tagId)}
+ className="p-2 hover:bg-destructive/10 rounded-md transition-colors"
+ >
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {COLOR_OPTIONS.map((color) => (
+ setNewTagColor(color)}
+ />
+ ))}
+
+
+
+
+
setNewTagName(e.target.value)}
+ placeholder="New tag name"
+ className="flex-1"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleAddTag();
+ }
+ }}
+ />
+
+
+
+ Add
+
+
+
+
+
+ {/* Statuses Section */}
+
+
Statuses
+
+ {statuses.map((status) => (
+
+
+
+
+
+
+
+
+
+ {COLOR_OPTIONS.map((color) => (
+
+ handleUpdateStatusColor(status.id, color)
+ }
+ />
+ ))}
+
+
+
+
+
+ handleUpdateStatusName(status.id, e.target.value)
+ }
+ placeholder="Status name"
+ className="flex-1"
+ />
+
+
handleDeleteStatus(status.id)}
+ className="p-2 hover:bg-destructive/10 rounded-md transition-colors"
+ >
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {COLOR_OPTIONS.map((color) => (
+ setNewStatusColor(color)}
+ />
+ ))}
+
+
+
+
+
setNewStatusName(e.target.value)}
+ placeholder="New status name"
+ className="flex-1"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ handleAddStatus();
+ }
+ }}
+ />
+
+
+
+ Add
+
+
+
+
+
+ );
+}
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 && (
+ {
+ e.stopPropagation();
+ onDelete(task.id);
+ }}
>
- {tag}
-
- ))}
+
+
+ )}
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 `.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 ``;
+ }
+
+ case "code":
+ return `${(block.data as any).code}
`;
+
+ case "quote":
+ return `${(block.data as any).text}
`;
+
+ case "delimiter":
+ return `
`;
+
+ case "image":
+ return `
`;
+
+ 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,
+ };
+}