From c17f11f59181973b918c3f70dcde234c8251ab8f Mon Sep 17 00:00:00 2001 From: Korrith Date: Thu, 17 Sep 2026 20:47:51 +0000 Subject: [PATCH] Initial commit: Korrith website, assessment API, PocketBase schema, Docker/Coolify deployment --- .dockerignore | 16 + .env.example | 21 + .gitignore | 30 + .nvmrc | 1 + .version | 1 + README.md | 61 + apps/api/.env.example | 19 + apps/api/Dockerfile | 17 + apps/api/docker-entrypoint.js | 30 + apps/api/eslint.config.mjs | 35 + apps/api/package.json | 27 + apps/api/src/constants/common.js | 8 + apps/api/src/main.js | 67 + apps/api/src/middleware/error.js | 24 + apps/api/src/middleware/global-rate-limit.js | 10 + apps/api/src/routes/assessment.js | 247 + apps/api/src/routes/health-check.js | 11 + apps/api/src/routes/index.js | 10 + apps/api/src/utils/integrationConfig.js | 31 + apps/api/src/utils/logger.js | 28 + apps/api/src/utils/mailService.js | 73 + apps/api/src/utils/pocketbaseClient.js | 84 + apps/pocketbase/.dockerignore | 6 + apps/pocketbase/.pocketbase-sha256 | 1 + apps/pocketbase/.pocketbase-version | 1 + apps/pocketbase/Dockerfile | 27 + apps/pocketbase/database-types.d.ts | 917 ++ apps/pocketbase/package.json | 14 + apps/pocketbase/pb_hooks/builder-mailer.pb.js | 45 + .../pb_hooks/custom-migrations-cmd.pb.js | 133 + .../pb_hooks/external-dashboard.pb.js | 27 + apps/pocketbase/pb_hooks/logs-forwarder.pb.js | 64 + .../pb_hooks/superusers-allow-list.js | 17 + apps/pocketbase/pb_hooks/superusers.pb.js | 101 + .../1759383931_initial_app_settings.js | 20 + .../1764579159_create_superuser.js | 10 + ...769159103_disable_auth_alert_superusers.js | 10 + .../1769164585_set_rate_limits.js | 55 + .../1775709407_disable_auth_alert_users.js | 19 + .../1782223262_001_created_assessments.js | 159 + ...16_002_remove_websites_from_assessments.js | 22 + ...226018_002_remove_apps_from_assessments.js | 22 + ...020_002_remove_systems_from_assessments.js | 22 + ...37_002_remove_websites_from_assessments.js | 22 + ...226039_002_remove_apps_from_assessments.js | 22 + ...041_002_remove_systems_from_assessments.js | 22 + ...52_002_add_websites_text_to_assessments.js | 31 + ...226054_002_add_apps_text_to_assessments.js | 31 + ...056_002_add_systems_text_to_assessments.js | 31 + ...62_003_remove_websites_from_assessments.js | 22 + ...226064_003_remove_apps_from_assessments.js | 22 + ...067_003_remove_systems_from_assessments.js | 22 + ...47_002_remove_websites_from_assessments.js | 22 + ...226448_002_remove_apps_from_assessments.js | 22 + ...450_002_remove_systems_from_assessments.js | 22 + .../1782226462_005_deleted_assessments.js | 16 + .../1782226667_001_deleted_assessments.js | 16 + .../1782226669_002_created_assessments.js | 170 + ...9685_002_update_websites_in_assessments.js | 21 + ..._002_update_applications_in_assessments.js | 21 + ...29689_002_update_systems_in_assessments.js | 21 + ...82229696_003_seed_assessments_1_records.js | 32 + apps/web/Dockerfile | 21 + apps/web/components.json | 23 + apps/web/eslint.config.mjs | 61 + apps/web/eslint.unicode-escapes-formatter.mjs | 61 + apps/web/eslint.unicode-escapes-plugin.mjs | 64 + apps/web/index.html | 52 + apps/web/jsconfig.json | 9 + apps/web/nginx.conf | 55 + apps/web/package.json | 87 + .../session-journal/session-journal-client.js | 471 + .../vite-plugin-session-journal.js | 103 + .../plugins/site-pages/site-pages-script.js | 48 + .../plugins/site-pages/site-pages-server.js | 323 + apps/web/plugins/utils/ast-utils.js | 280 + apps/web/plugins/utils/same-origin.js | 34 + .../visual-editor/api/draft-snapshot.js | 43 + apps/web/plugins/visual-editor/api/draft.js | 149 + .../plugins/visual-editor/constants/icons.js | 112 + .../plugins/visual-editor/constants/layout.js | 26 + .../visual-editor/constants/messages.js | 35 + .../visual-editor/constants/selectors.js | 84 + .../plugins/visual-editor/constants/theme.js | 50 + .../plugins/visual-editor/edit-mode-script.js | 812 ++ .../visual-editor/server/source-writer.js | 423 + .../visual-editor/state/annotation-state.js | 60 + .../visual-editor/state/editing-state.js | 25 + .../visual-editor/state/history-state.js | 439 + .../visual-editor/state/multi-select-state.js | 78 + .../visual-editor/state/panel-state.js | 65 + .../visual-editor/transform/editability.js | 301 + .../transform/inject-attributes.js | 54 + .../ui/annotation-panel/annotation-markers.js | 404 + .../ui/annotation-panel/panel.js | 544 + .../ui/annotation-panel/styles.js | 288 + .../ui/annotation-panel/template.js | 25 + .../ui/inline-edit/cursor-styles.js | 93 + .../ui/inline-edit/edit-action.js | 368 + .../visual-editor/ui/inline-edit/styles.js | 85 + .../ui/overlays/hover-outline.js | 71 + .../ui/overlays/selection-rect.js | 140 + .../visual-editor/ui/overlays/type-tooltip.js | 73 + .../text-format/color-action/color-action.js | 766 ++ .../color-action/color-conversions.js | 104 + .../ui/text-format/color-action/gradient.js | 59 + .../ui/text-format/color-action/hex-input.js | 252 + .../text-format/color-action/picker-square.js | 35 + .../ui/text-format/color-action/styles.js | 305 + .../ui/text-format/color-action/swatches.js | 295 + .../ui/text-format/color-action/template.js | 60 + .../ui/text-format/dropdown-panel.js | 94 + .../font-family-action/font-family-action.js | 113 + .../text-format/font-family-action/styles.js | 41 + .../font-family-action/template.js | 16 + .../ui/text-format/link-action/link-action.js | 427 + .../ui/text-format/link-action/styles.js | 120 + .../ui/text-format/link-action/template.js | 13 + .../ui/text-format/size-action/size-action.js | 102 + .../ui/text-format/size-action/styles.js | 42 + .../ui/text-format/size-action/template.js | 12 + .../ui/text-format/style-actions.js | 502 + .../ui/text-format/toolbar/positioning.js | 108 + .../ui/text-format/toolbar/styles.js | 211 + .../ui/text-format/toolbar/template.js | 52 + .../ui/text-format/toolbar/toolbar.js | 357 + .../plugins/visual-editor/utils/dom-utils.js | 69 + .../plugins/visual-editor/utils/html-utils.js | 334 + .../visual-editor/utils/inline-style-state.js | 104 + .../visual-editor/utils/parent-frame.js | 39 + .../utils/selection-mode-metadata.js | 416 + .../utils/session-history-command.js | 33 + .../visual-editor/vite-plugin-edit-mode.js | 55 + .../vite-plugin-react-inline-editor.js | 210 + .../vite-plugin-iframe-route-restoration.js | 134 + .../plugins/vite-plugin-pocketbase-auth.js | 52 + apps/web/plugins/vite-plugin-site-pages.js | 91 + apps/web/postcss.config.js | 6 + apps/web/public/.htaccess | 19 + apps/web/public/llms-full.txt | 110 + apps/web/src/App.jsx | 113 + apps/web/src/components/Footer.jsx | 108 + apps/web/src/components/Header.jsx | 201 + apps/web/src/components/LayoutWrapper.jsx | 15 + apps/web/src/components/ScrollToTop.jsx | 28 + apps/web/src/components/ui/accordion.jsx | 41 + apps/web/src/components/ui/alert-dialog.jsx | 97 + apps/web/src/components/ui/alert.jsx | 47 + apps/web/src/components/ui/aspect-ratio.jsx | 5 + apps/web/src/components/ui/avatar.jsx | 33 + apps/web/src/components/ui/badge.jsx | 34 + apps/web/src/components/ui/breadcrumb.jsx | 92 + apps/web/src/components/ui/button-group.jsx | 78 + apps/web/src/components/ui/button.jsx | 48 + apps/web/src/components/ui/calendar.jsx | 175 + apps/web/src/components/ui/card.jsx | 50 + apps/web/src/components/ui/carousel.jsx | 194 + apps/web/src/components/ui/chart.jsx | 312 + apps/web/src/components/ui/checkbox.jsx | 22 + apps/web/src/components/ui/collapsible.jsx | 11 + apps/web/src/components/ui/command.jsx | 127 + apps/web/src/components/ui/context-menu.jsx | 156 + apps/web/src/components/ui/dialog.jsx | 94 + apps/web/src/components/ui/drawer.jsx | 90 + apps/web/src/components/ui/dropdown-menu.jsx | 158 + apps/web/src/components/ui/empty.jsx | 110 + apps/web/src/components/ui/field.jsx | 234 + apps/web/src/components/ui/form.jsx | 138 + apps/web/src/components/ui/hover-card.jsx | 25 + apps/web/src/components/ui/input-group.jsx | 160 + apps/web/src/components/ui/input-otp.jsx | 53 + apps/web/src/components/ui/input.jsx | 19 + apps/web/src/components/ui/item.jsx | 197 + apps/web/src/components/ui/kbd.jsx | 32 + apps/web/src/components/ui/label.jsx | 18 + apps/web/src/components/ui/menubar.jsx | 198 + .../web/src/components/ui/navigation-menu.jsx | 104 + apps/web/src/components/ui/pagination.jsx | 100 + apps/web/src/components/ui/popover.jsx | 29 + apps/web/src/components/ui/progress.jsx | 23 + apps/web/src/components/ui/radio-group.jsx | 31 + apps/web/src/components/ui/resizable.jsx | 52 + apps/web/src/components/ui/scroll-area.jsx | 38 + apps/web/src/components/ui/select.jsx | 119 + apps/web/src/components/ui/separator.jsx | 25 + apps/web/src/components/ui/sheet.jsx | 109 + apps/web/src/components/ui/sidebar.jsx | 629 + apps/web/src/components/ui/skeleton.jsx | 14 + apps/web/src/components/ui/slider.jsx | 21 + apps/web/src/components/ui/sonner.jsx | 28 + apps/web/src/components/ui/spinner.jsx | 18 + apps/web/src/components/ui/switch.jsx | 24 + apps/web/src/components/ui/table.jsx | 86 + apps/web/src/components/ui/tabs.jsx | 41 + apps/web/src/components/ui/textarea.jsx | 18 + apps/web/src/components/ui/toast.jsx | 85 + apps/web/src/components/ui/toaster.jsx | 33 + apps/web/src/components/ui/toggle-group.jsx | 44 + apps/web/src/components/ui/toggle.jsx | 38 + apps/web/src/components/ui/tooltip.jsx | 26 + apps/web/src/hooks/use-mobile.jsx | 19 + apps/web/src/hooks/use-toast.js | 155 + apps/web/src/hooks/useGoogleAnalytics.js | 37 + apps/web/src/index.css | 159 + apps/web/src/lib/apiServerClient.js | 11 + apps/web/src/lib/pocketbaseClient.js | 9 + apps/web/src/lib/utils.js | 6 + apps/web/src/main.jsx | 8 + apps/web/src/pages/AboutPage.jsx | 169 + apps/web/src/pages/ActiveValidationPage.jsx | 286 + apps/web/src/pages/AgenciesPage.jsx | 298 + apps/web/src/pages/ArgusDetailPage.jsx | 347 + apps/web/src/pages/ArgusGridPage.jsx | 313 + apps/web/src/pages/ArgusPage.jsx | 15 + apps/web/src/pages/AssessmentPage.jsx | 422 + apps/web/src/pages/BlogPage.jsx | 312 + apps/web/src/pages/BusinessesPage.jsx | 272 + apps/web/src/pages/ContactPage.jsx | 183 + apps/web/src/pages/DocsPage.jsx | 15 + apps/web/src/pages/DocumentationPage.jsx | 205 + apps/web/src/pages/ExternalPosturePage.jsx | 366 + apps/web/src/pages/FAQPage.jsx | 179 + apps/web/src/pages/HomePage.jsx | 373 + apps/web/src/pages/InternalPosturePage.jsx | 423 + apps/web/src/pages/LoginPage.jsx | 160 + apps/web/src/pages/MSSPsPage.jsx | 380 + .../src/pages/ManagedSecureHostingPage.jsx | 15 + apps/web/src/pages/PartnersPage.jsx | 255 + .../src/pages/PostQuantumReadinessPage.jsx | 15 + apps/web/src/pages/PrivacyPage.jsx | 66 + apps/web/src/pages/QuantumReadinessPage.jsx | 369 + apps/web/src/pages/ReportsAndEvidencePage.jsx | 358 + apps/web/src/pages/ReportsPage.jsx | 15 + apps/web/src/pages/ResearchPage.jsx | 15 + apps/web/src/pages/RoadmapPage.jsx | 265 + apps/web/src/pages/SampleReportPage.jsx | 244 + apps/web/src/pages/TermsPage.jsx | 72 + apps/web/src/pages/TrustPage.jsx | 191 + .../src/pages/WebsiteSecurityAuditPage.jsx | 196 + apps/web/src/pages/WhyKorrithPage.jsx | 197 + apps/web/tailwind.config.js | 87 + apps/web/tools/generate-llms.js | 191 + apps/web/tools/install-missing-components.js | 226 + apps/web/vite.config.js | 421 + docker-compose.yml | 67 + knip.json | 226 + package-lock.json | 10413 ++++++++++++++++ package.json | 19 + 248 files changed, 39721 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .version create mode 100644 README.md create mode 100644 apps/api/.env.example create mode 100644 apps/api/Dockerfile create mode 100644 apps/api/docker-entrypoint.js create mode 100644 apps/api/eslint.config.mjs create mode 100644 apps/api/package.json create mode 100644 apps/api/src/constants/common.js create mode 100644 apps/api/src/main.js create mode 100644 apps/api/src/middleware/error.js create mode 100644 apps/api/src/middleware/global-rate-limit.js create mode 100644 apps/api/src/routes/assessment.js create mode 100644 apps/api/src/routes/health-check.js create mode 100644 apps/api/src/routes/index.js create mode 100644 apps/api/src/utils/integrationConfig.js create mode 100644 apps/api/src/utils/logger.js create mode 100644 apps/api/src/utils/mailService.js create mode 100644 apps/api/src/utils/pocketbaseClient.js create mode 100644 apps/pocketbase/.dockerignore create mode 100644 apps/pocketbase/.pocketbase-sha256 create mode 100644 apps/pocketbase/.pocketbase-version create mode 100644 apps/pocketbase/Dockerfile create mode 100644 apps/pocketbase/database-types.d.ts create mode 100644 apps/pocketbase/package.json create mode 100644 apps/pocketbase/pb_hooks/builder-mailer.pb.js create mode 100644 apps/pocketbase/pb_hooks/custom-migrations-cmd.pb.js create mode 100644 apps/pocketbase/pb_hooks/external-dashboard.pb.js create mode 100644 apps/pocketbase/pb_hooks/logs-forwarder.pb.js create mode 100644 apps/pocketbase/pb_hooks/superusers-allow-list.js create mode 100644 apps/pocketbase/pb_hooks/superusers.pb.js create mode 100644 apps/pocketbase/pb_migrations/1759383931_initial_app_settings.js create mode 100644 apps/pocketbase/pb_migrations/1764579159_create_superuser.js create mode 100644 apps/pocketbase/pb_migrations/1769159103_disable_auth_alert_superusers.js create mode 100644 apps/pocketbase/pb_migrations/1769164585_set_rate_limits.js create mode 100644 apps/pocketbase/pb_migrations/1775709407_disable_auth_alert_users.js create mode 100644 apps/pocketbase/pb_migrations/1782223262_001_created_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226016_002_remove_websites_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226018_002_remove_apps_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226020_002_remove_systems_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226037_002_remove_websites_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226039_002_remove_apps_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226041_002_remove_systems_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226052_002_add_websites_text_to_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226054_002_add_apps_text_to_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226056_002_add_systems_text_to_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226062_003_remove_websites_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226064_003_remove_apps_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226067_003_remove_systems_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226447_002_remove_websites_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226448_002_remove_apps_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226450_002_remove_systems_from_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226462_005_deleted_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226667_001_deleted_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782226669_002_created_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782229685_002_update_websites_in_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782229687_002_update_applications_in_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782229689_002_update_systems_in_assessments.js create mode 100644 apps/pocketbase/pb_migrations/1782229696_003_seed_assessments_1_records.js create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/components.json create mode 100644 apps/web/eslint.config.mjs create mode 100644 apps/web/eslint.unicode-escapes-formatter.mjs create mode 100644 apps/web/eslint.unicode-escapes-plugin.mjs create mode 100644 apps/web/index.html create mode 100644 apps/web/jsconfig.json create mode 100644 apps/web/nginx.conf create mode 100644 apps/web/package.json create mode 100644 apps/web/plugins/session-journal/session-journal-client.js create mode 100644 apps/web/plugins/session-journal/vite-plugin-session-journal.js create mode 100644 apps/web/plugins/site-pages/site-pages-script.js create mode 100644 apps/web/plugins/site-pages/site-pages-server.js create mode 100644 apps/web/plugins/utils/ast-utils.js create mode 100644 apps/web/plugins/utils/same-origin.js create mode 100644 apps/web/plugins/visual-editor/api/draft-snapshot.js create mode 100644 apps/web/plugins/visual-editor/api/draft.js create mode 100644 apps/web/plugins/visual-editor/constants/icons.js create mode 100644 apps/web/plugins/visual-editor/constants/layout.js create mode 100644 apps/web/plugins/visual-editor/constants/messages.js create mode 100644 apps/web/plugins/visual-editor/constants/selectors.js create mode 100644 apps/web/plugins/visual-editor/constants/theme.js create mode 100644 apps/web/plugins/visual-editor/edit-mode-script.js create mode 100644 apps/web/plugins/visual-editor/server/source-writer.js create mode 100644 apps/web/plugins/visual-editor/state/annotation-state.js create mode 100644 apps/web/plugins/visual-editor/state/editing-state.js create mode 100644 apps/web/plugins/visual-editor/state/history-state.js create mode 100644 apps/web/plugins/visual-editor/state/multi-select-state.js create mode 100644 apps/web/plugins/visual-editor/state/panel-state.js create mode 100644 apps/web/plugins/visual-editor/transform/editability.js create mode 100644 apps/web/plugins/visual-editor/transform/inject-attributes.js create mode 100644 apps/web/plugins/visual-editor/ui/annotation-panel/annotation-markers.js create mode 100644 apps/web/plugins/visual-editor/ui/annotation-panel/panel.js create mode 100644 apps/web/plugins/visual-editor/ui/annotation-panel/styles.js create mode 100644 apps/web/plugins/visual-editor/ui/annotation-panel/template.js create mode 100644 apps/web/plugins/visual-editor/ui/inline-edit/cursor-styles.js create mode 100644 apps/web/plugins/visual-editor/ui/inline-edit/edit-action.js create mode 100644 apps/web/plugins/visual-editor/ui/inline-edit/styles.js create mode 100644 apps/web/plugins/visual-editor/ui/overlays/hover-outline.js create mode 100644 apps/web/plugins/visual-editor/ui/overlays/selection-rect.js create mode 100644 apps/web/plugins/visual-editor/ui/overlays/type-tooltip.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/color-action/color-action.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/color-action/color-conversions.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/color-action/gradient.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/color-action/hex-input.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/color-action/picker-square.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/color-action/styles.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/color-action/swatches.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/color-action/template.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/dropdown-panel.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/font-family-action/font-family-action.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/font-family-action/styles.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/font-family-action/template.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/link-action/link-action.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/link-action/styles.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/link-action/template.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/size-action/size-action.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/size-action/styles.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/size-action/template.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/style-actions.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/toolbar/positioning.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/toolbar/styles.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/toolbar/template.js create mode 100644 apps/web/plugins/visual-editor/ui/text-format/toolbar/toolbar.js create mode 100644 apps/web/plugins/visual-editor/utils/dom-utils.js create mode 100644 apps/web/plugins/visual-editor/utils/html-utils.js create mode 100644 apps/web/plugins/visual-editor/utils/inline-style-state.js create mode 100644 apps/web/plugins/visual-editor/utils/parent-frame.js create mode 100644 apps/web/plugins/visual-editor/utils/selection-mode-metadata.js create mode 100644 apps/web/plugins/visual-editor/utils/session-history-command.js create mode 100644 apps/web/plugins/visual-editor/vite-plugin-edit-mode.js create mode 100644 apps/web/plugins/visual-editor/vite-plugin-react-inline-editor.js create mode 100644 apps/web/plugins/vite-plugin-iframe-route-restoration.js create mode 100644 apps/web/plugins/vite-plugin-pocketbase-auth.js create mode 100644 apps/web/plugins/vite-plugin-site-pages.js create mode 100644 apps/web/postcss.config.js create mode 100644 apps/web/public/.htaccess create mode 100644 apps/web/public/llms-full.txt create mode 100644 apps/web/src/App.jsx create mode 100644 apps/web/src/components/Footer.jsx create mode 100644 apps/web/src/components/Header.jsx create mode 100644 apps/web/src/components/LayoutWrapper.jsx create mode 100644 apps/web/src/components/ScrollToTop.jsx create mode 100644 apps/web/src/components/ui/accordion.jsx create mode 100644 apps/web/src/components/ui/alert-dialog.jsx create mode 100644 apps/web/src/components/ui/alert.jsx create mode 100644 apps/web/src/components/ui/aspect-ratio.jsx create mode 100644 apps/web/src/components/ui/avatar.jsx create mode 100644 apps/web/src/components/ui/badge.jsx create mode 100644 apps/web/src/components/ui/breadcrumb.jsx create mode 100644 apps/web/src/components/ui/button-group.jsx create mode 100644 apps/web/src/components/ui/button.jsx create mode 100644 apps/web/src/components/ui/calendar.jsx create mode 100644 apps/web/src/components/ui/card.jsx create mode 100644 apps/web/src/components/ui/carousel.jsx create mode 100644 apps/web/src/components/ui/chart.jsx create mode 100644 apps/web/src/components/ui/checkbox.jsx create mode 100644 apps/web/src/components/ui/collapsible.jsx create mode 100644 apps/web/src/components/ui/command.jsx create mode 100644 apps/web/src/components/ui/context-menu.jsx create mode 100644 apps/web/src/components/ui/dialog.jsx create mode 100644 apps/web/src/components/ui/drawer.jsx create mode 100644 apps/web/src/components/ui/dropdown-menu.jsx create mode 100644 apps/web/src/components/ui/empty.jsx create mode 100644 apps/web/src/components/ui/field.jsx create mode 100644 apps/web/src/components/ui/form.jsx create mode 100644 apps/web/src/components/ui/hover-card.jsx create mode 100644 apps/web/src/components/ui/input-group.jsx create mode 100644 apps/web/src/components/ui/input-otp.jsx create mode 100644 apps/web/src/components/ui/input.jsx create mode 100644 apps/web/src/components/ui/item.jsx create mode 100644 apps/web/src/components/ui/kbd.jsx create mode 100644 apps/web/src/components/ui/label.jsx create mode 100644 apps/web/src/components/ui/menubar.jsx create mode 100644 apps/web/src/components/ui/navigation-menu.jsx create mode 100644 apps/web/src/components/ui/pagination.jsx create mode 100644 apps/web/src/components/ui/popover.jsx create mode 100644 apps/web/src/components/ui/progress.jsx create mode 100644 apps/web/src/components/ui/radio-group.jsx create mode 100644 apps/web/src/components/ui/resizable.jsx create mode 100644 apps/web/src/components/ui/scroll-area.jsx create mode 100644 apps/web/src/components/ui/select.jsx create mode 100644 apps/web/src/components/ui/separator.jsx create mode 100644 apps/web/src/components/ui/sheet.jsx create mode 100644 apps/web/src/components/ui/sidebar.jsx create mode 100644 apps/web/src/components/ui/skeleton.jsx create mode 100644 apps/web/src/components/ui/slider.jsx create mode 100644 apps/web/src/components/ui/sonner.jsx create mode 100644 apps/web/src/components/ui/spinner.jsx create mode 100644 apps/web/src/components/ui/switch.jsx create mode 100644 apps/web/src/components/ui/table.jsx create mode 100644 apps/web/src/components/ui/tabs.jsx create mode 100644 apps/web/src/components/ui/textarea.jsx create mode 100644 apps/web/src/components/ui/toast.jsx create mode 100644 apps/web/src/components/ui/toaster.jsx create mode 100644 apps/web/src/components/ui/toggle-group.jsx create mode 100644 apps/web/src/components/ui/toggle.jsx create mode 100644 apps/web/src/components/ui/tooltip.jsx create mode 100644 apps/web/src/hooks/use-mobile.jsx create mode 100644 apps/web/src/hooks/use-toast.js create mode 100644 apps/web/src/hooks/useGoogleAnalytics.js create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/apiServerClient.js create mode 100644 apps/web/src/lib/pocketbaseClient.js create mode 100644 apps/web/src/lib/utils.js create mode 100644 apps/web/src/main.jsx create mode 100644 apps/web/src/pages/AboutPage.jsx create mode 100644 apps/web/src/pages/ActiveValidationPage.jsx create mode 100644 apps/web/src/pages/AgenciesPage.jsx create mode 100644 apps/web/src/pages/ArgusDetailPage.jsx create mode 100644 apps/web/src/pages/ArgusGridPage.jsx create mode 100644 apps/web/src/pages/ArgusPage.jsx create mode 100644 apps/web/src/pages/AssessmentPage.jsx create mode 100644 apps/web/src/pages/BlogPage.jsx create mode 100644 apps/web/src/pages/BusinessesPage.jsx create mode 100644 apps/web/src/pages/ContactPage.jsx create mode 100644 apps/web/src/pages/DocsPage.jsx create mode 100644 apps/web/src/pages/DocumentationPage.jsx create mode 100644 apps/web/src/pages/ExternalPosturePage.jsx create mode 100644 apps/web/src/pages/FAQPage.jsx create mode 100644 apps/web/src/pages/HomePage.jsx create mode 100644 apps/web/src/pages/InternalPosturePage.jsx create mode 100644 apps/web/src/pages/LoginPage.jsx create mode 100644 apps/web/src/pages/MSSPsPage.jsx create mode 100644 apps/web/src/pages/ManagedSecureHostingPage.jsx create mode 100644 apps/web/src/pages/PartnersPage.jsx create mode 100644 apps/web/src/pages/PostQuantumReadinessPage.jsx create mode 100644 apps/web/src/pages/PrivacyPage.jsx create mode 100644 apps/web/src/pages/QuantumReadinessPage.jsx create mode 100644 apps/web/src/pages/ReportsAndEvidencePage.jsx create mode 100644 apps/web/src/pages/ReportsPage.jsx create mode 100644 apps/web/src/pages/ResearchPage.jsx create mode 100644 apps/web/src/pages/RoadmapPage.jsx create mode 100644 apps/web/src/pages/SampleReportPage.jsx create mode 100644 apps/web/src/pages/TermsPage.jsx create mode 100644 apps/web/src/pages/TrustPage.jsx create mode 100644 apps/web/src/pages/WebsiteSecurityAuditPage.jsx create mode 100644 apps/web/src/pages/WhyKorrithPage.jsx create mode 100644 apps/web/tailwind.config.js create mode 100644 apps/web/tools/generate-llms.js create mode 100644 apps/web/tools/install-missing-components.js create mode 100644 apps/web/vite.config.js create mode 100644 docker-compose.yml create mode 100644 knip.json create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fe7ca8d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +node_modules +**/node_modules +dist +.env +.env.* +!.env.example +apps/pocketbase/pb_data +apps/pocketbase/pb_backups +apps/pocketbase/pb_snapshots +apps/pocketbase/pocketbase +vault +*.log +.DS_Store +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f6de849 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Root environment file — used by docker-compose.yml (Coolify injects these too). +# Copy to .env and fill in real values. NEVER commit .env. + +# --- PocketBase --- +# Initial admin account, seeded on first boot (also used by the API to read/write). +PB_SUPERUSER_EMAIL=admin@example.com +PB_SUPERUSER_PASSWORD=change-me-strong-password +# 32+ random characters; encrypts PocketBase settings at rest. +PB_ENCRYPTION_KEY=change-me-32-plus-random-chars + +# --- SMTP (assessment confirmation + admin notification emails) --- +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=smtp@example.com +SMTP_PASSWORD=change-me + +# --- Optional --- +# MAIL_FROM=smtp@example.com +# MAIL_REPLY_TO=noreply@example.com +# ADMIN_EMAILS=hello@example.com,ops@example.com +# CORS_ORIGIN=* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38d935d --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Secrets — never commit real credentials +.env +.env.* +!.env.example + +# Dependencies +node_modules/ + +# Build output +dist/ + +# PocketBase runtime data & binary (recreated on deploy) +pb_data/ +apps/pocketbase/pb_data/ +apps/pocketbase/pb_backups/ +apps/pocketbase/pb_snapshots/ +apps/pocketbase/pocketbase + +# Platform tooling internals (not part of the app) +vault/ + +# Logs +*.log +npm-debug.log* + +# OS / editor +.DS_Store +Thumbs.db +.idea/ +.vscode/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.version b/.version new file mode 100644 index 0000000..abac1ea --- /dev/null +++ b/.version @@ -0,0 +1 @@ +47 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2cc3055 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Korrith — Website & Assessment Platform + +Marketing site and security-assessment intake for Korrith. npm-workspaces monorepo, Node 22 (see `.nvmrc`). + +| App | Stack | Port | Purpose | +| --- | --- | --- | --- | +| `apps/web` | React 18 + Vite + Tailwind + shadcn/ui | 3000 | Public website (static build served by nginx in production) | +| `apps/api` | Express 5 + Nodemailer | 3001 | Assessment intake: validates, stores in PocketBase, emails the submitter + admins | +| `apps/pocketbase` | PocketBase 0.40.4 (SQLite) | 8090 | Database, auth, admin UI | + +## Project structure + +``` +apps/ + web/ # Frontend (src/pages, src/components, nginx.conf + Dockerfile for prod) + api/ # Express API (src/routes, src/utils, Dockerfile) + pocketbase/ # pb_migrations (schema, append-only), pb_hooks, Dockerfile +docker-compose.yml +``` + +## Local development + +1. `npm install` +2. `cp apps/api/.env.example apps/api/.env` and fill in real values. +3. Download the PocketBase 0.40.4 binary into `apps/pocketbase/pocketbase` + (it is gitignored): https://github.com/pocketbase/pocketbase/releases/tag/v0.40.4 +4. `npm run dev` — starts web (3000), api (3001) and pocketbase (8090) together. + +## Environment variables + +| Variable | Used by | Description | +| --- | --- | --- | +| `PB_SUPERUSER_EMAIL` / `PB_SUPERUSER_PASSWORD` | pocketbase (first boot), api | Initial PocketBase admin account; the API uses it to read/write | +| `PB_ENCRYPTION_KEY` | pocketbase | 32+ random chars; encrypts settings at rest | +| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` | api | Transactional email (587 = STARTTLS, 465 = SSL) | +| `MAIL_FROM` / `MAIL_REPLY_TO` | api | Optional sender identity overrides | +| `ADMIN_EMAILS` | api | Optional comma-separated notification recipients | +| `CORS_ORIGIN` | api | Default `*` | + +## Deploying to a VPS with Coolify + +1. Push this repo to GitHub. `.gitignore` already excludes secrets, `node_modules`, + build output, the PocketBase binary and its database. +2. In Coolify: **New Resource → from GitHub repo → Docker Compose** build pack + (uses the root `docker-compose.yml`). +3. Set the environment variables above in Coolify's UI. +4. Expose the **`web`** service (container port **80**) with your domain. It serves + the site and reverse-proxies same-origin paths the frontend depends on: + - `/hcgi/api/*` → `api:3001` (Express) + - `/hcgi/platform/*` → `pocketbase:8090` (admin UI at `https://your-domain/hcgi/platform/_/`) +5. The `pocketbase-data` volume persists the database. On first boot, migrations + build the schema and seed the admin account from the env vars. + +### Notes + +- `apps/pocketbase/pb_migrations` is append-only history — never edit or delete + applied migrations; add new ones instead. +- The PocketBase Dockerfile uses Docker's `TARGETARCH`, so arm64 VPSes work too. +- Building the frontend image needs ~2 GB RAM on the VPS. +- Assessment emails are sent only by the Express API (Nodemailer); PocketBase + ships with an empty hooks directory in production. diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..2326bc5 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,19 @@ +# API environment — copy to apps/api/.env for local development. +PORT=3001 +CORS_ORIGIN=* + +# PocketBase runs on localhost:8090 (fixed). In docker-compose the API reaches +# it automatically via a loopback forwarder — no configuration needed. +PB_SUPERUSER_EMAIL=admin@example.com +PB_SUPERUSER_PASSWORD=change-me-strong-password + +# SMTP (assessment confirmation + admin notification emails) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=smtp@example.com +SMTP_PASSWORD=change-me + +# Optional overrides +# MAIL_FROM=smtp@example.com +# MAIL_REPLY_TO=noreply@example.com +# ADMIN_EMAILS=hello@example.com,ops@example.com diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..9c3a77e --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,17 @@ +# Express API — build context must be the REPO ROOT (npm workspaces). + +FROM node:22-alpine +WORKDIR /app + +# Install workspace dependencies first for better layer caching. +COPY package.json package-lock.json ./ +COPY apps/api/package.json ./apps/api/package.json +RUN npm ci --omit=dev --workspace apps/api + +COPY apps/api ./apps/api +WORKDIR /app/apps/api + +ENV NODE_ENV=production +# Environment variables are injected by Coolify / docker-compose — no .env file needed. +EXPOSE 3001 +CMD ["node", "docker-entrypoint.js"] diff --git a/apps/api/docker-entrypoint.js b/apps/api/docker-entrypoint.js new file mode 100644 index 0000000..8fe716b --- /dev/null +++ b/apps/api/docker-entrypoint.js @@ -0,0 +1,30 @@ +// Docker-only entrypoint. +// The API's PocketBase client targets http://localhost:8090 (fixed in a +// platform-managed file). In docker-compose, PocketBase runs in its own +// container, so forward localhost:8090 -> the pocketbase service before +// starting the API. Local development (npm run dev) does not use this file. + +import net from 'node:net'; +import { spawn } from 'node:child_process'; + +const UPSTREAM_HOST = process.env.PB_UPSTREAM_HOST || 'pocketbase'; +const UPSTREAM_PORT = 8090; + +function forward(socket) { + const upstream = net.connect(UPSTREAM_PORT, UPSTREAM_HOST); + socket.pipe(upstream).pipe(socket); + socket.on('error', () => socket.destroy()); + upstream.on('error', () => socket.destroy()); +} + +function bind(host) { + const server = net.createServer(forward); + server.on('error', () => {}); // e.g. IPv6 unavailable in the container + server.listen(UPSTREAM_PORT, host); +} + +bind('127.0.0.1'); +bind('::1'); + +const child = spawn('node', ['src/main.js'], { stdio: 'inherit', env: process.env }); +child.on('exit', (code) => process.exit(code ?? 0)); diff --git a/apps/api/eslint.config.mjs b/apps/api/eslint.config.mjs new file mode 100644 index 0000000..88a945f --- /dev/null +++ b/apps/api/eslint.config.mjs @@ -0,0 +1,35 @@ +import js from '@eslint/js'; +import importPlugin from 'eslint-plugin-import'; +import globals from 'globals'; + +export default [ + { ignores: ['node_modules/**'] }, + js.configs.recommended, + { + files: ['**/*.js'], + plugins: { import: importPlugin }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + globals: { + ...globals.node, + } + }, + settings: { + 'import/resolver': { + node: { extensions: ['.js'] }, + }, + }, + rules: { + ...importPlugin.flatConfigs.recommended.rules, + 'no-console': 'off', + 'no-unused-vars': 'off', + 'no-undef': 'error', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'import/no-named-as-default': 'off', + 'import/no-named-as-default-member': 'off', + 'import/no-self-import': 'error', + 'import/no-cycle': 'off', + }, + }, +]; diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..b64f8ca --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,27 @@ +{ + "name": "api", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "node --trace-deprecation --env-file=.env src/main.js", + "start": "node --env-file=.env src/main.js", + "lint": "eslint . --quiet" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^17.4.2", + "express": "^5.0.1", + "express-rate-limit": "^8.3.1", + "helmet": "^8.0.0", + "morgan": "^1.10.0", + "nodemailer": "^6.9.0", + "pocketbase": "^0.27.0" + }, + "devDependencies": { + "globals": "^15.14.0", + "@eslint/js": "^9.39.4", + "eslint": "^9.39.4", + "eslint-plugin-import": "^2.32.0" + } +} \ No newline at end of file diff --git a/apps/api/src/constants/common.js b/apps/api/src/constants/common.js new file mode 100644 index 0000000..7e02ce1 --- /dev/null +++ b/apps/api/src/constants/common.js @@ -0,0 +1,8 @@ +const NodeEnv = { + Development: 'development', + Production: 'production', +}; + +const BodyLimit = 1024 * 1024 * 20; + +export { NodeEnv, BodyLimit }; \ No newline at end of file diff --git a/apps/api/src/main.js b/apps/api/src/main.js new file mode 100644 index 0000000..dd5d8f6 --- /dev/null +++ b/apps/api/src/main.js @@ -0,0 +1,67 @@ +import express from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import morgan from 'morgan'; + +import routes from './routes/index.js'; +import { errorMiddleware } from './middleware/error.js'; +import { globalRateLimit } from './middleware/global-rate-limit.js'; +import logger from './utils/logger.js'; +import { BodyLimit } from './constants/common.js'; + +const app = express(); + +app.set('trust proxy', true); + +process.on('uncaughtException', (error) => { + logger.error('Uncaught exception:', error); +}); + +process.on('unhandledRejection', (reason, promise) => { + logger.error('Unhandled rejection at:', promise, 'reason:', reason); +}); + +process.on('SIGINT', async () => { + logger.info('Interrupted'); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + logger.info('SIGTERM signal received'); + + await new Promise(resolve => setTimeout(resolve, 3000)); + + logger.info('Exiting'); + process.exit(); +}); + +app.use(helmet()); +app.use(cors({ + origin: process.env.CORS_ORIGIN, + credentials: true, +})); +app.use(morgan('combined')); +app.use(globalRateLimit); +app.use(express.json({ + limit: BodyLimit, +})); +app.use(express.urlencoded({ + extended: true, + limit: BodyLimit, +})); + +app.use('/', routes()); + +app.use(errorMiddleware); + +app.use((req, res) => { + res.status(404).json({ error: 'Route not found' }); +}); + +const port = process.env.PORT || 3001; + +app.listen(port, () => { + logger.info(`🚀 API Server running on http://localhost:${port}`); +}); + +export default app; \ No newline at end of file diff --git a/apps/api/src/middleware/error.js b/apps/api/src/middleware/error.js new file mode 100644 index 0000000..4a82d13 --- /dev/null +++ b/apps/api/src/middleware/error.js @@ -0,0 +1,24 @@ +import logger from '../utils/logger.js'; +import { NodeEnv } from '../constants/common.js'; + +const errorMiddleware = (err, req, res, next) => { + logger.error(err.message, err.stack); + + if (res.headersSent) { + return next(err); + } + + res.status(500).json({ + message: 'Something went wrong!', + ...(process.env.NODE_ENV !== NodeEnv.Production && { + error: { + name: err.name, + message: err.message, + stack: err.stack, + }, + }), + }); +}; + +export default errorMiddleware; +export { errorMiddleware }; diff --git a/apps/api/src/middleware/global-rate-limit.js b/apps/api/src/middleware/global-rate-limit.js new file mode 100644 index 0000000..06a9efc --- /dev/null +++ b/apps/api/src/middleware/global-rate-limit.js @@ -0,0 +1,10 @@ +import rateLimit from 'express-rate-limit'; + +export const globalRateLimit = rateLimit({ + windowMs: 5 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many requests, please try again later' }, + validate: { trustProxy: false }, +}); \ No newline at end of file diff --git a/apps/api/src/routes/assessment.js b/apps/api/src/routes/assessment.js new file mode 100644 index 0000000..07a2ff4 --- /dev/null +++ b/apps/api/src/routes/assessment.js @@ -0,0 +1,247 @@ +import express from 'express'; +import pb from '../utils/pocketbaseClient.js'; +import { sendEmail } from '../utils/mailService.js'; +import logger from '../utils/logger.js'; + +const router = express.Router(); + +const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || 'hello@korrith.com,smtp@korrith.com') + .split(',') + .map((address) => address.trim()) + .filter(Boolean); + +router.post('/submit', async (req, res) => { + const { firstName, lastName, email, industry, websites, applications, systems } = req.body; + + // Validate required fields + if (!firstName || !lastName || !email) { + return res.status(400).json({ + error: 'Missing required fields: firstName, lastName, and email are required' + }); + } + + // Validate industry is provided + if (!industry) { + return res.status(400).json({ + error: 'Industry selection is required' + }); + } + + // Log the exact request body being sent to PocketBase + logger.info('Assessment submission received', { + firstName, + lastName, + email, + industry, + websites, + applications, + systems + }); + + // Prepare the data object for PocketBase + const assessmentData = { + firstName, + lastName, + email, + industry, + websites: websites || '', + applications: applications || '', + systems: systems || '' + }; + + logger.info('Sending to PocketBase with data:', assessmentData); + + // Create assessment record in PocketBase + // If validation fails, PocketBase will throw an error with details + const record = await pb.collection('assessments').create(assessmentData, { $autoCancel: false }); + + logger.info('Assessment record created successfully', { id: record.id }); + + // Build client confirmation email body + const clientHtmlBody = ` + + + + + + + +
+
+

Assessment Submission Received

+
+ +
+

Dear ${firstName},

+

Thank you for submitting your assessment. We have received your submission and will review it shortly.

+
+ +
+
Submission Summary
+
+ Industry: + ${industry} +
+ ${websites ? ` +
+ Websites: + ${websites} +
+ ` : ''} + ${applications ? ` +
+ Applications: + ${applications} +
+ ` : ''} + ${systems ? ` +
+ Systems: + ${systems} +
+ ` : ''} +
+ +
+

We will be in touch with you soon regarding your assessment.

+
+ + +
+ + + `; + + // Send confirmation email to client + logger.info('Sending confirmation email to client', { + recipient: email, + subject: 'Assessment Submission Confirmation', + timestamp: new Date().toISOString() + }); + + await sendEmail(email, 'Assessment Submission Confirmation', clientHtmlBody); + + logger.info('Client confirmation email sent successfully', { + recipient: email, + timestamp: new Date().toISOString() + }); + + // Build admin notification email body + const adminHtmlBody = ` + + + + + + + +
+
+

New Assessment Submission

+
+ +
+

A new assessment has been submitted and requires review.

+
+ +
+
Submission Details
+
+ Name: + ${firstName} ${lastName} +
+
+ Email: + ${email} +
+
+ Industry: + ${industry} +
+ ${websites ? ` +
+ Websites: + ${websites} +
+ ` : ''} + ${applications ? ` +
+ Applications: + ${applications} +
+ ` : ''} + ${systems ? ` +
+ Systems: + ${systems} +
+ ` : ''} +
+ Record ID: + ${record.id} +
+
+ Submitted: + ${new Date().toISOString()} +
+
+ +
+

Please review this assessment in the admin panel.

+
+ + +
+ + + `; + + // Send notification email to admin addresses + logger.info('Sending notification email to admin addresses', { + recipients: ADMIN_EMAILS.join(', '), + subject: 'New Assessment Submission', + timestamp: new Date().toISOString() + }); + + await sendEmail(ADMIN_EMAILS, 'New Assessment Submission', adminHtmlBody); + + logger.info('Admin notification email sent successfully', { + recipients: ADMIN_EMAILS.join(', '), + timestamp: new Date().toISOString() + }); + + // Return success response + res.status(201).json({ + success: true, + message: 'Assessment request submitted successfully', + id: record.id + }); +}); + +export default router; \ No newline at end of file diff --git a/apps/api/src/routes/health-check.js b/apps/api/src/routes/health-check.js new file mode 100644 index 0000000..42022e5 --- /dev/null +++ b/apps/api/src/routes/health-check.js @@ -0,0 +1,11 @@ +import express from 'express'; + +const router = express.Router(); + +router.get('/', async (req, res) => { + res.json({ + status: 'ok', + }); +}); + +export default router; \ No newline at end of file diff --git a/apps/api/src/routes/index.js b/apps/api/src/routes/index.js new file mode 100644 index 0000000..9205e6c --- /dev/null +++ b/apps/api/src/routes/index.js @@ -0,0 +1,10 @@ +import express from 'express'; +import healthCheckRoutes from './health-check.js'; +import assessmentRoutes from './assessment.js'; + +export default function routes() { + const router = express.Router(); + router.use('/health-check', healthCheckRoutes); + router.use('/assessment', assessmentRoutes); + return router; +} \ No newline at end of file diff --git a/apps/api/src/utils/integrationConfig.js b/apps/api/src/utils/integrationConfig.js new file mode 100644 index 0000000..7b44af1 --- /dev/null +++ b/apps/api/src/utils/integrationConfig.js @@ -0,0 +1,31 @@ +// Helpers for third-party integrations that need a USER-SUPPLIED secret +// (Stripe, PayPal, Twilio, TMDB, ...). A missing user key is NOT a server +// error: do NOT throw it. errorMiddleware would turn a throw into a 500 the +// user cannot fix, and the site would retry it on every page load. Report +// "not configured yet" instead, so the UI can show a setup state and the user +// can add the key. + +export const INTEGRATION_NOT_CONFIGURED = 'INTEGRATION_NOT_CONFIGURED'; + +// True only when at least one key is listed and every listed env var holds a +// non-empty, non-whitespace value. Fails closed: an unset, blank, or +// whitespace-only key (or no keys at all) reads as "not configured". +export function isIntegrationConfigured(...envKeys) { + return envKeys.length > 0 && envKeys.every((key) => String(process.env[key] ?? '').trim() !== ''); +} + +// Canonical response for a missing user-supplied secret. 503 = the feature is +// unavailable until the key is provided. The INTEGRATION_NOT_CONFIGURED marker +// tells the platform this is an expected setup state, not a bug to auto-fix. +export function respondNotConfigured(res, { integration, envKeys }) { + const keys = Array.isArray(envKeys) ? envKeys : [envKeys]; + + return res.status(503).json({ + error: INTEGRATION_NOT_CONFIGURED, + integration, + envKeys: keys, + message: `${integration} is not set up yet. Add the required key(s) to enable this feature.`, + }); +} + +export default { INTEGRATION_NOT_CONFIGURED, isIntegrationConfigured, respondNotConfigured }; diff --git a/apps/api/src/utils/logger.js b/apps/api/src/utils/logger.js new file mode 100644 index 0000000..5e57cf8 --- /dev/null +++ b/apps/api/src/utils/logger.js @@ -0,0 +1,28 @@ +const logger = { + // Application errors - goes to stdout + error: (...args) => { + console.log('[ERROR]', ...args); + }, + + // Critical system errors - goes to stderr + fatal: (...args) => { + console.error('[FATAL]', ...args); + }, + + info: (...args) => { + console.log('[INFO]', ...args); + }, + + debug: (...args) => { + console.log('[DEBUG]', ...args); + }, + + warn: (...args) => { + console.log('[WARN]', ...args); + } +}; + +export default logger; + +export { logger }; + diff --git a/apps/api/src/utils/mailService.js b/apps/api/src/utils/mailService.js new file mode 100644 index 0000000..c2fff8c --- /dev/null +++ b/apps/api/src/utils/mailService.js @@ -0,0 +1,73 @@ +import 'dotenv/config'; +import nodemailer from 'nodemailer'; +import logger from './logger.js'; + +const FROM_EMAIL = process.env.MAIL_FROM || 'smtp@korrith.com'; +const REPLY_TO_EMAIL = process.env.MAIL_REPLY_TO || 'noreply@korrith.com'; + +const smtpPort = parseInt(process.env.SMTP_PORT, 10) || 587; + +// Initialize nodemailer transporter with SMTP credentials +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: smtpPort, + secure: smtpPort === 465, // SSL on port 465, STARTTLS otherwise (e.g. 587) + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASSWORD, + }, +}); + +/** + * Send email using nodemailer with SMTP + * @param {string|string[]} to - Recipient email address(es) + * @param {string} subject - Email subject + * @param {string} htmlBody - HTML email body + * @returns {Promise} - Mail service response + * @throws {Error} - If email sending fails + */ +async function sendEmail(to, subject, htmlBody) { + const recipients = Array.isArray(to) ? to : [to]; + + logger.info('Attempting to send email via SMTP', { + recipients: recipients.join(', '), + subject, + from: FROM_EMAIL, + timestamp: new Date().toISOString() + }); + + try { + const mailOptions = { + from: FROM_EMAIL, + to: recipients.join(', '), + subject, + html: htmlBody, + replyTo: REPLY_TO_EMAIL, + }; + + const result = await transporter.sendMail(mailOptions); + + logger.info('Email sent successfully via SMTP', { + recipients: recipients.join(', '), + subject, + from: FROM_EMAIL, + messageId: result.messageId, + timestamp: new Date().toISOString() + }); + + return result; + } catch (error) { + logger.error('Email send failed', { + recipients: recipients.join(', '), + subject, + from: FROM_EMAIL, + errorMessage: error.message, + timestamp: new Date().toISOString() + }); + + throw new Error(`SMTP email error: ${error.message}`); + } +} + +export { sendEmail }; +export default sendEmail; \ No newline at end of file diff --git a/apps/api/src/utils/pocketbaseClient.js b/apps/api/src/utils/pocketbaseClient.js new file mode 100644 index 0000000..7f5ea84 --- /dev/null +++ b/apps/api/src/utils/pocketbaseClient.js @@ -0,0 +1,84 @@ +import Pocketbase from 'pocketbase'; +import logger from './logger.js'; + +const POCKETBASE_HOST = `http://localhost:8090`; + +async function waitForHealth({ retries = 10, delayMs = 1000 } = {}) { + for (let i = 1; i <= retries; i++) { + try { + const response = await fetch(`${POCKETBASE_HOST}/api/health`, { method: 'HEAD' }); + + if (response.ok) { + return; + } + } catch { + // PocketBase not reachable yet; retry below + } + + logger.warn(`PocketBase not ready, retrying (${i}/${retries})...`); + + await new Promise((r) => setTimeout(r, delayMs)); + } + + throw new Error(`PocketBase health check failed after ${retries} retries`); +} + +const pocketbaseClient = new Pocketbase(POCKETBASE_HOST); + +pocketbaseClient.autoCancellation(false); + +let authPromise = null; + +pocketbaseClient.beforeSend = async function (url, options) { + if (url.includes('/api/collections/_superusers/auth-with-password')) { + return { url, options }; + } + + if (!pocketbaseClient.authStore.isValid && !authPromise) { + authPromise = pocketbaseClient.collection('_superusers').authWithPassword( + process.env.PB_SUPERUSER_EMAIL, + process.env.PB_SUPERUSER_PASSWORD, + ).finally(() => { + authPromise = null; + }); + } + + if (authPromise) { + await authPromise; + } + + if (pocketbaseClient.authStore.isValid && pocketbaseClient.authStore.token) { + options.headers = options.headers || {}; + options.headers['Authorization'] = pocketbaseClient.authStore.token; + } + + return { url, options }; +}; + +(async () => { + try { + await waitForHealth(); + + if (!pocketbaseClient.authStore.isValid && !authPromise) { + authPromise = pocketbaseClient.collection('_superusers').authWithPassword( + process.env.PB_SUPERUSER_EMAIL, + process.env.PB_SUPERUSER_PASSWORD, + ).finally(() => { + authPromise = null; + }); + } + + if (authPromise) { + await authPromise; + } + + logger.info('PocketBase client initialized successfully'); + } catch (err) { + logger.error('Failed to initialize PocketBase client:', err); + + process.exit(1); + } +})(); + +export default pocketbaseClient; +export { pocketbaseClient }; diff --git a/apps/pocketbase/.dockerignore b/apps/pocketbase/.dockerignore new file mode 100644 index 0000000..7ac9fc6 --- /dev/null +++ b/apps/pocketbase/.dockerignore @@ -0,0 +1,6 @@ +pb_data +pb_backups +pb_snapshots +pocketbase +.pocketbase-sha256 +database-types.d.ts diff --git a/apps/pocketbase/.pocketbase-sha256 b/apps/pocketbase/.pocketbase-sha256 new file mode 100644 index 0000000..c9e50c3 --- /dev/null +++ b/apps/pocketbase/.pocketbase-sha256 @@ -0,0 +1 @@ +66396ed5efec837aa8d2addc009894968a5f4fb7cb2b0ea57db6105cb91b095d \ No newline at end of file diff --git a/apps/pocketbase/.pocketbase-version b/apps/pocketbase/.pocketbase-version new file mode 100644 index 0000000..f57373a --- /dev/null +++ b/apps/pocketbase/.pocketbase-version @@ -0,0 +1 @@ +0.40.4 diff --git a/apps/pocketbase/Dockerfile b/apps/pocketbase/Dockerfile new file mode 100644 index 0000000..caa10a1 --- /dev/null +++ b/apps/pocketbase/Dockerfile @@ -0,0 +1,27 @@ +# PocketBase 0.40.4 — the schema in pb_migrations is applied automatically on +# first boot, and the admin account is seeded from PB_SUPERUSER_EMAIL / +# PB_SUPERUSER_PASSWORD. Data persists in the mounted /pb_data volume. + +FROM alpine:3.20 + +ARG PB_VERSION=0.40.4 +# Docker BuildKit sets TARGETARCH (amd64/arm64) — matches PocketBase release assets. +ARG TARGETARCH + +RUN apk add --no-cache curl ca-certificates unzip \ + && curl -fsSL -o /tmp/pocketbase.zip \ + "https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${TARGETARCH:-amd64}.zip" \ + && unzip /tmp/pocketbase.zip -d /usr/local/bin/ \ + && rm /tmp/pocketbase.zip \ + && chmod +x /usr/local/bin/pocketbase + +WORKDIR /pb + +COPY pb_migrations ./pb_migrations +# All app logic (validation + email) lives in the Express API, so no runtime +# hooks are required here — an empty hooks dir keeps startup clean. +RUN mkdir -p ./pb_hooks /pb_data + +EXPOSE 8090 + +CMD ["pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks", "--encryptionEnv=PB_ENCRYPTION_KEY"] diff --git a/apps/pocketbase/database-types.d.ts b/apps/pocketbase/database-types.d.ts new file mode 100644 index 0000000..764de22 --- /dev/null +++ b/apps/pocketbase/database-types.d.ts @@ -0,0 +1,917 @@ +/** + * This file contains TypeScript definitions for PocketBase JavaScript migrations, + * including operations with collections, records, and the $app instance. + */ + +// ------------------------------------------------------------------- +// Core App Interface (Transaction Context) +// ------------------------------------------------------------------- + +interface App { + /** + * Find a collection by its name or ID + */ + findCollectionByNameOrId(nameOrId: string): Collection; + + /** + * Find an auth record by email + */ + findAuthRecordByEmail(collection: string, email: string): PBRecord; + + /** + * Save a model (Collection, Record, etc.) to the database + */ + save(model: Model): void; + + /** + * Save a model without validation + */ + saveNoValidate(model: Model): void; + + /** + * Delete a model from the database + */ + delete(model: Model): void; + + /** + * Get the app database instance for raw SQL queries + */ + db(): Database; + + /** + * Get app settings + */ + settings(): Settings; + + /** + * Save app settings + */ + save(settings: Settings): void; + + /** + * Execute raw SQL query + */ + newQuery(sql: string): Query; +} + +// ------------------------------------------------------------------- +// Collection Operations +// ------------------------------------------------------------------- + +interface Collection { + /** + * 15 characters string to store as collection ID. + * If not set, it will be auto generated. + */ + id?: string; + + /** + * Unique collection name (used as a table name for the records table). + */ + name: string; + + /** + * Type of the collection. + * If not set, the collection type will be "base" by default. + */ + type?: "base" | "view" | "auth"; + + /** + * List with the collection fields. + * This field is optional and autopopulated for "view" collections based on the viewQuery. + */ + fields?: Array; + + /** + * The collection indexes and unique constraints. + * Note that "view" collections don't support indexes. + */ + indexes?: Array; + + /** + * Marks the collection as "system" to prevent being renamed, deleted or modify its API rules. + */ + system?: boolean; + + /** + * CRUD API rules + */ + listRule?: null | string; + viewRule?: null | string; + createRule?: null | string; + updateRule?: null | string; + deleteRule?: null | string; + + // ------------------------------------------------------- + // view options + // ------------------------------------------------------- + + /** + * Required for view collections + */ + viewQuery?: string; + + // ------------------------------------------------------- + // auth options + // ------------------------------------------------------- + + /** + * API rule that gives admin-like permissions to allow fully managing the auth record(s), + * e.g. changing the password without requiring to enter the old one, directly updating the + * verified state or email, etc. This rule is executed in addition to the createRule and updateRule. + */ + manageRule?: null | string; + + /** + * API rule that could be used to specify additional record constraints applied after record + * authentication and right before returning the auth token response to the client. + * + * For example, to allow only verified users you could set it to "verified = true". + * + * Set it to empty string to allow any Auth collection record to authenticate. + * + * Set it to null to disallow authentication altogether for the collection. + */ + authRule?: null | string; + + /** + * AuthAlert defines options related to the auth alerts on new device login. + */ + authAlert?: { + enabled?: boolean; + emailTemplate?: { + subject: string; + body: string; + }; + }; + + /** + * OAuth2 specifies whether OAuth2 auth is enabled for the collection + * and which OAuth2 providers are allowed. + */ + oauth2?: { + enabled?: boolean; + mappedFields?: { + id?: string; + name?: string; + username?: string; + avatarURL?: string; + }; + providers?: Array<{ + name: string; + clientId: string; + clientSecret: string; + authUrl?: string; + tokenUrl?: string; + userApiUrl?: string; + displayName?: string; + pkce?: null | boolean; + }>; + }; + + /** + * PasswordAuth defines options related to the collection password authentication. + */ + passwordAuth?: { + enabled?: boolean; + identityFields: Array; + }; + + /** + * MFA defines options related to the Multi-factor authentication (MFA). + */ + mfa?: { + enabled?: boolean; + duration: number; + rule?: string; + }; + + /** + * OTP defines options related to the One-time password authentication (OTP). + */ + otp?: { + enabled?: boolean; + duration: number; + length: number; + emailTemplate?: { + subject: string; + body: string; + }; + }; + + /** + * Token configurations. + */ + authToken?: { + duration: number; + secret: string; + }; + passwordResetToken?: { + duration: number; + secret: string; + }; + emailChangeToken?: { + duration: number; + secret: string; + }; + verificationToken?: { + duration: number; + secret: string; + }; + fileToken?: { + duration: number; + secret: string; + }; + + /** + * Default email templates. + */ + verificationTemplate?: { + subject: string; + body: string; + }; + resetPasswordTemplate?: { + subject: string; + body: string; + }; + confirmEmailChangeTemplate?: { + subject: string; + body: string; + }; + + /** + * Add an index to the collection + */ + addIndex( + name: string, + unique: boolean, + columnsExpr: string, + optWhereExpr?: string + ): void; + + /** + * Remove an index from the collection + */ + removeIndex(name: string): void; +} + +// ------------------------------------------------------------------- +// Type Definitions +// ------------------------------------------------------------------- + +/** + * DateTime represents a date-time value + */ +type DateTime = string | Date; + +// ------------------------------------------------------------------- +// Base Field Interface +// ------------------------------------------------------------------- + +/** + * BaseField defines the common properties that all field types share + */ +interface BaseField { + /** + * Name (required) is the unique name of the field. + */ + name: string + /** + * Id is the unique stable field identifier. + * + * It is automatically generated from the name when adding to a collection FieldsList. + */ + id: string + /** + * System prevents the renaming and removal of the field. + */ + system: boolean + /** + * Hidden hides the field from the API response. + */ + hidden: boolean + /** + * Presentable hints the Dashboard UI to use the underlying + * field record value in the relation preview label. + */ + presentable: boolean + /** + * Required will require the field value to be non-empty/non-zero. + */ + required: boolean +} + +// ------------------------------------------------------------------- +// Field Type Definitions +// ------------------------------------------------------------------- + +/** + * AutodateField defines an "autodate" type field, aka. + * field which datetime value could be auto set on record create/update. + * + * This field is usually used for defining timestamp fields like "created" and "updated". + * + * Requires either both or at least one of the OnCreate or OnUpdate options to be set. + */ +interface AutodateField extends BaseField { + /** + * OnCreate auto sets the current datetime as field value on record create. + */ + onCreate: boolean + /** + * OnUpdate auto sets the current datetime as field value on record update. + */ + onUpdate: boolean +} + +/** + * BoolField defines "bool" type field to store a single true/false value. + * + * The respective zero record field value is false. + */ +interface BoolField extends BaseField { + // BoolField has no additional properties beyond BaseField + // The required property from BaseField will require the field value to be always "true" +} + +/** + * DateField defines "date" type field to store a single [types.DateTime] value. + * + * The respective zero record field value is the zero [types.DateTime]. + */ +interface DateField extends BaseField { + /** + * Min specifies the min allowed field value. + * + * Leave it empty to skip the validator. + */ + min: DateTime + /** + * Max specifies the max allowed field value. + * + * Leave it empty to skip the validator. + */ + max: DateTime +} +/** + * EditorField defines "editor" type field to store HTML formatted text. + * + * The respective zero record field value is empty string. + */ +interface EditorField extends BaseField { + /** + * MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1). + * + * If zero, a default limit of ~5MB is applied. + */ + maxSize: number + /** + * ConvertURLs is usually used to instruct the editor whether to + * apply url conversion (eg. stripping the domain name in case the + * urls are using the same domain as the one where the editor is loaded). + * + * (see also https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls) + */ + convertURLs: boolean +} +/** + * EmailField defines "email" type field for storing a single email string address. + * + * The respective zero record field value is empty string. + */ +interface EmailField extends BaseField { + /** + * ExceptDomains will require the email domain to NOT be included in the listed ones. + * + * This validator can be set only if OnlyDomains is empty. + */ + exceptDomains: Array + /** + * OnlyDomains will require the email domain to be included in the listed ones. + * + * This validator can be set only if ExceptDomains is empty. + */ + onlyDomains: Array +} + +/** + * FileField defines "file" type field for managing record file(s). + * + * Only the file name is stored as part of the record value. + * New files (aka. files to upload) are expected to be of *filesytem.File. + * + * If MaxSelect is not set or <= 1, then the field value is expected to be a single record id. + * + * If MaxSelect is > 1, then the field value is expected to be a slice of record ids. + * + * The respective zero record field value is either empty string (single) or empty string slice (multiple). + * + * --- + * + * The following additional setter keys are available: + * + * ``` + * - "fieldName+" - append one or more files to the existing record one. For example: + * + * // []string{"old1.txt", "old2.txt", "new1_ajkvass.txt", "new2_klhfnwd.txt"} + * record.Set("documents+", []*filesystem.File{new1, new2}) + * + * - "+fieldName" - prepend one or more files to the existing record one. For example: + * + * // []string{"new1_ajkvass.txt", "new2_klhfnwd.txt", "old1.txt", "old2.txt",} + * record.Set("+documents", []*filesystem.File{new1, new2}) + * + * - "fieldName-" - subtract/delete one or more files from the existing record one. For example: + * + * // []string{"old2.txt",} + * record.Set("documents-", "old1.txt") + * ``` + */ +interface FileField extends BaseField { + /** + * MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1). + * + * If zero, a default limit of 5MB is applied. + */ + maxSize: number + /** + * MaxSelect specifies the max allowed files. + * + * For multiple files the value must be > 1, otherwise fallbacks to single (default). + */ + maxSelect: number + /** + * MimeTypes specifies an optional list of the allowed file mime types. + * + * Leave it empty to disable the validator. + */ + mimeTypes: Array + /** + * Thumbs specifies an optional list of the supported thumbs for image based files. + * + * Each entry must be in one of the following formats: + * + * ``` + * - WxH (eg. 100x300) - crop to WxH viewbox (from center) + * - WxHt (eg. 100x300t) - crop to WxH viewbox (from top) + * - WxHb (eg. 100x300b) - crop to WxH viewbox (from bottom) + * - WxHf (eg. 100x300f) - fit inside a WxH viewbox (without cropping) + * - 0xH (eg. 0x300) - resize to H height preserving the aspect ratio + * - Wx0 (eg. 100x0) - resize to W width preserving the aspect ratio + * ``` + */ + thumbs: Array + /** + * Protected will require the users to provide a special file token to access the file. + * + * Note that by default all files are publicly accessible. + * + * For the majority of the cases this is fine because by default + * all file names have random part appended to their name which + * need to be known by the user before accessing the file. + */ + protected: boolean +} +/** + * GeoPointField defines "geoPoint" type field for storing latitude and longitude GPS coordinates. + * + * You can set the record field value as [types.GeoPoint], map or serialized json object with lat-lon props. + * The stored value is always converted to [types.GeoPoint]. + * Nil, empty map, empty bytes slice, etc. results in zero [types.GeoPoint]. + * + * Examples of updating a record's GeoPointField value programmatically: + * + * ``` + * record.Set("location", types.GeoPoint{Lat: 123, Lon: 456}) + * record.Set("location", map[string]any{"lat":123, "lon":456}) + * record.Set("location", []byte(`{"lat":123, "lon":456}`) + * ``` + */ +interface GeoPointField extends BaseField { + // GeoPointField has no additional properties beyond BaseField + // The required property from BaseField will require the field coordinates to be non-zero (aka. not "Null Island") +} +/** + * JSONField defines "json" type field for storing any serialized JSON value. + * + * The respective zero record field value is the zero [types.JSONRaw]. + */ +interface JSONField extends BaseField { + /** + * MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1). + * + * If zero, a default limit of 1MB is applied. + */ + maxSize: number +} +/** + * NumberField defines "number" type field for storing numeric (float64) value. + * + * The respective zero record field value is 0. + * + * The following additional setter keys are available: + * + * ``` + * - "fieldName+" - appends to the existing record value. For example: + * record.Set("total+", 5) + * - "fieldName-" - subtracts from the existing record value. For example: + * record.Set("total-", 5) + * ``` + */ +interface NumberField extends BaseField { + /** + * Min specifies the min allowed field value. + * + * Leave it nil to skip the validator. + */ + min?: number + /** + * Max specifies the max allowed field value. + * + * Leave it nil to skip the validator. + */ + max?: number + /** + * OnlyInt will require the field value to be integer. + */ + onlyInt: boolean +} +/** + * PasswordField defines "password" type field for storing bcrypt hashed strings + * (usually used only internally for the "password" auth collection system field). + * + * If you want to set a direct bcrypt hash as record field value you can use the SetRaw method, for example: + * + * ``` + * // generates a bcrypt hash of "123456" and set it as field value + * // (record.GetString("password") returns the plain password until persisted, otherwise empty string) + * record.Set("password", "123456") + * + * // set directly a bcrypt hash of "123456" as field value + * // (record.GetString("password") returns empty string) + * record.SetRaw("password", "$2a$10$.5Elh8fgxypNUWhpUUr/xOa2sZm0VIaE0qWuGGl9otUfobb46T1Pq") + * ``` + * + * The following additional getter keys are available: + * + * ``` + * - "fieldName:hash" - returns the bcrypt hash string of the record field value (if any). For example: + * record.GetString("password:hash") + * ``` + */ +interface PasswordField extends BaseField { + /** + * Pattern specifies an optional regex pattern to match against the field value. + * + * Leave it empty to skip the pattern check. + */ + pattern: string + /** + * Min specifies an optional required field string length. + */ + min: number + /** + * Max specifies an optional required field string length. + * + * If zero, fallback to max 71 bytes. + */ + max: number + /** + * Cost specifies the cost/weight/iteration/etc. bcrypt factor. + * + * If zero, fallback to [bcrypt.DefaultCost]. + * + * If explicitly set, must be between [bcrypt.MinCost] and [bcrypt.MaxCost]. + */ + cost: number +} +/** + * RelationField defines "relation" type field for storing single or + * multiple collection record references. + * + * Requires the CollectionId option to be set. + * + * If MaxSelect is not set or <= 1, then the field value is expected to be a single record id. + * + * If MaxSelect is > 1, then the field value is expected to be a slice of record ids. + * + * The respective zero record field value is either empty string (single) or empty string slice (multiple). + * + * --- + * + * The following additional setter keys are available: + * + * ``` + * - "fieldName+" - append one or more values to the existing record one. For example: + * + * record.Set("categories+", []string{"new1", "new2"}) // []string{"old1", "old2", "new1", "new2"} + * + * - "+fieldName" - prepend one or more values to the existing record one. For example: + * + * record.Set("+categories", []string{"new1", "new2"}) // []string{"new1", "new2", "old1", "old2"} + * + * - "fieldName-" - subtract one or more values from the existing record one. For example: + * + * record.Set("categories-", "old1") // []string{"old2"} + * ``` + */ +interface RelationField extends BaseField { + /** + * CollectionId is the id of the related collection. + */ + collectionId: string + /** + * CascadeDelete indicates whether the root model should be deleted + * in case of delete of all linked relations. + */ + cascadeDelete: boolean + /** + * MinSelect indicates the min number of allowed relation records + * that could be linked to the main model. + * + * No min limit is applied if it is zero or negative value. + */ + minSelect: number + /** + * MaxSelect indicates the max number of allowed relation records + * that could be linked to the main model. + * + * For multiple select the value must be > 1, otherwise fallbacks to single (default). + * + * If MinSelect is set, MaxSelect must be at least >= MinSelect. + */ + maxSelect: number +} +/** + * SelectField defines "select" type field for storing single or + * multiple string values from a predefined list. + * + * Requires the Values option to be set. + * + * If MaxSelect is not set or <= 1, then the field value is expected to be a single Values element. + * + * If MaxSelect is > 1, then the field value is expected to be a subset of Values slice. + * + * The respective zero record field value is either empty string (single) or empty string slice (multiple). + * + * --- + * + * The following additional setter keys are available: + * + * ``` + * - "fieldName+" - append one or more values to the existing record one. For example: + * + * record.Set("roles+", []string{"new1", "new2"}) // []string{"old1", "old2", "new1", "new2"} + * + * - "+fieldName" - prepend one or more values to the existing record one. For example: + * + * record.Set("+roles", []string{"new1", "new2"}) // []string{"new1", "new2", "old1", "old2"} + * + * - "fieldName-" - subtract one or more values from the existing record one. For example: + * + * record.Set("roles-", "old1") // []string{"old2"} + * ``` + */ +interface SelectField extends BaseField { + /** + * Values specifies the list of accepted values. + */ + values: Array + /** + * MaxSelect specifies the max allowed selected values. + * + * For multiple select the value must be > 1, otherwise fallbacks to single (default). + */ + maxSelect: number +} +/** + * TextField defines "text" type field for storing any string value. + * + * The respective zero record field value is empty string. + * + * The following additional setter keys are available: + * + * - "fieldName:autogenerate" - autogenerate field value if AutogeneratePattern is set. For example: + * + * ``` + * record.Set("slug:autogenerate", "") // [random value] + * record.Set("slug:autogenerate", "abc-") // abc-[random value] + * ``` + */ +interface TextField extends BaseField { + /** + * Min specifies the minimum required string characters. + * + * if zero value, no min limit is applied. + */ + min: number + /** + * Max specifies the maximum allowed string characters. + * + * If zero, a default limit of 5000 is applied. + */ + max: number + /** + * Pattern specifies an optional regex pattern to match against the field value. + * + * Leave it empty to skip the pattern check. + */ + pattern: string + /** + * AutogeneratePattern specifies an optional regex pattern that could + * be used to generate random string from it and set it automatically + * on record create if no explicit value is set or when the `:autogenerate` modifier is used. + * + * Note: the generated value still needs to satisfy min, max, pattern (if set) + */ + autogeneratePattern: string + /** + * PrimaryKey will mark the field as primary key. + * + * A single collection can have only 1 field marked as primary key. + */ + primaryKey: boolean +} +/** + * URLField defines "url" type field for storing a single URL string value. + * + * The respective zero record field value is empty string. + */ +interface URLField extends BaseField { + /** + * ExceptDomains will require the URL domain to NOT be included in the listed ones. + * + * This validator can be set only if OnlyDomains is empty. + */ + exceptDomains: Array + /** + * OnlyDomains will require the URL domain to be included in the listed ones. + * + * This validator can be set only if ExceptDomains is empty. + */ + onlyDomains: Array +} + +/** + * Collection field definition + */ +interface Field { + [key: string]: any; +} + +interface FieldsList extends Array { + /** + * GetByName returns a single field by its name. + */ + getByName(fieldName: string): Field + + /** + * RemoveByName removes a single field by its name. + * + * This method does nothing if field with the specified name doesn't exist. + */ + removeByName(fieldName: string): void + + /** +* Add adds one or more fields to the current list. +* +* By default this method will try to REPLACE existing fields with +* the new ones by their id or by their name if the new field doesn't have an explicit id. +* +* If no matching existing field is found, it will APPEND the field to the end of the list. +* +* In all cases, if any of the new fields don't have an explicit id it will auto generate a default one for them +* (the id value doesn't really matter and it is mostly used as a stable identifier in case of a field rename). +*/ + add(...fields: Field[]): void + + /** + * AddAt is the same as Add but insert/move the fields at the specific position. + * + * If pos < 0, then this method acts the same as calling Add. + * + * If pos > FieldsList total items, then the specified fields are inserted/moved at the end of the list. + */ + addAt(pos: number, ...fields: Field[]): void +} + +// ------------------------------------------------------------------- +// Record Operations +// ------------------------------------------------------------------- + +interface PBRecord { + /** + * Record ID + */ + id?: string; + + /** + * Record data + */ + [key: string]: any; + + /** + * Set a field value + */ + set(field: string, value: any): void; + + /** + * Get a field value + */ + get(field: string): any; + + /** + * Check if field exists + */ + has(field: string): boolean; + + /** + * Remove a field + */ + remove(field: string): void; + + /** + * Get all field names + */ + keys(): string[]; + + /** + * Get all field values + */ + values(): any[]; + + /** + * Get record as object + */ + export(): { [key: string]: any }; + + /** + * Import data into record + */ + import(data: { [key: string]: any }): void; + + /** + * Refresh record from database + */ + refresh(): void; + + /** + * Get collection this record belongs to + */ + collection(): Collection; +} + +// ------------------------------------------------------------------- +// Database Operations +// ------------------------------------------------------------------- + +interface Database { + /** + * Create a new query + */ + newQuery(sql: string): Query; + + /** + * Execute a query + */ + execute(): any; +} + +interface Query { + /** + * Execute the query + */ + execute(): any; + + /** + * Bind parameters to the query + */ + bind(...params: any[]): Query; +} + +// ------------------------------------------------------------------- +// Settings +// ------------------------------------------------------------------- + +interface Settings { + /** + * App metadata + */ + meta: { + appName: string; + appURL: string; + }; +} + +// ------------------------------------------------------------------- +// Model defines an interface with common methods that all db models should have. +// ------------------------------------------------------------------- +interface Model { + [key: string]: any; + tableName(): string; + pk(): any; + lastSavedPK(): any; + isNew(): boolean; + markAsNew(): void; + markAsNotNew(): void; +} diff --git a/apps/pocketbase/package.json b/apps/pocketbase/package.json new file mode 100644 index 0000000..70adfe2 --- /dev/null +++ b/apps/pocketbase/package.json @@ -0,0 +1,14 @@ +{ + "name": "pocketbase-app", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "./pocketbase serve --http=0.0.0.0:8090 --encryptionEnv=PB_ENCRYPTION_KEY --hooksWatch=false", + "start": "./pocketbase serve --http=0.0.0.0:8090 --encryptionEnv=PB_ENCRYPTION_KEY --dir=/data --migrationsDir=./pb_migrations --hooksDir=./pb_hooks --hooksWatch=false", + "migrations:up": "./pocketbase migrate up --encryptionEnv=PB_ENCRYPTION_KEY --dir=./pb_data --migrationsDir=./pb_migrations --hooksDir=./pb_hooks --hooksWatch=false", + "migrations:revert": "./pocketbase horizons migrations:revert --encryptionEnv=PB_ENCRYPTION_KEY --dir=./pb_data --migrationsDir=./pb_migrations --hooksDir=./pb_hooks --hooksWatch=false", + "migrations:snapshot": "mkdir -p ./pb_snapshots && echo 'y' | ./pocketbase migrate collections --encryptionEnv=PB_ENCRYPTION_KEY --dir=./pb_data --migrationsDir=./pb_snapshots", + "update": "./pocketbase update --encryptionEnv=PB_ENCRYPTION_KEY --backup=0" + } +} \ No newline at end of file diff --git a/apps/pocketbase/pb_hooks/builder-mailer.pb.js b/apps/pocketbase/pb_hooks/builder-mailer.pb.js new file mode 100644 index 0000000..ef260a0 --- /dev/null +++ b/apps/pocketbase/pb_hooks/builder-mailer.pb.js @@ -0,0 +1,45 @@ + +/// +onMailerSend((e) => { + if (e.app.settings().smtp.enabled) { + return e.next() + } + + const senderAddress = $os.getenv("BUILDER_MAILER_SENDER_ADDRESS"); + const cc = (e.message.cc || []).map((recipient) => recipient.address).filter(Boolean); + const bcc = (e.message.bcc || []).map((recipient) => recipient.address).filter(Boolean); + + const payload = { + "subject": e.message.subject, + "content": { + ...(e.message.html ? { + "html": e.message.html, + } : { + "text": e.message.text, + }), + "type": "plain", + }, + "from": senderAddress, + "fromName": e.message.from?.name, + "replyTo": senderAddress, + "to": e.message.to[0].address, + ...(cc.length ? { "cc": cc } : {}), + ...(bcc.length ? { "bcc": bcc } : {}), + } + + const response = $http.send({ + url: `${$os.getenv("BUILDER_MAILER_API_URL")}/api/v2/email`, + method: "POST", + headers: { + "Authorization": `Bearer ${$os.getenv("BUILDER_MAILER_API_KEY")}`, + "Content-Type": "application/json" + }, + body: JSON.stringify(payload) + }); + + if (response.statusCode !== 200) { + $app.logger().error("Failed to send email", "error", response.json); + + throw new ApiError(500, response.json?.message || 'Failed to send email'); + } +}) diff --git a/apps/pocketbase/pb_hooks/custom-migrations-cmd.pb.js b/apps/pocketbase/pb_hooks/custom-migrations-cmd.pb.js new file mode 100644 index 0000000..5cb1aba --- /dev/null +++ b/apps/pocketbase/pb_hooks/custom-migrations-cmd.pb.js @@ -0,0 +1,133 @@ +/// +/* + - Usage: ./pocketbase horizons migrations:revert migration1.js migration2.js ... - reverts specific migrations by name (not by count like the built-in "down" command) +*/ + +// Create a new "horizons" command group with a "migrations:revert" subcommand +const horizonsCmd = new Command({ + use: "horizons", + short: "Additional migration utilities", +}) + +const exitOnError = (run) => (...args) => { + try { + return run(...args) + } catch (error) { + console.error(error) + + // Command callbacks run in a panic-recovering goroutine, so throwing + // alone no longer propagates a nonzero status to the parent process. + $os.exit(1) + } +} + +horizonsCmd.addCommand(new Command({ + use: "migrations:revert [migration names...]", + short: "Revert specific migrations by name and delete their files", + run: exitOnError((_cmd, args) => { + if (args.length === 0) { + console.log("Usage: horizons migrations:revert [migration2.js] ...") + console.log("No migrations specified to revert.") + return + } + + const migrationsDir = $filepath.join(__hooks, "../pb_migrations") + + const migrationFiles = {} + try { + const files = $os.readDir(migrationsDir) + for (const file of files) { + const name = file.name() + migrationFiles[name] = $filepath.join(migrationsDir, name) + } + } catch (e) { + console.error("Failed to read migrations directory:", e) + return + } + + const appliedMigrations = [] + for (const migrationName of args) { + if (!migrationFiles[migrationName]) { + console.error(`Migration file not found: ${migrationName}`) + continue + } + + try { + const result = arrayOf(new DynamicModel({ file: "" })) + $app.db() + .select("file") + .from("_migrations") + .where($dbx.hashExp({ file: migrationName })) + .limit(1) + .all(result) + + if (result.length === 0) { + console.log(`Migration ${migrationName} is not applied, skipping.`) + $os.remove(migrationFiles[migrationName]); + continue + } + + appliedMigrations.push(migrationName) + } catch (e) { + console.error(`Failed to check migration status for ${migrationName}:`, e) + continue + } + } + + if (appliedMigrations.length === 0) { + console.log("No applied migrations found to revert.") + return + } + + console.log("\nMigrations to revert:") + for (const name of appliedMigrations) { + console.log(`${name}`) + } + + const reverted = [] + + $app.runInTransaction((txApp) => { + for (const migrationName of appliedMigrations) { + const filePath = migrationFiles[migrationName] + + try { + const content = toString($os.readFile(filePath)) + + // Extract the down function from migrate(up, down) call + // We need to execute the file and capture the down function + let downFn = null + + const captureMigrate = (up, down) => { + downFn = down + } + + // Execute the migration file with our custom migrate function since hooks doesn't have access to migrate function + const migrationCode = content.replace(/migrate\s*\(/g, '__captureMigrate(') + const fn = new Function('__captureMigrate', '$app', 'Collection', 'Record', migrationCode) + fn(captureMigrate, txApp, Collection, Record) + + if (!downFn) { + console.log(`Migration ${migrationName} has no down function, just removing from history.`) + } else { + downFn(txApp) + } + + txApp.db().delete("_migrations", $dbx.hashExp({ file: migrationName })).execute() + + reverted.push({ name: migrationName, path: filePath }) + console.log(`✓ Reverted: ${migrationName}`) + } catch (e) { + throw new Error(`failed to revert migration ${migrationName}: ${e}`) + } + } + }) + + for (const migration of reverted) { + $os.remove(migration.path) + } + + console.log(`Successfully reverted ${reverted.length} migration(s).`) + }), +})) + +$app.rootCmd.addCommand(horizonsCmd) diff --git a/apps/pocketbase/pb_hooks/external-dashboard.pb.js b/apps/pocketbase/pb_hooks/external-dashboard.pb.js new file mode 100644 index 0000000..e0e35ed --- /dev/null +++ b/apps/pocketbase/pb_hooks/external-dashboard.pb.js @@ -0,0 +1,27 @@ + +/// + +routerAdd("GET", "/{$}", (e) => { + const fileContent = $os.readFile(`${__hooks}/../.pocketbase-version`) + const contentStr = Array.isArray(fileContent) + ? String.fromCharCode.apply(null, fileContent) + : fileContent + const pbVersion = `v${contentStr.replace(/\n/g, '').trim()}` + const externalUrl = `https://horizons-static-cdn.hostinger.com/pocketbase/__PB_VERSION__/ui/dist/index.html`.replace("__PB_VERSION__", pbVersion) + + try { + const response = $http.send({ + url: externalUrl, + method: "GET", + timeout: 30, + }) + + const htmlContent = Array.isArray(response.body) + ? String.fromCharCode.apply(null, response.body) + : response.body + + return e.html(response.statusCode, htmlContent) + } catch (err) { + throw new NotFoundError("Failed to load dashboard", err) + } +}) diff --git a/apps/pocketbase/pb_hooks/logs-forwarder.pb.js b/apps/pocketbase/pb_hooks/logs-forwarder.pb.js new file mode 100644 index 0000000..1dbe250 --- /dev/null +++ b/apps/pocketbase/pb_hooks/logs-forwarder.pb.js @@ -0,0 +1,64 @@ + +/// + +// Routes every _logs row to stdout/stderr on production +// Forwards every _logs row to the session journal on development and continue writing to the database +onModelCreate((e) => { + const env = $os.getenv("NODE_ENV") + + if (env === "production") { + const write = e.model.level >= 8 ? console.error : console.log + + try { + write(JSON.stringify({ + id: e.model.id, + created: e.model.created.string(), + level: e.model.level, + message: e.model.message, + data: JSON.parse(e.model.data.string()), + })) + } catch (err) { + console.error(`Failed to forward log: ${err}`) + + write(JSON.stringify({ level: e.model.level, message: e.model.message })) + } + + // do not continue writing to the database + return + } + + if (env === "development" || !env) { + try { + const journalPath = $filepath.join(__hooks, "..", "..", "..", "vault", "temp", "SESSION_JOURNAL.md") + + const levels = { "-4": "debug", "0": "info", "4": "warn", "8": "error" } + const level = levels[String(e.model.level)] || `level${e.model.level}` + + // Matches vite-plugin-session-journal's entry shape + const bullet = (key, value) => { + const text = String(value) + const indented = text.includes("\n") ? `\n ${text.replace(/\n/g, "\n ")}` : text + + return `- ${key}: ${indented}\n` + } + + let entry = `## ${e.model.created.string()} pocketbase.${level}\n` + entry += bullet("message", e.model.message) + + const data = e.model.data.string() + if (data && data !== "{}") { + entry += bullet("data", data) + } + entry += "\n" + + $os.mkdirAll($filepath.dir(journalPath), 0o755) + + // $os has no append primitive + $os.cmd("sh", "-c", 'printf "%s" "$1" >> "$2"', "sh", entry, journalPath).run() + } catch (err) { + console.error(`Failed to journal log: ${err}`) + } + } + + e.next() +}, "_logs") diff --git a/apps/pocketbase/pb_hooks/superusers-allow-list.js b/apps/pocketbase/pb_hooks/superusers-allow-list.js new file mode 100644 index 0000000..01ca5b0 --- /dev/null +++ b/apps/pocketbase/pb_hooks/superusers-allow-list.js @@ -0,0 +1,17 @@ +const ALLOWED_IPS = [ + "86.106.20.28", // L2 + "89.116.209.21", // L2 + "86.106.20.24", // L3 + "84.32.173.80", // L3 + "34.141.125.49", // euw3-stage-koalemos + "35.230.138.117", // euw2-prod-apollo + "35.242.131.17", // euw2-prod-apollo + "34.89.86.34", // euw2-prod-apollo + "35.242.132.122", // euw2-prod-apollo + "35.234.116.158", // euw3-stage-koalemos + "34.159.186.242", // euw3-stage-koalemos +] + +module.exports = { + ALLOWED_IPS, +} diff --git a/apps/pocketbase/pb_hooks/superusers.pb.js b/apps/pocketbase/pb_hooks/superusers.pb.js new file mode 100644 index 0000000..3e53c67 --- /dev/null +++ b/apps/pocketbase/pb_hooks/superusers.pb.js @@ -0,0 +1,101 @@ + +/// + +onRecordCreateRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to create superuser`) + } + + e.next() +}, "_superusers") + +onRecordUpdateRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to update superuser`) + } + + e.next() +}, "_superusers") + +onRecordDeleteRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to delete superuser`) + } + + e.next() +}, "_superusers") + +onRecordRequestPasswordResetRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to request password reset for superuser`) + } + + e.next() +}, "_superusers") + +onCollectionViewRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to view collection`) + } + + e.next() +}) + +onCollectionCreateRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to create collection`) + } + + e.next() +}) + +onCollectionUpdateRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to update collection`) + } + + e.next() +}) + +onCollectionDeleteRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to delete collection`) + } + + e.next() +}) + +onCollectionsImportRequest((e) => { + const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`) + const ip = e.realIP() + + if (!ALLOWED_IPS.includes(ip)) { + throw new BadRequestError(`Not allowed to import collections`) + } + + e.next() +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1759383931_initial_app_settings.js b/apps/pocketbase/pb_migrations/1759383931_initial_app_settings.js new file mode 100644 index 0000000..de6ba28 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1759383931_initial_app_settings.js @@ -0,0 +1,20 @@ +/// +migrate((app) => { + let settings = app.settings() + + settings.meta.appName = "f7b2aa49-1077-4692-8b37-dae2a255116c.app-preview.com" + settings.meta.appURL = "https://f7b2aa49-1077-4692-8b37-dae2a255116c.app-preview.com/hcgi/platform" + settings.meta.hideControls = true + + settings.logs.maxDays = 7 + settings.logs.minLevel = 8 + settings.logs.logIP = true + + settings.trustedProxy.headers = [ + "X-Real-IP", + "X-Forwarded-For", + "CF-Connecting-IP", + ] + + app.save(settings) +}) diff --git a/apps/pocketbase/pb_migrations/1764579159_create_superuser.js b/apps/pocketbase/pb_migrations/1764579159_create_superuser.js new file mode 100644 index 0000000..9e1ec86 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1764579159_create_superuser.js @@ -0,0 +1,10 @@ +/// +migrate((app) => { + const superusers = app.findCollectionByNameOrId("_superusers") + const record = new Record(superusers) + + record.set("email", $os.getenv("PB_SUPERUSER_EMAIL")) + record.set("password", $os.getenv("PB_SUPERUSER_PASSWORD")) + + app.save(record) +}) diff --git a/apps/pocketbase/pb_migrations/1769159103_disable_auth_alert_superusers.js b/apps/pocketbase/pb_migrations/1769159103_disable_auth_alert_superusers.js new file mode 100644 index 0000000..cc359b6 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1769159103_disable_auth_alert_superusers.js @@ -0,0 +1,10 @@ +/// +migrate((app) => { + const superusers = app.findCollectionByNameOrId("_superusers") + + superusers.authAlert = { + enabled: false, + } + + app.save(superusers) +}) diff --git a/apps/pocketbase/pb_migrations/1769164585_set_rate_limits.js b/apps/pocketbase/pb_migrations/1769164585_set_rate_limits.js new file mode 100644 index 0000000..a51c55e --- /dev/null +++ b/apps/pocketbase/pb_migrations/1769164585_set_rate_limits.js @@ -0,0 +1,55 @@ +/// +migrate((app) => { + let settings = app.settings() + + settings.rateLimits = { + enabled: true, + rules: [ + // General API limit - 200 requests per 5 minutes + { + label: "/api", + audience: "", + duration: 5 * 60, // 5 minutes + maxRequests: 200 + }, + // Brute force protection for auth attempts - 20 attempts per 5 minutes (guests only) + { + label: "*:auth", + audience: "@guest", // Only for unauthenticated users + duration: 5 * 60, // 15 minutes + maxRequests: 20 + }, + // Password reset - 5 per hour + { + label: "POST /api/collections/users/request-password-reset", + audience: "", + duration: 60 * 60, // 1 hour + maxRequests: 5 + }, + // Email verification - 5 per hour + { + label: "POST /api/collections/users/request-verification", + audience: "", + duration: 60 * 60, // 1 hour + maxRequests: 5 + }, + // Email change - 3 per hour (authenticated only) + { + label: "POST /api/collections/users/request-email-change", + audience: "@auth", + duration: 60 * 60, // 1 hour + maxRequests: 3 + }, + // OTP - 10 per hour + { + label: "POST /api/collections/users/request-otp", + audience: "", + duration: 60 * 60, // 1 hour + maxRequests: 10 + } + ] + } + + app.save(settings) +}) + diff --git a/apps/pocketbase/pb_migrations/1775709407_disable_auth_alert_users.js b/apps/pocketbase/pb_migrations/1775709407_disable_auth_alert_users.js new file mode 100644 index 0000000..349ebe5 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1775709407_disable_auth_alert_users.js @@ -0,0 +1,19 @@ +/// +migrate((app) => { + let usersCollection + try { + usersCollection = app.findCollectionByNameOrId("users") + usersCollection.authAlert = { + enabled: false, + } + + } catch (error) { + if (error.message.includes("no rows in result set")) { + return + } + + throw error + } + + app.save(usersCollection) +}) diff --git a/apps/pocketbase/pb_migrations/1782223262_001_created_assessments.js b/apps/pocketbase/pb_migrations/1782223262_001_created_assessments.js new file mode 100644 index 0000000..6ebb1fd --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782223262_001_created_assessments.js @@ -0,0 +1,159 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "", + "deleteRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text9973129546", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "text7224323192", + "name": "firstName", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "text8927180967", + "name": "lastName", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "email3000958229", + "name": "email", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "email", + "exceptDomains": [], + "onlyDomains": [] + }, + { + "hidden": false, + "id": "json4467574208", + "name": "websites", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "json", + "maxSize": 0 + }, + { + "hidden": false, + "id": "json7565497650", + "name": "apps", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "json", + "maxSize": 0 + }, + { + "hidden": false, + "id": "json2686026609", + "name": "systems", + "presentable": false, + "primaryKey": false, + "required": false, + "system": false, + "type": "json", + "maxSize": 0 + }, + { + "hidden": false, + "id": "text2579574645", + "name": "industry", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "autodate2823430259", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate7741794431", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_3506166606", + "indexes": [], + "listRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'", + "name": "assessments", + "system": false, + "type": "base", + "updateRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'", + "viewRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'" + }); + + try { + return app.save(collection); + } catch (e) { + if (e.message.includes("Collection name must be unique")) { + console.log("Collection already exists, skipping"); + return; + } + throw e; + } +}, (app) => { + try { + const collection = app.findCollectionByNameOrId("pbc_3506166606"); + return app.delete(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226016_002_remove_websites_from_assessments.js b/apps/pocketbase/pb_migrations/1782226016_002_remove_websites_from_assessments.js new file mode 100644 index 0000000..4bcabae --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226016_002_remove_websites_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("websites"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "websites", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226018_002_remove_apps_from_assessments.js b/apps/pocketbase/pb_migrations/1782226018_002_remove_apps_from_assessments.js new file mode 100644 index 0000000..8f32d77 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226018_002_remove_apps_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("apps"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "apps", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226020_002_remove_systems_from_assessments.js b/apps/pocketbase/pb_migrations/1782226020_002_remove_systems_from_assessments.js new file mode 100644 index 0000000..e70359b --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226020_002_remove_systems_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("systems"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "systems", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226037_002_remove_websites_from_assessments.js b/apps/pocketbase/pb_migrations/1782226037_002_remove_websites_from_assessments.js new file mode 100644 index 0000000..4bcabae --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226037_002_remove_websites_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("websites"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "websites", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226039_002_remove_apps_from_assessments.js b/apps/pocketbase/pb_migrations/1782226039_002_remove_apps_from_assessments.js new file mode 100644 index 0000000..8f32d77 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226039_002_remove_apps_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("apps"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "apps", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226041_002_remove_systems_from_assessments.js b/apps/pocketbase/pb_migrations/1782226041_002_remove_systems_from_assessments.js new file mode 100644 index 0000000..e70359b --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226041_002_remove_systems_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("systems"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "systems", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226052_002_add_websites_text_to_assessments.js b/apps/pocketbase/pb_migrations/1782226052_002_add_websites_text_to_assessments.js new file mode 100644 index 0000000..4712bae --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226052_002_add_websites_text_to_assessments.js @@ -0,0 +1,31 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + + const existing = collection.fields.getByName("websites_text"); + if (existing) { + if (existing.type === "text") { + return; // field already exists with correct type, skip + } + collection.fields.removeByName("websites_text"); // exists with wrong type, remove first + } + + collection.fields.add(new TextField({ + name: "websites_text", + required: true + })); + + return app.save(collection); +}, (app) => { + try { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("websites_text"); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226054_002_add_apps_text_to_assessments.js b/apps/pocketbase/pb_migrations/1782226054_002_add_apps_text_to_assessments.js new file mode 100644 index 0000000..c743c3c --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226054_002_add_apps_text_to_assessments.js @@ -0,0 +1,31 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + + const existing = collection.fields.getByName("apps_text"); + if (existing) { + if (existing.type === "text") { + return; // field already exists with correct type, skip + } + collection.fields.removeByName("apps_text"); // exists with wrong type, remove first + } + + collection.fields.add(new TextField({ + name: "apps_text", + required: true + })); + + return app.save(collection); +}, (app) => { + try { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("apps_text"); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226056_002_add_systems_text_to_assessments.js b/apps/pocketbase/pb_migrations/1782226056_002_add_systems_text_to_assessments.js new file mode 100644 index 0000000..06bcebf --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226056_002_add_systems_text_to_assessments.js @@ -0,0 +1,31 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + + const existing = collection.fields.getByName("systems_text"); + if (existing) { + if (existing.type === "text") { + return; // field already exists with correct type, skip + } + collection.fields.removeByName("systems_text"); // exists with wrong type, remove first + } + + collection.fields.add(new TextField({ + name: "systems_text", + required: true + })); + + return app.save(collection); +}, (app) => { + try { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("systems_text"); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226062_003_remove_websites_from_assessments.js b/apps/pocketbase/pb_migrations/1782226062_003_remove_websites_from_assessments.js new file mode 100644 index 0000000..4bcabae --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226062_003_remove_websites_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("websites"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "websites", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226064_003_remove_apps_from_assessments.js b/apps/pocketbase/pb_migrations/1782226064_003_remove_apps_from_assessments.js new file mode 100644 index 0000000..8f32d77 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226064_003_remove_apps_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("apps"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "apps", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226067_003_remove_systems_from_assessments.js b/apps/pocketbase/pb_migrations/1782226067_003_remove_systems_from_assessments.js new file mode 100644 index 0000000..e70359b --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226067_003_remove_systems_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("systems"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "systems", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226447_002_remove_websites_from_assessments.js b/apps/pocketbase/pb_migrations/1782226447_002_remove_websites_from_assessments.js new file mode 100644 index 0000000..4bcabae --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226447_002_remove_websites_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("websites"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "websites", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226448_002_remove_apps_from_assessments.js b/apps/pocketbase/pb_migrations/1782226448_002_remove_apps_from_assessments.js new file mode 100644 index 0000000..8f32d77 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226448_002_remove_apps_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("apps"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "apps", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226450_002_remove_systems_from_assessments.js b/apps/pocketbase/pb_migrations/1782226450_002_remove_systems_from_assessments.js new file mode 100644 index 0000000..e70359b --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226450_002_remove_systems_from_assessments.js @@ -0,0 +1,22 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.removeByName("systems"); + return app.save(collection); +}, (app) => { + try { + + const collection = app.findCollectionByNameOrId("assessments"); + collection.fields.add(new JSONField({ + name: "systems", + required: false + })); + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226462_005_deleted_assessments.js b/apps/pocketbase/pb_migrations/1782226462_005_deleted_assessments.js new file mode 100644 index 0000000..273aa13 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226462_005_deleted_assessments.js @@ -0,0 +1,16 @@ +/// +migrate((app) => { + try { + const collection = app.findCollectionByNameOrId("assessments"); + return app.delete(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping delete"); + return; + } + throw e; + } +}, (app) => { + // Note: The down migration cannot restore data, only the collection structure + // You would need to manually recreate the collection structure here if needed +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226667_001_deleted_assessments.js b/apps/pocketbase/pb_migrations/1782226667_001_deleted_assessments.js new file mode 100644 index 0000000..273aa13 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226667_001_deleted_assessments.js @@ -0,0 +1,16 @@ +/// +migrate((app) => { + try { + const collection = app.findCollectionByNameOrId("assessments"); + return app.delete(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping delete"); + return; + } + throw e; + } +}, (app) => { + // Note: The down migration cannot restore data, only the collection structure + // You would need to manually recreate the collection structure here if needed +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782226669_002_created_assessments.js b/apps/pocketbase/pb_migrations/1782226669_002_created_assessments.js new file mode 100644 index 0000000..29572bb --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782226669_002_created_assessments.js @@ -0,0 +1,170 @@ +/// +migrate((app) => { + const collection = new Collection({ + "createRule": "", + "deleteRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'", + "fields": [ + { + "autogeneratePattern": "[a-z0-9]{15}", + "hidden": false, + "id": "text3930877004", + "max": 15, + "min": 15, + "name": "id", + "pattern": "^[a-z0-9]+$", + "presentable": false, + "primaryKey": true, + "required": true, + "system": true, + "type": "text" + }, + { + "hidden": false, + "id": "text9496940303", + "name": "firstName", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "text6526030650", + "name": "lastName", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "text1504649217", + "name": "email", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "text3766238150", + "name": "industry", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "text0206998537", + "name": "websites", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "text1142803244", + "name": "applications", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "text6766822077", + "name": "systems", + "presentable": false, + "primaryKey": false, + "required": true, + "system": false, + "type": "text", + "autogeneratePattern": "", + "max": 0, + "min": 0, + "pattern": "" + }, + { + "hidden": false, + "id": "autodate1878312857", + "name": "created", + "onCreate": true, + "onUpdate": false, + "presentable": false, + "system": false, + "type": "autodate" + }, + { + "hidden": false, + "id": "autodate5206414872", + "name": "updated", + "onCreate": true, + "onUpdate": true, + "presentable": false, + "system": false, + "type": "autodate" + } + ], + "id": "pbc_6716256996", + "indexes": [], + "listRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'", + "name": "assessments", + "system": false, + "type": "base", + "updateRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'", + "viewRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'" + }); + + try { + return app.save(collection); + } catch (e) { + if (e.message.includes("Collection name must be unique")) { + console.log("Collection already exists, skipping"); + return; + } + throw e; + } +}, (app) => { + try { + const collection = app.findCollectionByNameOrId("pbc_6716256996"); + return app.delete(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782229685_002_update_websites_in_assessments.js b/apps/pocketbase/pb_migrations/1782229685_002_update_websites_in_assessments.js new file mode 100644 index 0000000..2a0fa51 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782229685_002_update_websites_in_assessments.js @@ -0,0 +1,21 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + const field = collection.fields.getByName("websites"); + field.required = false; + return app.save(collection); +}, (app) => { + try { + const collection = app.findCollectionByNameOrId("assessments"); + const field = collection.fields.getByName("websites"); + if (!field) { console.log("Field not found, skipping revert"); return; } + field.required = true; + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection or field not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782229687_002_update_applications_in_assessments.js b/apps/pocketbase/pb_migrations/1782229687_002_update_applications_in_assessments.js new file mode 100644 index 0000000..8c27d32 --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782229687_002_update_applications_in_assessments.js @@ -0,0 +1,21 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + const field = collection.fields.getByName("applications"); + field.required = false; + return app.save(collection); +}, (app) => { + try { + const collection = app.findCollectionByNameOrId("assessments"); + const field = collection.fields.getByName("applications"); + if (!field) { console.log("Field not found, skipping revert"); return; } + field.required = true; + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection or field not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782229689_002_update_systems_in_assessments.js b/apps/pocketbase/pb_migrations/1782229689_002_update_systems_in_assessments.js new file mode 100644 index 0000000..7e7cb2e --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782229689_002_update_systems_in_assessments.js @@ -0,0 +1,21 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + const field = collection.fields.getByName("systems"); + field.required = false; + return app.save(collection); +}, (app) => { + try { + const collection = app.findCollectionByNameOrId("assessments"); + const field = collection.fields.getByName("systems"); + if (!field) { console.log("Field not found, skipping revert"); return; } + field.required = true; + return app.save(collection); + } catch (e) { + if (e.message.includes("no rows in result set")) { + console.log("Collection or field not found, skipping revert"); + return; + } + throw e; + } +}) \ No newline at end of file diff --git a/apps/pocketbase/pb_migrations/1782229696_003_seed_assessments_1_records.js b/apps/pocketbase/pb_migrations/1782229696_003_seed_assessments_1_records.js new file mode 100644 index 0000000..9fa8d2e --- /dev/null +++ b/apps/pocketbase/pb_migrations/1782229696_003_seed_assessments_1_records.js @@ -0,0 +1,32 @@ +/// +migrate((app) => { + const collection = app.findCollectionByNameOrId("assessments"); + + const record0 = new Record(collection); + record0.id = "zlolg0zav7yhepz"; + record0.set("firstName", "John"); + record0.set("lastName", "Doe"); + record0.set("email", "john@example.com"); + record0.set("industry", "Technology"); + try { + app.save(record0); + } catch (e) { + if (e.message.includes("Value must be unique")) { + console.log("Record with unique value already exists, skipping"); + } else { + throw e; + } + } +}, (app) => { + const seededRecordIds = ["zlolg0zav7yhepz"]; + for (const seededRecordId of seededRecordIds) { + try { + app.delete(app.findRecordById("assessments", seededRecordId)); + } catch (error) { + if (error.message.includes("no rows in result set")) { + continue; + } + throw error; + } + } +}) \ No newline at end of file diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..a5358a8 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,21 @@ +# Frontend — build the Vite app, then serve it with nginx. +# Build context must be the REPO ROOT (npm workspaces). + +FROM node:22-alpine AS build +WORKDIR /app + +# Install workspace dependencies first for better layer caching. +COPY package.json package-lock.json ./ +COPY apps/web/package.json ./apps/web/package.json +RUN npm ci --workspace apps/web + +# Build the static site (outputs to /app/dist/apps/web). +COPY apps/web ./apps/web +WORKDIR /app/apps/web +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=build /app/dist/apps/web /usr/share/nginx/html +COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..5a55abf --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} \ No newline at end of file diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..3ffacac --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,61 @@ +import importPlugin from 'eslint-plugin-import'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import unicodeEscapePlugin from './eslint.unicode-escapes-plugin.mjs'; + +export default [ + { ignores: ['node_modules/**', 'dist/**', 'build/**', 'vite.config.js'] }, + { + files: ['**/*.js', '**/*.jsx'], + plugins: { react, 'react-hooks': reactHooks, 'import': importPlugin }, + languageOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + parserOptions: { ecmaFeatures: { jsx: true } }, + globals: { ...globals.browser, React: 'readonly', Intl: 'readonly' }, + }, + settings: { + 'react': { version: 'detect' }, + 'import/extensions': ['.js', '.jsx'], + 'import/resolver': { + node: { extensions: ['.js', '.jsx'] }, + alias: { map: [['@', './src']], extensions: ['.js', '.jsx'] }, + }, + }, + rules: { + ...react.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + ...importPlugin.flatConfigs.recommended.rules, + + // Non-critical rules - disabled since code works fine without them + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + 'react/display-name': 'off', // Non-critical, component works without displayName + 'react/jsx-uses-react': 'off', // Not needed in React 17+, non-critical + 'react/react-in-jsx-scope': 'off', // Not needed in React 17+, non-critical + 'react/jsx-uses-vars': 'off', // Non-critical, code works fine + 'react/jsx-no-comment-textnodes': 'off', // Non-critical, comments could be visible if put inside the JSX, most cases are just rendering text like '///' + + 'no-unused-vars': 'off', // Non-critical, code works fine with unused vars + 'import/no-named-as-default': 'off', // Can cause runtime import errors, usually fine to leave as is + 'import/no-named-as-default-member': 'off', // Can cause runtime import errors + + // Critical rules that prevent runtime errors + 'no-undef': 'error', // Undefined variables cause runtime errors + 'no-empty': ['error', { allowEmptyCatch: true }], // Empty blocks often signal a bug (e.g. missing body) + + // Override recommended import rules for stricter checking + 'import/no-self-import': 'error', // Extremely fast rule, breaking results in infinite loop/bundling error + + // Disable expensive rules for performance + 'import/no-cycle': 'off', // AI rarely makes this error, and the rule is very slow to run + }, + }, + { + files: ['**/*.jsx'], + plugins: { horizons: unicodeEscapePlugin }, + rules: { 'horizons/no-unicode-escapes-in-jsx': 'warn' }, + }, + { files: ['tools/**/*.js', 'tailwind.config.js'], languageOptions: { globals: globals.node } }, +]; diff --git a/apps/web/eslint.unicode-escapes-formatter.mjs b/apps/web/eslint.unicode-escapes-formatter.mjs new file mode 100644 index 0000000..5e9cb92 --- /dev/null +++ b/apps/web/eslint.unicode-escapes-formatter.mjs @@ -0,0 +1,61 @@ +const RULE_ID = 'horizons/no-unicode-escapes-in-jsx'; +const MAX_DETAILED_FINDINGS = 25; +const ESCAPE_FROM_MESSAGE_PATTERN = /^Literal Unicode escape "(\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\}))"/; +const GUIDANCE = 'Unicode escapes in JSX text or quoted attributes may be displayed verbatim to website visitors. Replace every escape in affected JSX in place with its intended literal character (for example, use "ç", not "\\u00e7"). Do not move the text into variables, objects, or JSX expressions, and do not wrap escapes in JSX braces ({}).'; + +function selectFindingsAcrossFiles(findings) { + const findingsByFile = new Map(); + for (const finding of findings) { + const fileFindings = findingsByFile.get(finding.filePath) ?? []; + fileFindings.push(finding); + findingsByFile.set(finding.filePath, fileFindings); + } + + const shown = []; + for (let findingIndex = 0; shown.length < MAX_DETAILED_FINDINGS; findingIndex++) { + let addedFinding = false; + + for (const fileFindings of findingsByFile.values()) { + const finding = fileFindings[findingIndex]; + if (finding) { + shown.push(finding); + addedFinding = true; + } + + if (shown.length === MAX_DETAILED_FINDINGS) { + break; + } + } + + if (!addedFinding) { + break; + } + } + + return shown; +} + +export default function format(results) { + const findings = results.flatMap(result => + result.messages + .filter(message => message.ruleId === RULE_ID) + .map(message => ({ filePath: result.filePath, ...message })), + ); + + if (findings.length === 0) { + return ''; + } + + const shown = selectFindingsAcrossFiles(findings); + const lines = [ + GUIDANCE, + ...shown.map((finding) => { + const escape = finding.message.match(ESCAPE_FROM_MESSAGE_PATTERN)?.[1] ?? 'Unicode escape'; + return `${finding.filePath}:${finding.line}:${finding.column} warning ${escape} ${RULE_ID}`; + }), + ]; + const omitted = findings.length - shown.length; + lines.push(`Found ${findings.length} Unicode escapes; ${omitted} additional findings omitted. Replace all escapes in affected JSX. [horizons-unicode-escapes] total=${findings.length} shown=${shown.length} omitted=${omitted}`); + + return `${lines.join('\n')}\n`; +} diff --git a/apps/web/eslint.unicode-escapes-plugin.mjs b/apps/web/eslint.unicode-escapes-plugin.mjs new file mode 100644 index 0000000..e0236b9 --- /dev/null +++ b/apps/web/eslint.unicode-escapes-plugin.mjs @@ -0,0 +1,64 @@ +const CODE_LIKE_ELEMENTS = new Set(['code', 'pre', 'kbd', 'samp']); +const UNICODE_ESCAPE_PATTERN = /\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\})/g; + +function isInsideCodeLikeElement(node) { + for (let parent = node.parent; parent; parent = parent.parent) { + if (parent.type !== 'JSXElement') { + continue; + } + + const name = parent.openingElement.name; + if (name.type === 'JSXIdentifier' && CODE_LIKE_ELEMENTS.has(name.name)) { + return true; + } + } + + return false; +} + +export default { + rules: { + 'no-unicode-escapes-in-jsx': { + meta: { + type: 'problem', + docs: { + description: 'Warn when Unicode escapes in JSX text or quoted attributes would be shown verbatim to website visitors instead of rendering the intended character', + }, + schema: [], + messages: { + found: 'Literal Unicode escape "{{escape}}" in JSX text or a quoted attribute may be displayed verbatim to users. Replace only the escape in place with the intended literal character (for example, use "ç", not "\\u00e7"). Do not move the text into variables, objects, or JSX expressions, and do not wrap the escape in JSX braces ({}).', + }, + }, + create(context) { + function check(node) { + if (isInsideCodeLikeElement(node)) { + return; + } + + const text = context.sourceCode.getText(node); + for (const match of text.matchAll(UNICODE_ESCAPE_PATTERN)) { + const startIndex = node.range[0] + match.index; + context.report({ + node, + loc: { + start: context.sourceCode.getLocFromIndex(startIndex), + end: context.sourceCode.getLocFromIndex(startIndex + match[0].length), + }, + messageId: 'found', + data: { escape: match[0] }, + }); + } + } + + return { + JSXText: check, + JSXAttribute(node) { + if (node.value?.type === 'Literal') { + check(node.value); + } + }, + }; + }, + }, + }, +}; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..b91d33b --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,52 @@ + + + + + + + + + + + + + Korrith - Secure Infrastructure. Quantum Future. + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/apps/web/jsconfig.json b/apps/web/jsconfig.json new file mode 100644 index 0000000..5a6e281 --- /dev/null +++ b/apps/web/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf new file mode 100644 index 0000000..59396d5 --- /dev/null +++ b/apps/web/nginx.conf @@ -0,0 +1,55 @@ +# Korrith frontend — static SPA + same-origin reverse proxies. +# The frontend calls the API and PocketBase on relative paths, so they must +# be reachable on the same origin as the site: +# /hcgi/api/* -> Express API +# /hcgi/platform/* -> PocketBase (API + admin UI) + +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 20m; + + gzip on; + gzip_min_length 1024; + gzip_types text/css application/javascript application/json image/svg+xml font/woff2; + + # Express API + location /hcgi/api/ { + proxy_pass http://api:3001/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # PocketBase + location /hcgi/platform/ { + proxy_pass http://pocketbase:8090/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # Server-sent events (realtime subscriptions) + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + } + + # Hashed build assets — safe to cache aggressively + location /assets/ { + expires 30d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # SPA fallback — client-side routing + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..fa6360a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,87 @@ +{ + "name": "web", + "type": "module", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "vite --host :: --port 3000", + "build": "node tools/generate-llms.js || true && vite build --outDir ../../dist/apps/web", + "start": "vite preview --outDir ../../dist/apps/web --host :: --port 3000", + "lint": "eslint . --quiet; lint_status=$?; eslint \"src/**/*.jsx\" --config eslint.config.mjs --format ./eslint.unicode-escapes-formatter.mjs --no-error-on-unmatched-pattern; exit $lint_status", + "lint:warn": "eslint ." + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@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-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "^11.15.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.469.0", + "next-themes": "^0.4.6", + "pocketbase": "^0.27.0", + "react": "^18.3.1", + "react-day-picker": "^9.14.0", + "react-dom": "^18.3.1", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4.7.2", + "react-router-dom": "^7.17.0", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@babel/generator": "^7.26.3", + "@babel/parser": "^7.26.3", + "@babel/traverse": "^7.26.4", + "@babel/types": "^7.26.3", + "@types/node": "^20.17.10", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.2.0", + "autoprefixer": "^10.4.20", + "globals": "^15.14.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "terser": "^5.37.0", + "vite": "^7.3.1", + "eslint": "^9.39.4", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-import": "^2.32.0", + "eslint-import-resolver-alias": "^1.1.2" + } +} \ No newline at end of file diff --git a/apps/web/plugins/session-journal/session-journal-client.js b/apps/web/plugins/session-journal/session-journal-client.js new file mode 100644 index 0000000..9c866cb --- /dev/null +++ b/apps/web/plugins/session-journal/session-journal-client.js @@ -0,0 +1,471 @@ +const EVENT = 'session-journal:event'; +const MAX_TEXT = 2000; +const MAX_BATCH = 50; +const FLUSH_INTERVAL_MS = 1000; + +let queue = []; +let flushTimer = null; +let connected = false; + +function flushQueue() { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (!queue.length || !import.meta.hot || !connected) { + return; + } + const batch = queue; + queue = []; + try { + import.meta.hot.send(EVENT, { batch }); + } catch {} +} + +function send(kind, data) { + if (!import.meta.hot) { + return; + } + queue.push({ kind, t: Date.now(), ...data }); + if (connected && queue.length >= MAX_BATCH) { + flushQueue(); + } else if (!flushTimer) { + flushTimer = setTimeout(flushQueue, FLUSH_INTERVAL_MS); + } +} + +if (import.meta.hot) { + import.meta.hot.on('vite:ws:connect', () => { + connected = true; + flushQueue(); + }); + import.meta.hot.on('vite:ws:disconnect', () => { + connected = false; + }); +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + flushQueue(); + } +}); +window.addEventListener('pagehide', flushQueue); + +function truncate(value) { + if (value == null) { + return ''; + } + const s = String(value); + return s.length > MAX_TEXT ? s.slice(0, MAX_TEXT) + '...' : s; +} + +function isSensitiveName(name) { + return /password|secret|token|apikey|key|auth|credential|cookie/i.test(String(name || '')); +} + +function sanitizeUrl(value) { + try { + const url = new URL(String(value), location.href); + for (const key of [...url.searchParams.keys()]) { + if (isSensitiveName(key)) { + url.searchParams.set(key, '[redacted]'); + } + } + return truncate(url.toString()); + } catch { + return truncate(value); + } +} + +function summarizeValue(name, value) { + if (isSensitiveName(name)) { + const length = value == null ? 0 : String(value).length; + return '[redacted:length=' + length + ']'; + } + if (Array.isArray(value)) { + return value.map((item) => summarizeValue(name, item)); + } + if (value && typeof value === 'object') { + return summarizeObject(value); + } + return truncate(value); +} + +function summarizeObject(value) { + const out = {}; + for (const [key, val] of Object.entries(value || {})) { + out[key] = summarizeValue(key, val); + } + return out; +} + +function summarizeRequestBody(body) { + if (body == null) { + return null; + } + if (body instanceof FormData) { + const out = {}; + for (const [key, val] of body.entries()) { + out[key] = val instanceof File + ? { fileName: truncate(val.name), type: val.type || null, size: val.size } + : summarizeValue(key, val); + } + return out; + } + if (body instanceof URLSearchParams) { + const out = {}; + for (const [key, val] of body.entries()) out[key] = summarizeValue(key, val); + return out; + } + if (typeof body === 'string') { + try { + return summarizeObject(JSON.parse(body)); + } catch { + return truncate(body); + } + } + if (body && typeof body === 'object' && body.constructor === Object) { + return summarizeObject(body); + } + return '[' + Object.prototype.toString.call(body).slice(8, -1) + ' body]'; +} + +function isSensitive(el) { + if (!el) { + return false; + } + if (el.type === 'password') { + return true; + } + return isSensitiveName(el.name || el.id || el.getAttribute('autocomplete') || ''); +} + +function compactText(value) { + return truncate(String(value || '').replace(/\s+/g, ' ').trim()); +} + +function labelFromLabelledBy(el) { + const ids = (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean); + if (!ids.length) { + return ''; + } + const parts = ids + .map((id) => document.getElementById(id)) + .filter(Boolean) + .map((node) => compactText(node.textContent)); + return parts.filter(Boolean).join(' '); +} + +function labelFromForAttr(el) { + if (!el.id) { + return ''; + } + try { + const escapedId = window.CSS && CSS.escape ? CSS.escape(el.id) : el.id; + const labelEl = document.querySelector('label[for="' + escapedId + '"]'); + return labelEl ? compactText(labelEl.textContent) : ''; + } catch { + return ''; + } +} + +function labelFromWrappingLabel(el) { + let cur = el.parentElement; + for (let i = 0; i < 4 && cur; i++) { + if (cur.tagName === 'LABEL') { + const clone = cur.cloneNode(true); + for (const inner of clone.querySelectorAll('input, textarea, select')) { + inner.remove(); + } + return compactText(clone.textContent); + } + cur = cur.parentElement; + } + return ''; +} + +function fieldLabel(el) { + if (!el || el.nodeType !== 1) { + return null; + } + const sources = [ + el.getAttribute('name'), + labelFromLabelledBy(el), + el.getAttribute('aria-label'), + labelFromForAttr(el), + labelFromWrappingLabel(el), + el.id, + el.getAttribute('placeholder'), + ]; + for (const candidate of sources) { + const trimmed = candidate ? String(candidate).trim() : ''; + if (trimmed) { + return compactText(trimmed); + } + } + const type = (el.getAttribute('type') || el.tagName || '').toLowerCase(); + return type ? '[' + type + ']' : null; +} + +function fieldValue(el) { + if (!el || !('value' in el)) { + return { value: '', length: 0, redacted: false }; + } + const raw = el.value == null ? '' : String(el.value); + if (isSensitive(el)) { + return { value: '[redacted:length=' + raw.length + ']', length: raw.length, redacted: true }; + } + return { value: truncate(raw), length: raw.length, redacted: false }; +} + +function describeElement(el) { + if (!el || el.nodeType !== 1) { + return null; + } + const tag = el.tagName.toLowerCase(); + const role = el.getAttribute('role') || null; + const ariaLabel = el.getAttribute('aria-label') || null; + const name = el.getAttribute('name') || null; + const type = el.getAttribute('type') || null; + const id = el.id || null; + const placeholder = el.getAttribute('placeholder') || null; + const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName); + const valueInfo = isInput ? fieldValue(el) : null; + const text = isSensitive(el) ? '' : truncate(el.textContent || ''); + return { + tag, + role, + ariaLabel, + name, + type, + id, + placeholder, + label: isInput ? fieldLabel(el) : null, + value: valueInfo ? valueInfo.value : null, + valueLength: valueInfo ? valueInfo.length : 0, + text, + }; +} + +function findInteractive(el) { + let cur = el; + for (let i = 0; i < 6 && cur; i++) { + if (cur.nodeType !== 1) { cur = cur.parentNode; continue; } + const tag = cur.tagName; + if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' || + tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'LABEL' || + cur.hasAttribute('role') || cur.hasAttribute('onclick')) { + return cur; + } + cur = cur.parentNode; + } + return el; +} + +document.addEventListener('click', (e) => { + send('click', { element: describeElement(findInteractive(e.target)) }); +}, true); + +document.addEventListener('submit', (e) => { + const form = e.target; + const fields = []; + if (form && form.elements) { + for (const el of form.elements) { + const label = fieldLabel(el); + if (!label || !('value' in el)) { + continue; + } + const v = fieldValue(el); + fields.push({ + label, + type: el.type || el.tagName.toLowerCase(), + value: v.value, + length: v.length, + redacted: v.redacted, + }); + } + } + send('submit', { formId: form.id || null, action: form.action || null, fields }); +}, true); + +document.addEventListener('focus', (e) => { + const target = e.target; + if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) { + return; + } + send('focus', { element: describeElement(target) }); +}, true); + +document.addEventListener('change', (e) => { + const target = e.target; + if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) { + return; + } + if (target.type === 'file') { + return; + } + send('change', { element: describeElement(target) }); +}, true); + +document.addEventListener('blur', (e) => { + const target = e.target; + if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) { + return; + } + if (!('value' in target) || !target.value) { + return; + } + send('blur', { element: describeElement(target) }); +}, true); + +const nativeFetch = window.fetch.bind(window); +window.fetch = async (input, init = {}) => { + const started = performance.now(); + const url = typeof input === 'string' ? input : input?.url; + const method = (init?.method || input?.method || 'GET').toUpperCase(); + const requestBody = summarizeRequestBody(init?.body); + try { + const response = await nativeFetch(input, init); + const requestUrl = url || response.url; + if (!response.ok) { + let body = ''; + try { body = await response.clone().text(); } catch {} + send('network.error', { + method, + url: sanitizeUrl(requestUrl), + status: response.status, + statusText: response.statusText, + requestBody, + response: truncate(body), + durationMs: Math.round(performance.now() - started), + }); + } + return response; + } catch (err) { + send('network.error', { + method, + url: sanitizeUrl(url || ''), + requestBody, + message: truncate(err && err.message ? err.message : String(err)), + durationMs: Math.round(performance.now() - started), + }); + throw err; + } +}; + +const NativeXMLHttpRequest = window.XMLHttpRequest; +window.XMLHttpRequest = function SessionJournalXMLHttpRequest() { + const xhr = new NativeXMLHttpRequest(); + let method = 'GET'; + let url = ''; + let requestBody = null; + let started = 0; + + const originalOpen = xhr.open; + xhr.open = function (nextMethod, nextUrl, ...rest) { + method = String(nextMethod || 'GET').toUpperCase(); + url = String(nextUrl || ''); + return originalOpen.call(xhr, nextMethod, nextUrl, ...rest); + }; + + const originalSend = xhr.send; + xhr.send = function (body) { + started = performance.now(); + requestBody = summarizeRequestBody(body); + return originalSend.call(xhr, body); + }; + + xhr.addEventListener('loadend', () => { + if (xhr.status >= 400 || xhr.status === 0) { + let response = ''; + try { response = xhr.responseText || ''; } catch {} + send('network.error', { + transport: 'xhr', + method, + url: sanitizeUrl(url), + status: xhr.status, + statusText: xhr.statusText, + requestBody, + response: truncate(response), + durationMs: Math.round(performance.now() - started), + }); + } + }); + + return xhr; +}; +window.XMLHttpRequest.prototype = NativeXMLHttpRequest.prototype; + +send('load', { url: location.href, title: document.title }); + +const origPush = history.pushState; +history.pushState = function (...args) { + const result = origPush.apply(this, args); + send('navigate', { url: location.href, via: 'pushState' }); + return result; +}; +const origReplace = history.replaceState; +history.replaceState = function (...args) { + const result = origReplace.apply(this, args); + send('navigate', { url: location.href, via: 'replaceState' }); + return result; +}; +window.addEventListener('popstate', () => { + send('navigate', { url: location.href, via: 'popstate' }); +}); + +for (const level of ['error', 'warn']) { + const original = console[level]; + console[level] = (...args) => { + try { + const text = args.map((arg) => { + if (arg instanceof Error) { + return arg.stack || arg.message; + } + if (typeof arg === 'object') { + try { return JSON.stringify(arg); } catch { return String(arg); } + } + return String(arg); + }).join(' '); + send('console.' + level, { text: truncate(text) }); + } catch {} + original.apply(console, args); + }; +} + +window.addEventListener('error', (e) => { + send('window.error', { + message: truncate(e.message), + source: e.filename || null, + line: e.lineno || null, + col: e.colno || null, + stack: e.error && e.error.stack ? truncate(e.error.stack) : null, + }); +}); + +window.addEventListener('unhandledrejection', (e) => { + const reason = e.reason; + send('unhandledrejection', { + message: truncate(reason && reason.message ? reason.message : String(reason)), + stack: reason && reason.stack ? truncate(reason.stack) : null, + }); +}); + +const root = document.getElementById('root'); +if (root) { + let blankTimer = null; + const check = () => { + if (root.children.length === 0) { + if (!blankTimer) { + blankTimer = setTimeout(() => { + send('root.empty', { url: location.href }); + blankTimer = null; + }, 300); + } + } else if (blankTimer) { + clearTimeout(blankTimer); + blankTimer = null; + } + }; + new MutationObserver(check).observe(root, { childList: true }); +} diff --git a/apps/web/plugins/session-journal/vite-plugin-session-journal.js b/apps/web/plugins/session-journal/vite-plugin-session-journal.js new file mode 100644 index 0000000..4f3a194 --- /dev/null +++ b/apps/web/plugins/session-journal/vite-plugin-session-journal.js @@ -0,0 +1,103 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const VIRTUAL_ID = 'virtual:session-journal-client'; +const RESOLVED_ID = '\0' + VIRTUAL_ID; +const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url)); +const CLIENT_PATH = path.resolve(PLUGIN_DIR, 'session-journal-client.js'); +const LOG_PATH = path.resolve( + PLUGIN_DIR, + '..', + '..', + '..', + '..', + 'vault', + 'temp', + 'SESSION_JOURNAL.md', +); +const MAX_BYTES = 512 * 1024; + +function formatTime(ts) { + const d = new Date(ts || Date.now()); + return d.toISOString().replace('T', ' ').slice(0, 23) + 'Z'; +} + +function formatEntry(event) { + const { kind, t, ...rest } = event || {}; + const head = `## ${formatTime(t)} ${kind || 'unknown'}`; + const lines = [head]; + for (const [key, value] of Object.entries(rest)) { + if (value == null || value === '') { + continue; + } + if (typeof value === 'object') { + try { + lines.push(`- ${key}: ${JSON.stringify(value)}`); + } catch { + lines.push(`- ${key}: [unserializable]`); + } + } else { + const text = String(value); + lines.push(`- ${key}: ${text.includes('\n') ? '\n ' + text.replace(/\n/g, '\n ') : text}`); + } + } + return lines.join('\n') + '\n\n'; +} + +async function appendWithRotation(text) { + try { + await fs.mkdir(path.dirname(LOG_PATH), { recursive: true }); + await fs.appendFile(LOG_PATH, text); + const { size } = await fs.stat(LOG_PATH); + if (size > MAX_BYTES) { + const content = await fs.readFile(LOG_PATH, 'utf8'); + const keep = content.slice(-Math.floor(MAX_BYTES / 2)); + await fs.writeFile( + LOG_PATH, + '# SESSION_JOURNAL.md (rotated - earlier entries trimmed)\n\n' + keep, + ); + } + } catch { + // The journal should never break the app dev server. + } +} + +export default function sessionJournalPlugin() { + return { + name: 'session-journal', + apply: 'serve', + + resolveId(id) { + if (id === VIRTUAL_ID) { + return RESOLVED_ID; + } + }, + + load(id) { + if (id === RESOLVED_ID) { + return fs.readFile(CLIENT_PATH, 'utf-8'); + } + }, + + transformIndexHtml(html) { + return html.replace( + '', + ` \n`, + ); + }, + + configureServer(server) { + server.ws.on('session-journal:event', (payload) => { + const events = Array.isArray(payload?.batch) ? payload.batch : [payload]; + let text = ''; + for (const event of events) { + text += formatEntry(event); + } + if (text) { + void appendWithRotation(text); + } + }); + }, + }; +} diff --git a/apps/web/plugins/site-pages/site-pages-script.js b/apps/web/plugins/site-pages/site-pages-script.js new file mode 100644 index 0000000..da70d52 --- /dev/null +++ b/apps/web/plugins/site-pages/site-pages-script.js @@ -0,0 +1,48 @@ +const SITE_PAGES_ENDPOINT = '/__horizons/site-pages'; + +const OUTGOING_SITE_PAGES_MESSAGE = 'sitePages'; +const INCOMING_REQUEST_SITE_PAGES_MESSAGE = 'request-site-pages'; + +const ALLOWED_PARENT_ORIGINS = [ + 'https://horizons.hostinger.com', + 'https://horizons.hostinger.dev', + 'https://horizons-frontend-local.hostinger.dev', + 'http://localhost:4000', +]; + +function postSitePages(pages) { + let parentOrigin = window.location.ancestorOrigins?.[0]; + if (!parentOrigin && document.referrer) { + try { + parentOrigin = new URL(document.referrer).origin; + } catch {} + } + if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) { + window.parent.postMessage({ type: OUTGOING_SITE_PAGES_MESSAGE, payload: { pages } }, parentOrigin); + } +} + +async function sendSitePagesToParent() { + if (window.self === window.top) { + return; + } + + try { + const response = await fetch(SITE_PAGES_ENDPOINT); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + postSitePages(await response.json()); + } catch (error) { + console.error('[site-pages] Failed to send site pages to parent:', error); + } +} + +if (window.self !== window.top) { + window.addEventListener('load', sendSitePagesToParent); + window.addEventListener('message', (event) => { + if (event.data?.type === INCOMING_REQUEST_SITE_PAGES_MESSAGE) { + sendSitePagesToParent(); + } + }); +} diff --git a/apps/web/plugins/site-pages/site-pages-server.js b/apps/web/plugins/site-pages/site-pages-server.js new file mode 100644 index 0000000..4ea964b --- /dev/null +++ b/apps/web/plugins/site-pages/site-pages-server.js @@ -0,0 +1,323 @@ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import traverseBabel from '@babel/traverse'; +import * as t from '@babel/types'; + +const ECOMMERCE_API_RELATIVE_PATH = '../../src/api/EcommerceApi.js'; + +const HOME_ROUTE_LABEL = 'Main'; +const PRODUCT_PAGE_FETCH_LIMIT = 100; +const MAX_PRODUCT_PAGES = 50; + +/** + * Resolves the tag name of a JSX element, unwrapping member expressions (e.g. `Foo.Bar` → `Bar`). + * + * @param {import('@babel/types').JSXOpeningElement['name']} nameNode + * @returns {string|null} + */ +function getJsxElementName(nameNode) { + if (t.isJSXIdentifier(nameNode)) { + return nameNode.name; + } + if (t.isJSXMemberExpression(nameNode) && t.isJSXIdentifier(nameNode.property)) { + return nameNode.property.name; + } + return null; +} + +/** + * Reads a string literal from an attribute value, including one wrapped in a JSX expression container. + * + * @param {import('@babel/types').Node|null|undefined} value + * @returns {string|null} + */ +function getStringLiteral(value) { + if (!value) return null; + if (t.isStringLiteral(value)) return value.value; + if (t.isJSXExpressionContainer(value) && t.isStringLiteral(value.expression)) { + return value.expression.value; + } + return null; +} + +/** + * Reads a JSX element from an attribute value, including one wrapped in a JSX expression container. + * + * @param {import('@babel/types').Node|null|undefined} value + * @returns {import('@babel/types').JSXElement|null} + */ +function getJsxElement(value) { + if (t.isJSXElement(value)) return value; + if (t.isJSXExpressionContainer(value) && t.isJSXElement(value.expression)) { + return value.expression; + } + return null; +} + +/** + * Extracts the `path` and element component name from a `` opening element. + * + * @param {import('@babel/types').JSXOpeningElement} openingNode + * @returns {{ routePath: string, componentName: string }|null} + */ +function parseRoute(openingNode) { + if (getJsxElementName(openingNode.name) !== 'Route') return null; + + const pathAttr = openingNode.attributes.find((a) => t.isJSXAttribute(a) && a.name?.name === 'path'); + const elementAttr = openingNode.attributes.find((a) => t.isJSXAttribute(a) && a.name?.name === 'element'); + + const routePath = getStringLiteral(pathAttr?.value); + if (routePath === null) return null; + + const element = getJsxElement(elementAttr?.value); + const componentName = element && getJsxElementName(element.openingElement.name); + if (!componentName) return null; + + return { routePath, componentName }; +} + +/** + * Maps default import local names to their module source for one import declaration. + * + * @param {import('@babel/types').ImportDeclaration} importNode + * @returns {Record} + */ +function parseImports(importNode) { + const source = importNode.source.value; + return Object.fromEntries( + importNode.specifiers + .filter(t.isImportDefaultSpecifier) + .map((specifier) => [specifier.local.name, source]), + ); +} + +/** + * Reads the text of the `` inside a `<Helmet>` element, if present. + * + * @param {import('@babel/types').JSXElement} jsxElement + * @returns {string|null} + */ +function parseHelmetTitle(jsxElement) { + if (getJsxElementName(jsxElement.openingElement.name) !== 'Helmet') return null; + + for (const child of jsxElement.children) { + if (!t.isJSXElement(child) || getJsxElementName(child.openingElement.name) !== 'title') continue; + + const titleChild = child.children[0]; + if (t.isJSXText(titleChild)) return titleChild.value.trim(); + if (t.isJSXExpressionContainer(titleChild) && t.isStringLiteral(titleChild.expression)) { + return titleChild.expression.value; + } + } + return null; +} + +/** + * Scans one file's AST. Router files yield routes + imports; page files yield helmetTitle. + * + * @param {import('@babel/types').File} ast + * @returns {{ routes: Array<{ routePath: string, componentName: string }>, imports: Record<string, string>, helmetTitle: string|null, usesEcommerceApi: boolean }} + */ +export function scanFile(ast) { + const routes = []; + const imports = {}; + let helmetTitle = null; + let usesEcommerceApi = false; + + (traverseBabel.default)(ast, { + enter(nodePath) { + if (nodePath.isJSXOpeningElement()) { + const route = parseRoute(nodePath.node); + if (route) routes.push(route); + return; + } + if (nodePath.isImportDeclaration()) { + Object.assign(imports, parseImports(nodePath.node)); + if (nodePath.node.source.value.includes('EcommerceApi')) { + usesEcommerceApi = true; + } + return; + } + if (nodePath.isJSXElement()) { + const title = parseHelmetTitle(nodePath.node); + if (title) helmetTitle = title; + } + }, + }); + + return { routes, imports, helmetTitle, usesEcommerceApi }; +} + +/** + * Merges one file's scan result into the running index, replacing any previous + * entries contributed by that same file. + * + * @param {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} index + * @param {string} fileId + * @param {{ routes: Array<{ routePath: string, componentName: string }>, imports: Record<string, string>, helmetTitle: string|null, usesEcommerceApi: boolean }} scan + * @returns {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} + */ +export function upsertFile(index, fileId, scan) { + const routerEntries = [ + ...index.routerEntries.filter((entry) => entry.routerFileId !== fileId), + ...scan.routes.map(({ routePath, componentName }) => ({ + routePath, + componentName, + routerFileId: fileId, + imports: scan.imports, + })), + ]; + + const titlesByPageFile = Object.fromEntries( + Object.entries(index.titlesByPageFile).filter(([key]) => key !== fileId), + ); + if (scan.helmetTitle) { + titlesByPageFile[fileId] = scan.helmetTitle; + } + + const ecommercePageFiles = Object.fromEntries( + Object.entries(index.ecommercePageFiles).filter(([key]) => key !== fileId), + ); + if (scan.usesEcommerceApi) { + ecommercePageFiles[fileId] = true; + } + + return { routerEntries, titlesByPageFile, ecommercePageFiles }; +} + +/** + * Chooses a display label for a route: the home label for `/`, otherwise the + * helmet title when available, falling back to the component name. + * + * @param {string} routePath + * @param {string} componentName + * @param {string|null} helmetTitle + * @returns {string} + */ +export function resolvePageLabel(routePath, componentName, helmetTitle) { + if (routePath === '/') return HOME_ROUTE_LABEL; + return helmetTitle ?? componentName; +} + +/** + * Substitutes the first dynamic segment of a route with a concrete value. + * The param name is derived from the route (e.g. ":slug", ":productId"), not + * assumed to be ":id", since route params are authored by generated code. + * + * @param {string} routePath + * @param {string} productId + * @returns {string} + */ +export function buildProductPath(routePath, productId) { + const paramToken = routePath.match(/:[^/]+/)?.[0]; + return paramToken ? routePath.replace(paramToken, productId) : routePath; +} + +/** + * Expands a dynamic product route into one concrete page entry per product. + * + * @param {Array<{ id: string, title: string }>} products + * @param {string} routePath + * @param {string} componentName + * @returns {Array<{ path: string, label: string, helmetTitle: string, component: string, dynamic: true }>} + */ +export function mapProductsToPages(products, routePath, componentName) { + return products.map((product) => ({ + path: buildProductPath(routePath, product.id), + label: product.title, + helmetTitle: product.title, + component: componentName, + dynamic: true, + })); +} + +/** + * Fetches every product by paging the store API until a short page is returned, + * bounded by MAX_PRODUCT_PAGES. EcommerceApi is imported lazily so non-ecommerce + * sites never resolve a module that does not exist for them. + * + * Import via absolute file URL: after Vite bundles this plugin into + * vite.config.js.timestamp-*.mjs, relative imports resolve against the bundle + * path instead of the original plugin location. + * + * @param {string} ecommerceApiPath Absolute filesystem path to EcommerceApi.js + * @returns {Promise<Array<{ id: string, title: string }>>} + */ +async function fetchAllProducts(ecommerceApiPath) { + const { getProducts } = await import(pathToFileURL(ecommerceApiPath).href); + + const collected = []; + for (let page = 0; page < MAX_PRODUCT_PAGES; page++) { + const { products = [] } = await getProducts({ + limit: PRODUCT_PAGE_FETCH_LIMIT, + offset: page * PRODUCT_PAGE_FETCH_LIMIT, + }); + + collected.push(...products); + + if (products.length < PRODUCT_PAGE_FETCH_LIMIT) { + break; + } + } + + return collected; +} + +/** + * Builds the flat list of site pages from the scanned index, resolving each route's + * page module and expanding ecommerce product routes. Returns only routes actually + * detected by the scan (no synthetic home fallback). + * + * @param {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} index + * @param {(specifier: string, importer: string) => Promise<{ id: string }|null>} resolveModule + * @param {string} projectRoot + * @returns {Promise<Array<{ path: string, label: string, helmetTitle: string|null, component: string|null, file?: string|null, dynamic?: true }>>} + */ +export async function buildPageList(index, resolveModule, projectRoot) { + const groups = await Promise.all( + index.routerEntries.map(async ({ routePath, componentName, routerFileId, imports }) => { + const moduleSpecifier = imports[componentName]; + const resolved = moduleSpecifier + ? await resolveModule(moduleSpecifier, routerFileId) + : null; + const pageFileId = resolved?.id ?? null; + + if (routePath.includes(':')) { + // Only ecommerce product routes are expanded into concrete pages. + // Other dynamic routes (e.g. blog/CMS detail pages) are intentionally + // skipped since we have no data source to enumerate their instances. + if (!pageFileId || !index.ecommercePageFiles[pageFileId]) { + return []; + } + + const ecommerceApiPath = fileURLToPath(new URL(ECOMMERCE_API_RELATIVE_PATH, import.meta.url)); + if (!existsSync(ecommerceApiPath)) { + return []; + } + + try { + const products = await fetchAllProducts(ecommerceApiPath); + + return mapProductsToPages(products, routePath, componentName); + } catch { + return []; + } + } + + const helmetTitle = pageFileId ? (index.titlesByPageFile[pageFileId] ?? null) : null; + + return [{ + path: routePath, + label: resolvePageLabel(routePath, componentName, helmetTitle), + helmetTitle, + component: componentName, + file: pageFileId + ? path.relative(projectRoot, pageFileId).split(path.sep).join('/') + : null, + }]; + }), + ); + + return groups.flat(); +} diff --git a/apps/web/plugins/utils/ast-utils.js b/apps/web/plugins/utils/ast-utils.js new file mode 100644 index 0000000..1145086 --- /dev/null +++ b/apps/web/plugins/utils/ast-utils.js @@ -0,0 +1,280 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import generate from '@babel/generator'; +import { parse } from '@babel/parser'; +import traverseBabel from '@babel/traverse'; +import { + isJSXIdentifier, + isJSXMemberExpression, +} from '@babel/types'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const VITE_PROJECT_ROOT = path.resolve(__dirname, '../../../..'); + +// Blacklist of components that should not be extracted (utility/non-visual components) +const COMPONENT_BLACKLIST = new Set([ + 'Helmet', + 'HelmetProvider', + 'Head', + 'head', + 'Meta', + 'meta', + 'Script', + 'script', + 'NoScript', + 'noscript', + 'Style', + 'style', + 'title', + 'Title', + 'link', + 'Link', +]); + +/** + * Validates that a file path is safe to access + * @param {string} filePath - Relative file path + * @returns {{ isValid: boolean, absolutePath?: string, error?: string }} - Object containing validation result + */ +export function validateFilePath(filePath) { + if (!filePath) { + return { isValid: false, error: 'Missing filePath' }; + } + + const absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath); + + if (filePath.includes('..') + || !absoluteFilePath.startsWith(VITE_PROJECT_ROOT) + || absoluteFilePath.includes('node_modules')) { + return { isValid: false, error: 'Invalid path' }; + } + + if (!fs.existsSync(absoluteFilePath)) { + return { isValid: false, error: 'File not found' }; + } + + return { isValid: true, absolutePath: absoluteFilePath }; +} + +/** + * Parses a file into a Babel AST + * @param {string} absoluteFilePath - Absolute path to file + * @returns {object} Babel AST + */ +export function parseFileToAST(absoluteFilePath) { + const content = fs.readFileSync(absoluteFilePath, 'utf-8'); + + return parse(content, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + errorRecovery: true, + }); +} + +/** + * Finds a JSX opening element at a specific line and column + * @param {object} ast - Babel AST + * @param {number} line - Line number (1-indexed) + * @param {number} column - Column number (0-indexed for get-code-block, 1-indexed for apply-edit) + * @returns {object | null} Babel path to the JSX opening element + */ +export function findJSXElementAtPosition(ast, line, column) { + let targetNodePath = null; + let closestNodePath = null; + let closestDistance = Infinity; + const allNodesOnLine = []; + + const visitor = { + JSXOpeningElement(path) { + const node = path.node; + if (node.loc) { + // Exact match (with tolerance for off-by-one column differences) + if (node.loc.start.line === line + && Math.abs(node.loc.start.column - column) <= 1) { + targetNodePath = path; + path.stop(); + return; + } + + // Track all nodes on the same line + if (node.loc.start.line === line) { + allNodesOnLine.push({ + path, + column: node.loc.start.column, + distance: Math.abs(node.loc.start.column - column), + }); + } + + // Track closest match on the same line for fallback + if (node.loc.start.line === line) { + const distance = Math.abs(node.loc.start.column - column); + if (distance < closestDistance) { + closestDistance = distance; + closestNodePath = path; + } + } + } + }, + // Also check JSXElement nodes that contain the position + JSXElement(path) { + const node = path.node; + if (!node.loc) { + return; + } + + // Check if this element spans the target line (for multi-line elements) + if (node.loc.start.line > line || node.loc.end.line < line) { + return; + } + + // If we're inside this element's range, consider its opening element + if (!path.node.openingElement?.loc) { + return; + } + + const openingLine = path.node.openingElement.loc.start.line; + const openingCol = path.node.openingElement.loc.start.column; + + // Prefer elements that start on the exact line + if (openingLine === line) { + const distance = Math.abs(openingCol - column); + if (distance < closestDistance) { + closestDistance = distance; + closestNodePath = path.get('openingElement'); + } + return; + } + + // Handle elements that start before the target line + if (openingLine < line) { + const distance = (line - openingLine) * 100; // Penalize by line distance + if (distance < closestDistance) { + closestDistance = distance; + closestNodePath = path.get('openingElement'); + } + } + }, + }; + + const traverseFunction = traverseBabel.default || traverseBabel; + traverseFunction(ast, visitor); + + // Return exact match if found, otherwise return closest match if within reasonable distance + // Use larger threshold (50 chars) for same-line elements, 5 lines for multi-line elements + const threshold = closestDistance < 100 ? 50 : 500; + return targetNodePath || (closestDistance <= threshold ? closestNodePath : null); +} + +/** + * Checks if a JSX element name is blacklisted + * @param {object} jsxOpeningElement - Babel JSX opening element node + * @returns {boolean} True if blacklisted + */ +function isBlacklistedComponent(jsxOpeningElement) { + if (!jsxOpeningElement || !jsxOpeningElement.name) { + return false; + } + + // Handle JSXIdentifier (e.g., <Helmet>) + if (isJSXIdentifier(jsxOpeningElement.name)) { + return COMPONENT_BLACKLIST.has(jsxOpeningElement.name.name); + } + + // Handle JSXMemberExpression (e.g., <React.Fragment>) + if (isJSXMemberExpression(jsxOpeningElement.name)) { + let current = jsxOpeningElement.name; + while (isJSXMemberExpression(current)) { + current = current.property; + } + if (isJSXIdentifier(current)) { + return COMPONENT_BLACKLIST.has(current.name); + } + } + + return false; +} + +/** + * Generates code from an AST node + * @param {object} node - Babel AST node + * @param {object} options - Generator options + * @returns {string} Generated code + */ +export function generateCode(node, options = {}) { + const generateFunction = generate.default || generate; + const output = generateFunction(node, options); + return output.code; +} + +/** + * Generates a full source file from AST with source maps + * @param {object} ast - Babel AST + * @param {string} sourceFileName - Source file name for source map + * @param {string} originalCode - Original source code + * @returns {{code: string, map: object}} - Object containing generated code and source map + */ +export function generateSourceWithMap(ast, sourceFileName, originalCode) { + const generateFunction = generate.default || generate; + return generateFunction(ast, { + sourceMaps: true, + sourceFileName, + }, originalCode); +} + +/** + * Extracts code blocks from a JSX element at a specific location + * @param {string} filePath - Relative file path + * @param {number} line - Line number + * @param {number} column - Column number + * @param {object} [domContext] - Optional DOM context to return on failure + * @returns {{success: boolean, filePath?: string, specificLine?: string, error?: string, domContext?: object}} - Object with metadata for LLM + */ +export function extractCodeBlocks(filePath, line, column, domContext) { + try { + // Validate file path + const validation = validateFilePath(filePath); + if (!validation.isValid) { + return { success: false, error: validation.error, domContext }; + } + + // Parse AST + const ast = parseFileToAST(validation.absolutePath); + + // Find target node + const targetNodePath = findJSXElementAtPosition(ast, line, column); + + if (!targetNodePath) { + return { success: false, error: 'Target node not found at specified line/column', domContext }; + } + + // Check if the target node is a blacklisted component + const isBlacklisted = isBlacklistedComponent(targetNodePath.node); + + if (isBlacklisted) { + return { + success: true, + filePath, + specificLine: '', + }; + } + + // Get specific line code + const specificLine = generateCode(targetNodePath.parentPath?.node || targetNodePath.node); + + return { + success: true, + filePath, + specificLine, + }; + } catch (error) { + console.error('[ast-utils] Error extracting code blocks:', error); + return { success: false, error: 'Failed to extract code blocks', domContext }; + } +} + +/** + * Project root path + */ +export { VITE_PROJECT_ROOT }; diff --git a/apps/web/plugins/utils/same-origin.js b/apps/web/plugins/utils/same-origin.js new file mode 100644 index 0000000..611836b --- /dev/null +++ b/apps/web/plugins/utils/same-origin.js @@ -0,0 +1,34 @@ +/** + * Whether a dev-server API request came from the preview page itself. + * + * This is a cross-origin/CSRF control, NOT authorization. `Sec-Fetch-Site` is + * client-supplied and the preview URL needs no credentials, so a crafted request + * that sets the header still gets through. What it does stop: browser-driven + * cross-origin writes, and clients that send no fetch metadata at all. + * + * Browsers label the preview's own same-origin `fetch` calls + * `Sec-Fetch-Site: same-origin`. Hosts are compared rather than full origins + * because the request reaches Vite over plain HTTP behind the proxy, which + * forwards the original `Host`. + * + * @param {import('node:http').IncomingMessage} request + * @returns {boolean} + */ +export function isSameOriginRequest(request) { + const site = request.headers['sec-fetch-site']; + if (site) { + return site === 'same-origin'; + } + + // Pre-16.4 Safari omits fetch metadata but still sends `Origin` on POST. + const origin = request.headers.origin; + if (!origin) { + return false; + } + + try { + return new URL(origin).host === request.headers.host; + } catch { + return false; + } +} diff --git a/apps/web/plugins/visual-editor/api/draft-snapshot.js b/apps/web/plugins/visual-editor/api/draft-snapshot.js new file mode 100644 index 0000000..bb15ca7 --- /dev/null +++ b/apps/web/plugins/visual-editor/api/draft-snapshot.js @@ -0,0 +1,43 @@ +import { exportDraftSnapshot, importDraftSnapshot, getEditState } from "../state/history-state.js"; +import { exportAnnotationMarkers, importAnnotationMarkers } from "../ui/annotation-panel/annotation-markers.js"; +import { ParentMessage } from "../constants/messages.js"; +import { postToParent } from "../utils/parent-frame.js"; + +/** + * Full draft snapshot for parent-frame persistence (history + annotations). + * @returns {{ history: ReturnType<typeof exportDraftSnapshot>, annotations: ReturnType<typeof exportAnnotationMarkers> }} + */ +export function buildDraftSnapshot() { + return { + history: exportDraftSnapshot(), + annotations: exportAnnotationMarkers(), + }; +} + +/** + * Restores a previously persisted draft into the live editor session. + * @param {{ history?: object, annotations?: object[] }|null|undefined} snapshot + * @param {{ onMarkerClick: (comment: object, clientX: number, clientY: number) => void }} options + */ +export function applyDraftSnapshot(snapshot, { onMarkerClick }) { + if (!snapshot || typeof snapshot !== "object") return; + + importDraftSnapshot(snapshot.history); + importAnnotationMarkers(snapshot.annotations, onMarkerClick); +} + +/** + * Updates parent with the draft on user action + * @param {string} [type] - A {@link ParentMessage} value. + * @param {object} [extraPayload] + */ +export function notifyDraftStateChanged(type = ParentMessage.EDIT_STATE_CHANGED, extraPayload) { + const { pathname, search, hash } = window.location; + + postToParent(type, { + ...getEditState(), + ...extraPayload, + snapshot: buildDraftSnapshot(), + route: `${pathname}${search}${hash}`, + }); +} diff --git a/apps/web/plugins/visual-editor/api/draft.js b/apps/web/plugins/visual-editor/api/draft.js new file mode 100644 index 0000000..da7a552 --- /dev/null +++ b/apps/web/plugins/visual-editor/api/draft.js @@ -0,0 +1,149 @@ +import { ParentMessage } from "../constants/messages.js"; +import { postToParent } from "../utils/parent-frame.js"; +import { getEditing } from "../state/editing-state.js"; +import { getCurrentEdits, clearHistory, getEditState, getActionPointer } from "../state/history-state.js"; +import { applySessionUndo } from "../utils/session-history-command.js"; +import { commitCurrentEdit } from "../ui/inline-edit/edit-action.js"; +import { getComments, setComments } from "../state/annotation-state.js"; +import { getEditId } from "../constants/selectors.js"; +import { clearAllAnnotationMarkers } from "../ui/annotation-panel/annotation-markers.js"; +import { hideAnnotationPanel } from "../ui/annotation-panel/panel.js"; +import { resetMultiSelectState } from "../state/multi-select-state.js"; +import { unlockHoverOutline } from "../ui/overlays/hover-outline.js"; + +/** Relative URL used to persist draft edits to the server. */ +const DRAFT_SAVE_API_URL = "/api/draft-save"; +/** Minimum duration (ms) the parent loading indicator stays visible during a direct-only draft save. */ +const MIN_LOADING_DISPLAY_MS = 500; + +/** + * Rolls back every committed edit in the history stack and clears it, returning + * the document to its pre-edit state. + */ +export function discardAllEdits() { + while (getActionPointer() >= 0) { + applySessionUndo(); + } + clearHistory(); +} + +/** + * Commits any active inline edit, rolls back all edits in the history stack, + * clears annotations, and notifies the parent frame that the draft was discarded. + */ +export function handleDraftDiscard() { + if (getEditing()?.targetElement?.hasAttribute("contenteditable")) { + commitCurrentEdit(); + } + discardAllEdits(); + hideAnnotationPanel(); + clearAllAnnotationMarkers(); + setComments([]); + resetMultiSelectState(); + unlockHoverOutline(); + postToParent(ParentMessage.DRAFT_DISCARDED); +} + +/** + * Persists the current draft edits to the server. + * + * Commits any active contenteditable edit first, then POSTs the direct edits to + * `DRAFT_SAVE_API_URL`. Assisted edits and annotations are applied by the AI + * during the stream that follows, so a draft holding only those skips the + * request entirely. + * + * Parent frame messages emitted: + * - `DRAFT_SAVE_STARTED` — immediately on call + * - `DRAFT_APPLIED` — on success, includes server results and current edit state + * - `DRAFT_SAVE_FAILED` — on server error or network failure, includes error message + * - `DRAFT_SAVE_FINISHED` — always, delayed by `MIN_LOADING_DISPLAY_MS` for direct-only saves + * + * @returns {Promise<void>} + */ +export async function handleDraftSave() { + if (getEditing()?.targetElement?.hasAttribute("contenteditable")) { + commitCurrentEdit(); + } + postToParent(ParentMessage.DRAFT_SAVE_STARTED); + const startTime = Date.now(); + let hasAssistedEdits = false; + try { + const allEdits = getCurrentEdits(); + + const draft = Object.fromEntries( + Object.entries(allEdits) + .filter(([, edit]) => !edit.isAssisted) + .map(([key, edit]) => { + const sourceEdit = { ...edit }; + delete sourceEdit.editId; + return [key, sourceEdit]; + }) + ); + + const visualEdits = [ + ...Object.values(allEdits).map(edit => ({ + editId: edit.editId, + selectionModes: [edit.selectionMode], + instruction: edit.instruction, + isAssisted: edit.isAssisted, + })), + ...getComments().map(({ text, elements, selections, attachments }) => ({ + editId: elements.length === 1 ? getEditId(elements[0]) || undefined : undefined, + selectionModes: selections, + instruction: { + changes: [], + annotation: text, + ...(attachments?.length && { + attachments: attachments.map(({ url }) => ({ type: 'image', image: url })), + }), + }, + isAssisted: true, + })), + ]; + + hasAssistedEdits = visualEdits.some(edit => edit.isAssisted); + + let result = { success: true, results: [] }; + + if (Object.keys(draft).length) { + const response = await fetch(DRAFT_SAVE_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ draft }), + }); + + result = await response.json(); + } + + if (result.success) { + clearHistory(); + hideAnnotationPanel(); + clearAllAnnotationMarkers(); + setComments([]); + resetMultiSelectState(); + unlockHoverOutline(); + postToParent(ParentMessage.DRAFT_APPLIED, { + results: result.results, + visualEdits, + ...getEditState(), + }); + } else { + postToParent(ParentMessage.DRAFT_SAVE_FAILED, { error: result.error }); + console.error(`[vite][visual-editor] Error saving draft: ${result.error}`); + } + } catch (error) { + postToParent(ParentMessage.DRAFT_SAVE_FAILED, { error: error.message }); + console.error(`[vite][visual-editor] Error during draft save:`, error); + } finally { + if (!hasAssistedEdits) { + const elapsed = Date.now() - startTime; + const remaining = MIN_LOADING_DISPLAY_MS - elapsed; + if (remaining > 0) { + await new Promise((resolve) => setTimeout(resolve, remaining)); + } + } + postToParent(ParentMessage.DRAFT_SAVE_FINISHED); + } +} diff --git a/apps/web/plugins/visual-editor/constants/icons.js b/apps/web/plugins/visual-editor/constants/icons.js new file mode 100644 index 0000000..9d18a72 --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/icons.js @@ -0,0 +1,112 @@ +/** Small chevron-down icon for compact dropdown triggers. */ +export const ICON_CHEVRON_DOWN_SMALL = ` +<svg width="10" height="6" viewBox="0 0 10 6" fill="none" aria-hidden="true"> + <path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> +</svg> +`.trim(); + +/** Chevron-down icon for standard dropdown triggers. */ +export const ICON_CHEVRON_DOWN = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path d="M12.0303 7.03033C12.3232 6.73744 12.3232 6.26256 12.0303 5.96967C11.7374 5.67678 11.2626 5.67678 10.9697 5.96967L8 8.93934L5.03033 5.96967C4.73744 5.67678 4.26256 5.67678 3.96967 5.96967C3.67678 6.26256 3.67678 6.73744 3.96967 7.03033L7.46967 10.5303C7.76256 10.8232 8.23744 10.8232 8.53033 10.5303L12.0303 7.03033Z" fill="white"/> +</svg> +`.trim(); + +/** Bold text-format toolbar icon. */ +export const ICON_BOLD = ` +<svg xmlns="http://www.w3.org/2000/svg" width="8" height="10" viewBox="0 0 8 10" fill="none"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M5.03125 0.0146484C6.41781 0.155626 7.5 1.32626 7.5 2.75L7.48633 3.03125C7.42559 3.62924 7.17072 4.16858 6.78906 4.59082C7.52541 5.13749 8.00391 6.01245 8.00391 7L8 7.1543C7.92229 8.68818 6.69202 9.91814 5.1582 9.99609L5.00391 10H0.861328L0.773438 9.99512C0.370065 9.95414 0.0497858 9.63383 0.00878906 9.23047L0.00390625 9.14258V5.5H0V0.803711C2.4579e-05 0.387669 0.316487 0.045031 0.72168 0.00390625L0.803711 0H4.75L5.03125 0.0146484ZM1.50391 8.5H5.00391C5.83211 8.49974 6.50391 7.82826 6.50391 7C6.50391 6.17174 5.83211 5.50026 5.00391 5.5H1.50391V8.5ZM1.5 4H4.75C5.39701 3.99981 5.9301 3.5081 5.99414 2.87793L6 2.75C6 2.05977 5.44019 1.5002 4.75 1.5H1.5V4Z" fill="white"/> +</svg> +`.trim(); + +/** Italic text-format toolbar icon. */ +export const ICON_ITALIC = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path d="M8.45775 5C8.92563 5 9.27389 5.43265 9.17357 5.88965L7.62865 12.9248C7.55475 13.2606 7.25764 13.4999 6.9138 13.5C6.44592 13.5 6.09766 13.0673 6.19798 12.6104L7.7429 5.5752C7.8168 5.23941 8.11391 5.0001 8.45775 5Z" fill="white"/> + <path d="M9.00072 2.5C9.4148 2.50015 9.75072 2.83588 9.75072 3.25C9.75072 3.66412 9.4148 3.99985 9.00072 4C8.5865 4 8.25072 3.66421 8.25072 3.25C8.25072 2.83579 8.5865 2.5 9.00072 2.5Z" fill="white"/> +</svg> +`.trim(); + +/** Underline text-format toolbar icon. */ +export const ICON_UNDERLINE = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path d="M12.5771 12.5039C12.9551 12.5425 13.25 12.8618 13.25 13.25C13.25 13.6382 12.9551 13.9575 12.5771 13.9961L12.5 14H3.5C3.08579 14 2.75 13.6642 2.75 13.25C2.75 12.8358 3.08579 12.5 3.5 12.5H12.5L12.5771 12.5039Z" fill="white"/> + <path d="M4.75 2.74316C5.16421 2.74316 5.5 3.07895 5.5 3.49316V7.74609L5.5127 8.00098C5.64042 9.2608 6.70376 10.2445 7.99707 10.2461L8.25293 10.2334C9.43054 10.1151 10.3676 9.17942 10.4873 8.00195L10.5 7.74609V3.50098C10.5 3.08676 10.8358 2.75098 11.25 2.75098C11.6642 2.75098 12 3.08676 12 3.50098V7.74609L11.9951 7.95215C11.8912 9.99912 10.2483 11.6398 8.20117 11.7412L7.99512 11.7461C5.85703 11.7435 4.11219 10.0635 4.00488 7.95215L4 7.74609V3.49316C4 3.07895 4.33579 2.74316 4.75 2.74316Z" fill="white"/> +</svg> +`.trim(); + +/** Align-left text-format toolbar icon. */ +export const ICON_ALIGN_LEFT = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path d="M10.5771 11.2539C10.9551 11.2925 11.25 11.6118 11.25 12C11.25 12.3882 10.9551 12.7075 10.5771 12.7461L10.5 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H10.5L10.5771 11.2539Z" fill="white"/> + <path d="M7.5 7.25C7.91421 7.25 8.25 7.58579 8.25 8C8.25 8.41421 7.91421 8.75 7.5 8.75H3.5C3.08579 8.75 2.75 8.41421 2.75 8C2.75 7.58579 3.08579 7.25 3.5 7.25H7.5Z" fill="white"/> + <path d="M13.5 3.25C13.9142 3.25007 14.25 3.58583 14.25 4C14.25 4.41417 13.9142 4.74993 13.5 4.75H3.5C3.08579 4.75 2.75 4.41421 2.75 4C2.75 3.58579 3.08579 3.25 3.5 3.25H13.5Z" fill="white"/> +</svg> +`.trim(); + +/** “More options” toolbar icon (line height / extras). */ +export const ICON_MORE = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path d="M12.5 12.5C12.9142 12.5 13.25 12.8358 13.25 13.25C13.25 13.6642 12.9142 14 12.5 14H3.5C3.08579 14 2.75 13.6642 2.75 13.25C2.75 12.8358 3.08579 12.5 3.5 12.5H12.5Z" fill="white"/> + <path d="M8.03027 1.60755C8.334 1.60858 8.60681 1.79314 8.72168 2.07435L12.1943 10.5743C12.3507 10.9577 12.1665 11.3953 11.7832 11.5519C11.3998 11.7083 10.9623 11.5241 10.8057 11.1408L8.02148 4.32728L5.19238 11.1447C5.03363 11.5272 4.59541 11.7086 4.21289 11.5499C3.83039 11.3912 3.64899 10.953 3.80762 10.5704L7.33496 2.07044C7.45146 1.78974 7.72636 1.6067 8.03027 1.60755Z" fill="white"/> + <path d="M10.0863 7.25C10.5004 7.25013 10.8363 7.58587 10.8363 8C10.8363 8.41413 10.5004 8.74987 10.0863 8.75H5.87732C5.4631 8.75 5.12732 8.41421 5.12732 8C5.12732 7.58579 5.4631 7.25 5.87732 7.25H10.0863Z" fill="white"/> + <path d="M12.7811 1.19129C12.6805 0.936238 12.3195 0.936238 12.2189 1.19129L11.6936 2.52336C11.6629 2.60122 11.6012 2.66286 11.5234 2.69357L10.1913 3.21893C9.93624 3.31952 9.93624 3.68048 10.1913 3.78107L11.5234 4.30643C11.6012 4.33714 11.6629 4.39878 11.6936 4.47664L12.2189 5.80871C12.3195 6.06376 12.6805 6.06376 12.7811 5.80872L13.3064 4.47664C13.3371 4.39878 13.3988 4.33714 13.4766 4.30643L14.8087 3.78107C15.0638 3.68048 15.0638 3.31952 14.8087 3.21893L13.4766 2.69357C13.3988 2.66286 13.3371 2.60122 13.3064 2.52336L12.7811 1.19129Z" fill="white"/> +</svg> +`.trim(); + +/** Link insertion toolbar icon. */ +export const ICON_LINK = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M7.82058 2.51974C9.38336 0.956963 11.9171 0.956963 13.4799 2.51974C15.0427 4.08251 15.0427 6.61627 13.4799 8.17904L12.2356 9.42334C11.9427 9.71623 11.4678 9.71623 11.1749 9.42334C10.882 9.13044 10.882 8.65557 11.1749 8.36268L12.4192 7.11838C13.3962 6.1414 13.3962 4.55738 12.4192 3.5804C11.4422 2.60341 9.85823 2.60341 8.88124 3.5804L7.63695 4.82469C7.34406 5.11758 6.86918 5.11758 6.57629 4.82469C6.2834 4.5318 6.2834 4.05692 6.57629 3.76403L7.82058 2.51974ZM4.80405 6.59693C5.09695 6.88982 5.09695 7.3647 4.80405 7.65759L3.5804 8.88124C2.60341 9.85823 2.60341 11.4422 3.5804 12.4192C4.55739 13.3962 6.1414 13.3962 7.11838 12.4192L8.34204 11.1956C8.63493 10.9027 9.10981 10.9027 9.4027 11.1956C9.69559 11.4885 9.69559 11.9633 9.4027 12.2562L8.17904 13.4799C6.61627 15.0427 4.08251 15.0427 2.51974 13.4799C0.956964 11.9171 0.956962 9.38336 2.51974 7.82058L3.74339 6.59693C4.03629 6.30404 4.51116 6.30403 4.80405 6.59693Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M10.6946 5.36582C10.9875 5.65871 10.9875 6.13358 10.6946 6.42648L6.12342 10.9976C5.83053 11.2905 5.35566 11.2905 5.06276 10.9976C4.76987 10.7047 4.76987 10.2299 5.06276 9.93698L9.63393 5.36582C9.92682 5.07292 10.4017 5.07292 10.6946 5.36582Z" fill="white"/> +</svg> +`.trim(); + +/** Delete/trash icon for removing edits. */ +export const ICON_DELETE = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <g transform="translate(1.85 1.085)"> + <path d="M5.55 5.25C5.55 4.83579 5.21421 4.5 4.8 4.5C4.38579 4.5 4.05 4.83579 4.05 5.25V10.2C4.05 10.6142 4.38579 10.95 4.8 10.95C5.21421 10.95 5.55 10.6142 5.55 10.2V5.25Z" fill="white"/> + <path d="M7.5 4.5C7.91421 4.5 8.25 4.83579 8.25 5.25V10.2C8.25 10.6142 7.91421 10.95 7.5 10.95C7.08579 10.95 6.75 10.6142 6.75 10.2V5.25C6.75 4.83579 7.08579 4.5 7.5 4.5Z" fill="white"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M5.35623 0C4.73126 0 4.15992 0.353103 3.88043 0.912097L3.39085 1.89125L0.750363 1.88998C0.336149 1.88978 0.000200302 2.2254 8.95287e-08 2.63962C-0.000200122 3.05383 0.335424 3.38978 0.749637 3.38998L1.20364 3.3902L1.74954 12.2812C1.80298 13.1515 2.52443 13.83 3.39644 13.83H8.90357C9.77557 13.83 10.497 13.1515 10.5505 12.2812L11.0962 3.39498L11.5496 3.3952C11.9639 3.3954 12.2998 3.05977 12.3 2.64556C12.3002 2.23135 11.9646 1.8954 11.5504 1.8952L8.91049 1.89392L8.41957 0.912098C8.14008 0.353104 7.56874 0 6.94377 0H5.35623ZM5.22207 1.58292C5.24748 1.5321 5.29941 1.5 5.35623 1.5H6.94377C7.00059 1.5 7.05253 1.5321 7.07793 1.58292L7.18647 1.8H5.11353L5.22207 1.58292ZM2.70651 3.39092L9.59338 3.39425L9.05329 12.1892C9.04843 12.2684 8.98284 12.33 8.90357 12.33H3.39644C3.31716 12.33 3.25158 12.2684 3.24672 12.1892L2.70651 3.39092Z" fill="white"/> + </g> +</svg> +`.trim(); + +/** Close/dismiss icon for panels and popovers. */ +export const ICON_CLOSE = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path transform="translate(3.75 3.75)" d="M0.219671 1.28033C-0.0732222 0.987437 -0.0732222 0.512563 0.219671 0.21967C0.512564 -0.0732233 0.987438 -0.0732233 1.28033 0.21967L4.25 3.18934L7.21967 0.21967C7.51256 -0.0732232 7.98744 -0.0732232 8.28033 0.21967C8.57322 0.512563 8.57322 0.987437 8.28033 1.28033L5.31066 4.25L8.28033 7.21967C8.57322 7.51256 8.57322 7.98744 8.28033 8.28033C7.98744 8.57322 7.51256 8.57322 7.21967 8.28033L4.25 5.31066L1.28033 8.28033C0.987437 8.57322 0.512563 8.57322 0.21967 8.28033C-0.0732233 7.98744 -0.0732233 7.51256 0.21967 7.21967L3.18934 4.25L0.219671 1.28033Z" fill="white"/> +</svg> +`.trim(); + +/** Image icon for attaching reference images to an annotation. Mirrors the chat prompt's `ic-image-16`. */ +export const ICON_IMAGE = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 2.5H12.5C13.0523 2.5 13.5 2.94772 13.5 3.5V12.0818L10.8974 8.39082C10.5672 7.92252 10.4021 7.68836 10.1967 7.60571C10.017 7.53339 9.81632 7.53339 9.63663 7.60571C9.43128 7.68836 9.26618 7.92252 8.93596 8.39082L6.83975 11.3636L6.02303 10.2054C5.73684 9.79951 5.59374 9.59658 5.41578 9.52495C5.26005 9.46227 5.08611 9.46227 4.93038 9.52495C4.75241 9.59658 4.60932 9.79951 4.32313 10.2054L2.5308 12.7472C2.51069 12.6682 2.5 12.5853 2.5 12.5V3.5C2.5 2.94772 2.94772 2.5 3.5 2.5ZM1 3.5C1 2.11929 2.11929 1 3.5 1H12.5C13.8807 1 15 2.11929 15 3.5V12.5C15 13.8807 13.8807 15 12.5 15H3.5C2.11929 15 1 13.8807 1 12.5V3.5ZM5.5 7C6.32843 7 7 6.32843 7 5.5C7 4.67157 6.32843 4 5.5 4C4.67157 4 4 4.67157 4 5.5C4 6.32843 4.67157 7 5.5 7Z" fill="white"/> +</svg> +`.trim(); + +/** Cursor icon shown while actively editing text (used as a CSS `cursor: url(...)` value, must stay on one line). */ +export const ICON_TEXT_EDIT_CURSOR = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.6855 4.01657C15.1185 3.98715 15.5827 4.0007 15.9206 4.02768L16.0189 6.02799C15.4981 6.00166 15.1549 5.99184 14.856 6.00975C14.2838 6.13443 14.022 6.27355 13.7212 6.48238C13.5144 6.6249 13.1758 7.08781 13.0328 7.40688V11.9466H14.0308V13.9306H13.0328V17.5075C13.1757 17.8261 13.5144 18.2881 13.7256 18.4331C14.0074 18.6295 14.253 18.7705 14.4705 18.8452C15.3967 18.9569 15.3319 18.8892 15.8593 18.906L16.1008 20.8832C15.4621 20.9149 15.0617 20.9214 14.6848 20.8955C14.3728 20.8741 14.1113 20.8311 13.9107 20.7663C13.4939 20.6366 13.0581 20.4058 12.5878 20.0778C12.4192 19.9621 12.2307 19.7872 12.0449 19.5797C11.8745 19.7815 11.7053 19.9522 11.561 20.0652C11.1569 20.3791 10.6947 20.6094 10.2013 20.7643C9.98962 20.8325 9.72877 20.8751 9.41772 20.8965C8.98322 20.9264 8.5177 20.9128 8.17962 20.8857L8.0827 18.8854C8.60041 18.9118 8.94438 18.9217 9.2438 18.9036C9.86247 18.7641 10.1206 18.6516 10.3311 18.4881C10.5282 18.3338 10.9156 17.8079 11.0328 17.5043L11.0451 13.9306H10.0448V11.9466H11.0468V7.41344C10.9148 7.10644 10.5303 6.57954 10.3355 6.42776C10.1342 6.27198 9.89676 6.15407 9.63388 6.06667C8.70613 5.95562 8.77053 6.02289 8.24302 6.00632L8 4.02903C8.64058 3.99743 9.04059 3.99092 9.41699 4.01654C9.72766 4.03768 9.98817 4.07993 10.1966 4.1466C10.6967 4.30508 11.1568 4.53443 11.5622 4.84814C11.7056 4.95991 11.8748 5.13053 12.0455 5.33267C12.23 5.12617 12.417 4.95226 12.5834 4.83756C13.0581 4.508 13.4916 4.27789 13.9024 4.14775C14.1137 4.07998 14.3744 4.0377 14.6855 4.01657Z" fill="black" stroke="white" stroke-linejoin="round"/></svg>`; + +/** Sparkles icon (`ic-sparkles`) for AI edit affordances: the toolbar button and annotation markers. 12x13 glyph in a 16x16 box. */ +export const ICON_SPARKLES = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <g transform="translate(2 1)"> + <path d="M3.89349 3.7678C3.75267 3.41073 3.24733 3.41073 3.10651 3.7678L2.371 5.6327C2.32801 5.74171 2.24171 5.82801 2.1327 5.871L0.2678 6.60651C-0.0892668 6.74733 -0.0892668 7.25267 0.2678 7.39349L2.1327 8.129C2.24171 8.17199 2.32801 8.25829 2.371 8.3673L3.1065 10.2322C3.24733 10.5893 3.75267 10.5893 3.89349 10.2322L4.629 8.3673C4.67199 8.25829 4.75829 8.17199 4.8673 8.129L6.7322 7.39349C7.08927 7.25267 7.08927 6.74733 6.7322 6.60651L4.8673 5.871C4.75829 5.82801 4.67199 5.74171 4.629 5.6327L3.89349 3.7678Z" fill="white"/> + <path d="M9.78107 0.191286C9.68048 -0.0637618 9.31952 -0.0637621 9.21893 0.191286L8.69357 1.52336C8.66286 1.60122 8.60122 1.66286 8.52336 1.69357L7.19129 2.21893C6.93624 2.31952 6.93624 2.68048 7.19129 2.78107L8.52336 3.30643C8.60122 3.33714 8.66286 3.39878 8.69357 3.47664L9.21893 4.80871C9.31952 5.06376 9.68048 5.06376 9.78107 4.80872L10.3064 3.47664C10.3371 3.39878 10.3988 3.33714 10.4766 3.30643L11.8087 2.78107C12.0638 2.68048 12.0638 2.31952 11.8087 2.21893L10.4766 1.69357C10.3988 1.66286 10.3371 1.60122 10.3064 1.52336L9.78107 0.191286Z" fill="white"/> + <path d="M9.66864 10.1148C9.60829 9.96174 9.39171 9.96174 9.33136 10.1148L9.01614 10.914C8.99772 10.9607 8.96073 10.9977 8.91401 11.0161L8.11477 11.3314C7.96174 11.3917 7.96174 11.6083 8.11477 11.6686L8.91401 11.9839C8.96073 12.0023 8.99772 12.0393 9.01614 12.086L9.33136 12.8852C9.39171 13.0383 9.60829 13.0383 9.66864 12.8852L9.98386 12.086C10.0023 12.0393 10.0393 12.0023 10.086 11.9839L10.8852 11.6686C11.0383 11.6083 11.0383 11.3917 10.8852 11.3314L10.086 11.0161C10.0393 10.9977 10.0023 10.9607 9.98386 10.914L9.66864 10.1148Z" fill="white"/> + </g> +</svg> +`.trim(); + +/** Label icon shown on elements with assisted (AI) edits. */ +export const ASSISTED_EDIT_LABEL = ` +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none"> + <path fill-rule="evenodd" clip-rule="evenodd" d="M12.9707 8.64551C13.1721 8.57718 13.3909 8.5738 13.5938 8.6377C13.8245 8.71052 14.0256 8.91063 14.4277 9.30859L14.8369 9.71387C15.1919 10.0638 15.3707 10.2388 15.4482 10.4375C15.537 10.6652 15.5388 10.9183 15.4541 11.1475C15.38 11.3472 15.2046 11.5261 14.8564 11.8818L11.5137 15.2969C11.4057 15.4072 11.3511 15.463 11.29 15.5088C11.2359 15.5494 11.1774 15.5854 11.1162 15.6143L11 15.6582C10.9543 15.6732 10.8984 15.691 10.8242 15.7129L9.78418 16.0195L9.42383 16.125C9.10857 16.2124 8.91655 16.2452 8.76367 16.1973L8.63574 16.1426C8.51494 16.0759 8.41463 15.9741 8.35059 15.8496C8.27735 15.7071 8.27841 15.5121 8.31152 15.1865L8.35449 14.8145L8.50781 13.5195C8.5317 13.3189 8.54362 13.217 8.57422 13.123C8.58781 13.0815 8.60516 13.0403 8.62402 13.001L8.68848 12.8857C8.71549 12.8446 8.74705 12.8049 8.78906 12.7588L8.95605 12.5869L11.6582 9.84277L12.1504 9.33301C12.5436 8.92657 12.7417 8.72344 12.9707 8.64551ZM13.2295 10.376L12.7363 10.8857L12.7275 10.8945L10.0254 13.6377C10.0103 13.6531 10.0009 13.6736 9.99821 13.6949C9.99815 13.6954 9.9981 13.6958 9.99805 13.6963L9.95695 14.0409C9.93571 14.2191 10.1034 14.3609 10.2755 14.3104L10.3984 14.2744C10.4146 14.2696 10.4292 14.2605 10.441 14.2485C10.4411 14.2483 10.4413 14.2482 10.4414 14.248L13.7842 10.833C13.7981 10.8188 13.7972 10.7954 13.7831 10.7814L13.3719 10.3742C13.3322 10.3349 13.2684 10.3358 13.2295 10.376Z" fill="#18181A"/> + <path d="M7 0C7.41421 0 7.75 0.335786 7.75 0.75V2.40039C7.75 2.4554 7.7946 2.5 7.84961 2.5C8.95782 2.5 9.84974 2.49916 10.5605 2.57617C11.2852 2.6547 11.9107 2.82112 12.4541 3.21582C12.7725 3.44712 13.0529 3.72754 13.2842 4.0459C13.6789 4.58927 13.8453 5.21477 13.9238 5.93945C13.9538 6.21579 13.9716 6.5195 13.9825 6.85264C13.988 7.02115 13.8262 7.14461 13.6595 7.11978C13.3798 7.07813 13.0964 7.08423 12.8213 7.13676C12.6552 7.16849 12.4881 7.05113 12.4826 6.88211C12.4729 6.58977 12.4576 6.33246 12.4326 6.10156C12.3679 5.50448 12.2483 5.17286 12.0703 4.92773C11.9315 4.73672 11.7633 4.56847 11.5723 4.42969C11.3271 4.25165 10.9955 4.13207 10.3984 4.06738C9.78723 4.0012 8.99098 4 7.84961 4H6.15039C5.00902 4 4.21277 4.0012 3.60156 4.06738C3.00448 4.13207 2.67286 4.25165 2.42773 4.42969C2.23672 4.56847 2.06847 4.73672 1.92969 4.92773C1.75165 5.17286 1.63207 5.50448 1.56738 6.10156C1.5012 6.71277 1.5 7.50902 1.5 8.65039V8.84961C1.5 9.99098 1.5012 10.7872 1.56738 11.3984C1.63207 11.9955 1.75166 12.3271 1.92969 12.5723C2.06847 12.7633 2.23672 12.9315 2.42773 13.0703C2.67286 13.2483 3.00449 13.3679 3.60156 13.4326C4.21277 13.4988 5.00902 13.5 6.15039 13.5H6.71864C6.86837 13.5 6.98452 13.6307 6.9669 13.7794L6.86523 14.6377L6.86426 14.6426L6.84866 14.7785C6.83418 14.9047 6.72733 15 6.60029 15H6.15039C5.04218 15 4.15026 15.0008 3.43945 14.9238C2.71477 14.8453 2.08927 14.6789 1.5459 14.2842C1.22754 14.0529 0.94712 13.7725 0.715821 13.4541C0.321123 12.9107 0.154699 12.2852 0.0761723 11.5605C-0.000839256 10.8497 3.82375e-07 9.95782 3.82375e-07 8.84961V8.65039C3.82375e-07 7.54218 -0.000839256 6.65026 0.0761723 5.93945C0.154699 5.21477 0.321123 4.58927 0.715821 4.0459C0.947119 3.72754 1.22754 3.44712 1.5459 3.21582C2.08927 2.82112 2.71477 2.6547 3.43945 2.57617C4.15026 2.49916 5.04218 2.5 6.15039 2.5C6.2054 2.5 6.25 2.4554 6.25 2.40039V0.75C6.25 0.335787 6.58579 2.89917e-07 7 0Z" fill="#18181A"/> + <path d="M4.5 7.5C5.05229 7.5 5.5 7.94772 5.5 8.5C5.5 9.05229 5.05229 9.5 4.5 9.5C3.94772 9.5 3.5 9.05229 3.5 8.5C3.5 7.94772 3.94772 7.5 4.5 7.5Z" fill="#18181A"/> + <path d="M9.5 7.5C10.0523 7.5 10.5 7.94772 10.5 8.5C10.5 9.05229 10.0523 9.5 9.5 9.5C8.94771 9.5 8.5 9.05229 8.5 8.5C8.5 7.94772 8.94771 7.5 9.5 7.5Z" fill="#18181A"/> +</svg> +`.trim(); \ No newline at end of file diff --git a/apps/web/plugins/visual-editor/constants/layout.js b/apps/web/plugins/visual-editor/constants/layout.js new file mode 100644 index 0000000..cec79a7 --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/layout.js @@ -0,0 +1,26 @@ +/** Numeric spacing and sizing values (px) used to position editor UI. */ + +/** Gap (px) between a panel and its anchor element. */ +export const PANEL_GAP = 4; +/** Minimum margin (px) from the viewport edge when positioning panels. */ +export const PANEL_MARGIN = 8; +/** Reserved height (px) of the parent Horizons toolbar when positioning iframe UI. */ +export const PARENT_TOOLBAR_HEIGHT = 68; +/** Offset (px) from the cursor to the type tooltip's top-left corner. */ +export const TYPE_TOOLTIP_CURSOR_OFFSET = 12; +/** Minimum margin (px) from the viewport edge when clamping the type tooltip. */ +export const TOOLTIP_VIEWPORT_MARGIN = 4; +/** Border width (px) of the dashed hover outline drawn around the hovered element. */ +export const HOVER_OUTLINE_STROKE_WIDTH = 1; +/** Width (px) of the annotation panel. */ +export const ANNOTATION_PANEL_WIDTH = 320; +/** Estimated height (px) of the annotation panel for positioning calculations. */ +export const ANNOTATION_PANEL_ESTIMATED_HEIGHT = 184; +/** Pointer travel (px) from mousedown before a click becomes a drag-select. */ +export const DRAG_SELECT_THRESHOLD = 4; +/** Smallest side (px) an element may have to be worth drag-selecting. */ +export const MIN_SELECTION_CANDIDATE_SIZE = 8; +/** Elements one annotation may target. Matches the backend's cap so a drag can never build a payload the API rejects.*/ +export const MAX_SELECTED_ELEMENTS = 20; +/** Reference images across all annotations in one visual-editor turn. */ +export const MAX_ANNOTATION_ATTACHMENTS = 10; diff --git a/apps/web/plugins/visual-editor/constants/messages.js b/apps/web/plugins/visual-editor/constants/messages.js new file mode 100644 index 0000000..46090ba --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/messages.js @@ -0,0 +1,35 @@ +/** Message types sent from the iframe to the parent Horizons frame. */ +export const ParentMessage = { + EDIT_APPLIED: 'editApplied', + EDIT_CANCEL: 'editCancel', + EDIT_ENTER: 'editEnter', + IMAGE_EDIT_ENTER: 'imageEditEnter', + EDIT_STATE_CHANGED: 'editStateChanged', + EDIT_UNDO_APPLIED: 'undoApplied', + EDIT_REDO_APPLIED: 'redoApplied', + DRAFT_SAVE_STARTED: 'draftSaveStarted', + DRAFT_APPLIED: 'draftApplied', + DRAFT_SAVE_FAILED: 'draftSaveFailed', + DRAFT_SAVE_FINISHED: 'draftSaveFinished', + DRAFT_DISCARDED: 'draftDiscarded', + ANNOTATION_PANEL_OPENED: 'annotationPanelOpened', + ANNOTATION_IMAGE_ATTACH_REQUESTED: 'annotationImageAttachRequested', + ANNOTATION_IMAGE_REMOVED: 'annotationImageRemoved', + ANNOTATION_IMAGE_PREVIEW_REQUESTED: 'annotationImagePreviewRequested', +} + +/** Message types sent from the parent Horizons frame to the iframe. */ +export const ChildMessage = { + ENABLE_EDIT_MODE: 'enable-edit-mode', + DISABLE_EDIT_MODE: 'disable-edit-mode', + EDIT_SAVE: 'edit-save', + DRAFT_SAVE: 'draft-save', + DRAFT_DISCARD: 'draft-discard', + DRAFT_RESTORE: 'draft-restore', + DRAFT_SNAPSHOT_REQUEST: 'draft-snapshot-request', + EDIT_UNDO: 'edit-undo', + EDIT_REDO: 'edit-redo', + EDIT_CANCEL: 'edit-cancel', + ANNOTATION_OPEN_FOR_IMAGE: 'annotation-open-for-image', + ANNOTATION_IMAGE_ATTACHED: 'annotation-image-attached', +} diff --git a/apps/web/plugins/visual-editor/constants/selectors.js b/apps/web/plugins/visual-editor/constants/selectors.js new file mode 100644 index 0000000..8bbd150 --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/selectors.js @@ -0,0 +1,84 @@ +/** CSS properties captured in DOM context payloads sent to the parent frame. */ +export const IMPORTANT_STYLES = [ + 'display', + 'position', + 'flex-direction', + 'justify-content', + 'align-items', + 'width', + 'height', + 'padding', + 'margin', + 'border', + 'background-color', + 'color', + 'font-size', + 'font-weight', + 'font-family', + 'border-radius', + 'box-shadow', + 'gap', + 'grid-template-columns', +]; + +/** Maps logical element types to tag names (lowercase HTML and PascalCase components). */ +export const ELEMENT_TYPE_MAP = { + text: ['a', 'Link', 'p', 'span', 'time', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'Label', 'strong', 'em', 'b', 'i', 'u'], + button: ['button', 'Button'], + image: ['img'], +}; + +/** Hover type tooltip label maps to its translation key and default English label. */ +export const ELEMENT_TYPE_TRANSLATIONS = { + text: { key: 'elementTypeText', defaultLabel: 'Text' }, + button: { key: 'elementTypeButton', defaultLabel: 'Button' }, + image: { key: 'elementTypeImage', defaultLabel: 'Image' }, + block: { key: 'elementTypeBlock', defaultLabel: 'Block' }, +}; + +/** Selector for ancestors that use fixed/sticky positioning (affects overlay coordinates). */ +export const FIXED_CONTEXT_SELECTOR = 'nav, header, [style*="position: fixed"], [style*="position:fixed"], [style*="position: sticky"], [style*="position:sticky"]'; + +/** CSS selector matching the floating text-format toolbar and its action panels. */ +export const TOOLBAR_UI_SELECTOR = '#text-format-toolbar, #text-format-link-action, #text-format-size-action, #text-format-font-action, #text-format-color-action'; + +/** DOM id of the drag-select band drawn while rubber-band selecting. */ +export const SELECTION_RECT_ID = 'selection-mode-select-rect'; + +/** CSS selector matching the annotation panel, its pin markers and the drag-select band. */ +export const ANNOTATION_UI_SELECTOR = `#selection-mode-annotation-panel, .selection-mode-annotation-marker, #${SELECTION_RECT_ID}`; + +/** All floating editor UI (toolbar, annotation panel, pin markers). */ +export const EDITOR_UI_SELECTOR = `${TOOLBAR_UI_SELECTOR}, ${ANNOTATION_UI_SELECTOR}`; + +/** CSS selector matching any editable target (static or assisted). */ +export const EDIT_TARGET_SELECTOR = '[data-edit-id], [data-edit-assisted-id]'; + +/** + * Returns whichever edit-id attribute is present on the element. + * @param {HTMLElement} element + * @returns {string|null} + */ +export function getEditId(element) { + return element?.getAttribute('data-edit-id') || element?.getAttribute('data-edit-assisted-id') || null; +} + +/** Selector for actionable elements (pointer-events disabled in edit mode, see `EDIT_MODE_STYLES`). */ +export const ACTIONABLE_SELECTOR = 'a[href], button, [role="button"], [type="submit"]'; + +/** + * True while edit mode is active on the app root. + * @returns {boolean} + */ +export function isEditModeEnabled() { + return document.getElementById('root')?.getAttribute('data-edit-mode-enabled') === 'true'; +} + +/** + * True when the event target sits inside any floating editor UI (toolbar or action panels). + * @param {EventTarget|null} target + * @returns {boolean} + */ +export function isInsideEditorUi(target) { + return !!target?.closest?.(EDITOR_UI_SELECTOR); +} diff --git a/apps/web/plugins/visual-editor/constants/theme.js b/apps/web/plugins/visual-editor/constants/theme.js new file mode 100644 index 0000000..5e9dd59 --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/theme.js @@ -0,0 +1,50 @@ +/** Visual design tokens (colors, z-index, shadows) for the editor UI. */ + +/** Background color for floating panels and toolbars. */ +export const PANEL_BG = '#1D1E20'; +/** Background color for inset input surfaces inside panels (textareas, fields). */ +export const PANEL_INPUT_BG = '#262831'; +/** Border color for panel borders and dividers. */ +export const BORDER_COLOR = '#3B3D4A'; +/** Translucent white border (#fff at 20% opacity) for subtle on-dark outlines. */ +export const BORDER_COLOR_TRANSLUCENT = '#ffffff33'; +/** Border color for focused inputs: a touch brighter than the default, not white. */ +export const BORDER_COLOR_FOCUS = '#6B6E7E'; +/** Subtle hover background for interactive panel controls. */ +export const HOVER_BG = '#ffffff0d'; +/** Active/pressed background for panel controls. */ +export const ACTIVE_BG = '#2a2b2f'; +/** Default foreground color on dark panels. */ +export const COLOR_WHITE = '#ffffff'; +/** Secondary foreground on dark panels (#fff at 50% opacity) for labels and placeholders. */ +export const TEXT_MUTED = '#ffffff80'; +/** Foreground color for light (white-filled) buttons. */ +export const BUTTON_LIGHT_TEXT = '#161718'; +/** Hover background for light (white-filled) buttons. */ +export const BUTTON_LIGHT_BG_HOVER = '#E5E5E7'; +/** Outline color for editable targets in edit mode. */ +export const EDIT_BORDER_COLOR = '#673DE6'; +/** Hover background for editable targets in edit mode. */ +export const EDIT_BACKGROUND_COLOR = '#673DE61A'; +/** Accent color for annotation panel buttons and markers. */ +export const ANNOTATION_COLOR = '#673DE6'; +/** Hover state for annotation buttons and markers. */ +export const ANNOTATION_COLOR_HOVER = '#5025D1'; +/** Placeholder text color in panel inputs. */ +export const PLACEHOLDER_COLOR = '#D1D5DC'; +/** Scrollbar thumb on dark panel surfaces. Matches the chat's `--scrollbar-thumb`. */ +export const SCROLLBAR_THUMB = 'rgba(255, 255, 255, 0.28)'; +/** Hovered scrollbar thumb. Matches the chat's `--scrollbar-thumb-hover`. */ +export const SCROLLBAR_THUMB_HOVER = 'rgba(255, 255, 255, 0.42)'; + +/** Browser int32 max; toolbar, dropdowns, and annotation panel. */ +export const Z_INDEX_EDITOR_PANEL = 2147483647; +/** Overlays: element-type tooltip and drag-select band. */ +export const Z_INDEX_EDITOR_OVERLAY = Z_INDEX_EDITOR_PANEL - 1; +/** Annotation markers. */ +export const Z_INDEX_EDITOR_MARKER = Z_INDEX_EDITOR_PANEL - 2; + +/** Box shadow for dropdown panels. */ +export const BOX_SHADOW_DROPDOWN = '0 4px 16px 0 rgba(0, 0, 0, 0.30)'; +/** Box shadow for floating editor surfaces: inline toolbars and panels. */ +export const BOX_SHADOW_TOOLBAR = '0 1px 4px 0 rgba(0, 0, 0, 0.10)'; diff --git a/apps/web/plugins/visual-editor/edit-mode-script.js b/apps/web/plugins/visual-editor/edit-mode-script.js new file mode 100644 index 0000000..85f8f10 --- /dev/null +++ b/apps/web/plugins/visual-editor/edit-mode-script.js @@ -0,0 +1,812 @@ +import { startInlineEdit, commitCurrentEdit, placeCursorAtPoint } from "./ui/inline-edit/edit-action.js"; +import { getEditing } from "./state/editing-state.js"; +import { getElementType, isInFixedContext, isolateEditorUiEvents, getOpenModals } from "./utils/dom-utils.js"; +import { postToParent, ALLOWED_PARENT_ORIGINS } from "./utils/parent-frame.js"; +import { ParentMessage, ChildMessage } from "./constants/messages.js"; +import { isEditModeEnabled, isInsideEditorUi, ACTIONABLE_SELECTOR, EDIT_TARGET_SELECTOR, getEditId } from "./constants/selectors.js"; +import { recordEdit, reapplyCommittedEdits, getEditState } from "./state/history-state.js"; +import { applySessionUndo, applySessionRedo } from "./utils/session-history-command.js"; +import { handleDraftSave, handleDraftDiscard, discardAllEdits } from "./api/draft.js"; +import { applyDraftSnapshot, notifyDraftStateChanged } from "./api/draft-snapshot.js"; +import { patchReactDomMutations } from "./utils/html-utils.js"; +import { showTypeTooltip, hideTypeTooltip } from "./ui/overlays/type-tooltip.js"; +import { openOrCreateAnnotation, getAnnotationPanelEl, hideAnnotationPanel, openPanelForEdit, addPendingAttachments } from "./ui/annotation-panel/panel.js"; +import { clearAllAnnotationMarkers, refreshAnnotationMarkers } from "./ui/annotation-panel/annotation-markers.js"; +import { setEditorTranslations, setComments, getPendingElements } from "./state/annotation-state.js"; +import { captureElementMetadata } from "./utils/selection-mode-metadata.js"; +import { hideTextFormatToolbar } from "./ui/text-format/toolbar/toolbar.js"; +import { hideAllPanels } from "./state/panel-state.js"; +import { lockHoverOutline, unlockHoverOutline } from "./ui/overlays/hover-outline.js"; +import { + DRAG_SELECT_THRESHOLD, MAX_SELECTED_ELEMENTS, + ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT, PANEL_GAP, +} from "./constants/layout.js"; +import { + getSelectedElements, setSelectedElements, clearSelectedElements, + getDragStartX, getDragStartY, setDragStart, clearDragStart, + getIsDragging, setIsDragging, + getHadDrag, setHadDrag, + isDragPending, resetMultiSelectState, toggleSelectedElement, orderSelection, + pruneDetachedSelection, +} from "./state/multi-select-state.js"; +import { + getSelectionRect, collectSelection, NO_TEXT_SELECT_CLASS, + showSelectionRect, hideSelectionRect, destroySelectionRect, +} from "./ui/overlays/selection-rect.js"; + +let globalClickHandler = null; + +/** Tracks the element behind the parent image picker (back-compat `edit-save` flow). */ +let currentImageEdit = null; + +let lastCursorClientX = 0; +let lastCursorClientY = 0; +let typeTooltipAnimationFrameId = null; +/** True right after a mousedown until the next genuine mousemove, so a drag doesn't drag the tooltip too. */ +let suppressTypeTooltipUntilMove = false; +/** Caches the last raw `elementFromPoint` hit to skip the pointer-events workaround below when unchanged. */ +let lastRawHitTestElement = null; +let lastResolvedTypeTooltipTarget = null; + +/* ------------------------------------------------------------------ * + * Editable target lookup + * ------------------------------------------------------------------ */ + +function findEditableElementAtPoint(event) { + const direct = event.target.closest(EDIT_TARGET_SELECTOR); + if (direct) return direct; + + if (isInFixedContext(event.target)) return null; + + const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY); + const found = elementsAtPoint.find( + (element) => element !== event.target && (element.hasAttribute("data-edit-id") || element.hasAttribute("data-edit-assisted-id")) + ); + return found || null; +} + +/* ------------------------------------------------------------------ * + * Image edit (parent picker) — kept as the existing message contract + * ------------------------------------------------------------------ */ + +function startImageEdit(targetElement, editId) { + currentImageEdit = { editId, targetElement }; + lockHoverOutline(targetElement); + postToParent(ParentMessage.IMAGE_EDIT_ENTER, { + currentText: targetElement.getAttribute('src') || '', + elementType: 'img', + isDirectlyEditable: targetElement.hasAttribute('data-edit-id'), + }); +} + +/** Clears the in-progress image edit and drops its locked outline. */ +function clearImageEdit() { + if (!currentImageEdit) return; + currentImageEdit = null; + unlockHoverOutline(); +} + +/** + * Dismisses an active image selection and tells the parent to close the image + * picker popup (e.g. when the user clicks away to another element or empty space). + */ +function cancelImageEdit() { + if (!currentImageEdit) return; + clearImageEdit(); + postToParent(ParentMessage.EDIT_CANCEL, {}); +} + +/** + * The parent image picker drives image replacement via `edit-save`. The change + * is applied to the live DOM and recorded in history; it is written to source + * only on an explicit draft save. + */ +function handleImageEditSave(newText) { + if (!currentImageEdit) return; + const { editId, targetElement } = currentImageEdit; + const beforeContent = targetElement.getAttribute('src'); + const afterContent = newText ?? ""; + const isAssisted = targetElement.hasAttribute('data-edit-assisted-id'); + const selectionMode = captureElementMetadata(targetElement); + targetElement.setAttribute('src', afterContent); + recordEdit(editId, { beforeContent, afterContent }, { + attribute: 'src', + element: targetElement, + isAssisted, + selectionMode, + }); + clearImageEdit(); + notifyDraftStateChanged(); +} + +/* ------------------------------------------------------------------ * + * Group selection (drag band + shift+click) + * ------------------------------------------------------------------ */ + +let dragPreviewFrameId = null; + +/** Drops the group and the outlines marking it. */ +function clearMultiSelection() { + clearSelectedElements(); + unlockHoverOutline(); +} + +/** Element under the cursor that a group may target, or null for empty space. */ +function resolveGroupTarget(event) { + const rawTarget = document.elementFromPoint(event.clientX, event.clientY); + if (!rawTarget || isPageRoot(rawTarget) || isInsideEditorUi(rawTarget)) return null; + + const resolved = resolveHoverTarget(rawTarget, event.clientX, event.clientY); + return resolved && !isPageRoot(resolved) ? resolved : null; +} + +/** True when the click landed on a member of the group (or inside one). */ +function isGroupMember(group, target) { + return !!target && group.some(member => member === target || member.contains(target)); +} + +/** + * A drag takes over the whole surface, so every other floating affordance — + * toolbar dropdowns, the text toolbar, an open annotation panel, the image + * picker — gets out of the way before the band is drawn. + */ +function beginDragSelect(event) { + setIsDragging(true); + hideAllPanels(); + hideTextFormatToolbar(); + // A shift+drag extends the group, so the panel must not take it down with it. + hideAnnotationPanel({ restoreFocus: false, keepSelection: event.shiftKey }); + if (getEditing()) commitCurrentEdit(); + hideTypeTooltipImmediately(); + cancelImageEdit(); + if (!event.shiftKey) clearMultiSelection(); +} + +/** Ends the gesture and tears the band down, whether it completed or was abandoned. */ +function endDragSelect() { + clearDragStart(); + setIsDragging(false); + if (dragPreviewFrameId !== null) { + cancelAnimationFrame(dragPreviewFrameId); + dragPreviewFrameId = null; + } + hideSelectionRect(); + document.body.classList.remove(NO_TEXT_SELECT_CLASS); +} + +function cancelDragSelect() { + if (!isDragPending()) return; + endDragSelect(); + clearMultiSelection(); +} + +function handleVisibilityChange() { + if (document.visibilityState === "hidden" && getEditing()) { + commitCurrentEdit(); + } +} + +function handleSelectMouseDown(event) { + setHadDrag(false); + if (!isEditModeEnabled()) return; + if (event.button !== 0 || isInsideEditorUi(event.target)) return; + // Text selection inside the active inline edit stays native. + if (getEditing()?.targetElement?.contains(event.target)) return; + + // Suppress all native pointerdown side-effects (focus, text selection, form + // validation) so the host app never reacts to clicks during edit mode. + event.preventDefault(); + + document.body.classList.add(NO_TEXT_SELECT_CLASS); + if (event.shiftKey) { + window.getSelection()?.removeAllRanges(); + } + + setDragStart(event.clientX, event.clientY); +} + +/** + * Recomputes the previewed group at most once per frame — `collectSelection` + * walks the page and measures every candidate, which is far too heavy to run on + * every mousemove of a drag. + */ +function scheduleDragPreview(clientX, clientY, additive) { + if (dragPreviewFrameId !== null) return; + dragPreviewFrameId = requestAnimationFrame(() => { + dragPreviewFrameId = null; + if (!getIsDragging()) return; + + const preview = collectSelection(getSelectionRect(getDragStartX(), getDragStartY(), clientX, clientY)); + lockHoverOutline(orderSelection(additive ? [...getSelectedElements(), ...preview] : preview)); + }); +} + +function handleSelectMouseMove(event) { + if (!isDragPending()) return; + + const startX = getDragStartX(); + const startY = getDragStartY(); + + if (!getIsDragging()) { + const movedFarEnough = Math.abs(event.clientX - startX) > DRAG_SELECT_THRESHOLD + || Math.abs(event.clientY - startY) > DRAG_SELECT_THRESHOLD; + if (!movedFarEnough) return; + beginDragSelect(event); + } + + event.preventDefault(); + showSelectionRect(startX, startY, event.clientX, event.clientY); + scheduleDragPreview(event.clientX, event.clientY, event.shiftKey); +} + +function handleSelectMouseUp(event) { + if (!isDragPending()) return; + + const startX = getDragStartX(); + const startY = getDragStartY(); + const wasDragging = getIsDragging(); + endDragSelect(); + if (!wasDragging) return; + + event.preventDefault(); + event.stopPropagation(); + // The browser still delivers a click for this gesture; handleClick drops it. + setHadDrag(true); + + const picked = collectSelection(getSelectionRect(startX, startY, event.clientX, event.clientY)); + const group = orderSelection(event.shiftKey ? [...getSelectedElements(), ...picked] : picked) + .slice(0, MAX_SELECTED_ELEMENTS); + setSelectedElements(group); + + if (!group.length) { + clearMultiSelection(); + return; + } + openOrCreateAnnotation(group, event.clientX, event.clientY); +} + +/** Escape drops a group that no panel is holding; an open panel owns the key itself. */ +function handleSelectionKeydown(event) { + if (event.key !== 'Escape' || !getSelectedElements().length) return; + if (getAnnotationPanelEl()?.classList.contains('active')) return; + clearMultiSelection(); +} + +/* ------------------------------------------------------------------ * + * Click routing + * ------------------------------------------------------------------ */ + +function handleClick(event) { + if (!isEditModeEnabled()) return; + + if (event.target.closest?.("a[href]")) { + event.preventDefault(); + } + + if (isInsideEditorUi(event.target)) { + cancelImageEdit(); + return; + } + + // Action: drop the click a completed drag leaves behind. + if (getHadDrag()) { + setHadDrag(false); + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + + // Action: shift+click adds or removes one element from the group. The panel + // stays shut — it opens on the following plain click on a member. + if (event.shiftKey) { + const target = resolveGroupTarget(event); + if (!target) return; + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + hideTypeTooltipImmediately(); + cancelImageEdit(); + if (getEditing()) { + hideTextFormatToolbar(); + commitCurrentEdit(); + } + hideAnnotationPanel({ restoreFocus: false, keepSelection: true }); + + // At the cap, removing still works but adding is ignored — silently + // dropping some other member to make room would be worse. + const selected = getSelectedElements(); + if (selected.includes(target) || selected.length < MAX_SELECTED_ELEMENTS) { + toggleSelectedElement(target); + } + lockHoverOutline(getSelectedElements()); + return; + } + + // Action: open the group's panel when the click lands on one of its members; + // anything else drops the group and falls through to single-element routing. + const group = getSelectedElements(); + if (group.length) { + if (isGroupMember(group, resolveGroupTarget(event))) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + hideTypeTooltipImmediately(); + cancelImageEdit(); + if (getEditing()) commitCurrentEdit(); + hideAnnotationPanel({ restoreFocus: false, keepSelection: true }); + + openOrCreateAnnotation(group, event.clientX, event.clientY); + return; + } + clearMultiSelection(); + } + + // Action: dismiss annotation panel on outside click. + if (getAnnotationPanelEl()?.classList.contains('active')) { + hideAnnotationPanel(); + hideTextFormatToolbar(); + if (getEditing()) { + commitCurrentEdit(); + } + if (!findEditableElementAtPoint(event)) { + return; + } + } + + const editing = getEditing(); + + // Action: place cursor inside the active contenteditable on click. + if (editing?.targetElement?.hasAttribute("contenteditable") && editing.targetElement.contains(event.target)) { + event.stopPropagation(); + event.stopImmediatePropagation(); + const link = event.target.closest?.("a"); + if (link && editing.targetElement.contains(link)) { + event.preventDefault(); + if (event.detail <= 1) { + placeCursorAtPoint(event.clientX, event.clientY, editing.targetElement); + } + } + return; + } + + // Action: start inline edit or image edit on an editable element. + const editable = findEditableElementAtPoint(event); + + if (editable) { + if (editing && editing.targetElement === editable) { + commitCurrentEdit(); + return; + } + + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + + const editId = getEditId(editable); + if (!editId) return; + + hideTypeTooltipImmediately(); + + if (editable?.tagName?.toLowerCase() === 'img') { + if (editing) commitCurrentEdit(); + startImageEdit(editable, editId); + return; + } + + if (editing) { + commitCurrentEdit(); + } + clearImageEdit(); + startInlineEdit(editable, editId, editable.innerHTML || ""); + return; + } + + // Action: commit any active inline edit and stop. + if (editing) { + commitCurrentEdit(); + return; + } + + // Action: open annotation panel for non-editable elements (blocks, icon-only buttons/links, etc.). + const rawTarget = document.elementFromPoint(event.clientX, event.clientY); + if (rawTarget && !isPageRoot(rawTarget)) { + const hoverTarget = resolveHoverTarget(rawTarget, event.clientX, event.clientY); + const hoverType = hoverTarget && getElementType(hoverTarget); + if (hoverTarget && !isPageRoot(hoverTarget) && (hoverType === 'block' || hoverType === 'button')) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + hideTypeTooltipImmediately(); + cancelImageEdit(); + + postToParent(ParentMessage.ANNOTATION_PANEL_OPENED, { elementType: hoverTarget.tagName.toLowerCase() }); + openOrCreateAnnotation(hoverTarget, event.clientX, event.clientY); + return; + } + } + + // Action: close the image picker when clicking empty space. + cancelImageEdit(); + + if (event.target.closest("a[href], button, [role='button'], [type='submit']")) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } +} + +/* ------------------------------------------------------------------ * + * Hover type tooltip (element type near the cursor) + * ------------------------------------------------------------------ */ + +/** Finds the deepest actionable descendant of `container` still under `(clientX, clientY)`, or `container` itself. */ +function resolveActionableDescendantAtPoint(container, clientX, clientY) { + let deepest = container; + for (const candidate of container.querySelectorAll(ACTIONABLE_SELECTOR)) { + const bounds = candidate.getBoundingClientRect(); + if (clientX >= bounds.left && clientX <= bounds.right && clientY >= bounds.top && clientY <= bounds.bottom) { + deepest = candidate; + } + } + return deepest; +} + +/** Hides the type tooltip and drops any frame scheduled to redisplay it. */ +function hideTypeTooltipImmediately() { + if (typeTooltipAnimationFrameId !== null) { + cancelAnimationFrame(typeTooltipAnimationFrameId); + typeTooltipAnimationFrameId = null; + } + hideTypeTooltip(); +} + +/** Resolves the element under the cursor to highlight: + * an editable or `<svg>` ancestor wins, else the raw target. */ +function resolveHoverTarget(rawTarget, clientX, clientY) { + const editableAncestor = rawTarget.closest(EDIT_TARGET_SELECTOR); + if (editableAncestor) return resolveActionableDescendantAtPoint(editableAncestor, clientX, clientY); + + const svgAncestor = rawTarget.closest('svg'); + if (svgAncestor) return svgAncestor; + + return resolveActionableDescendantAtPoint(rawTarget, clientX, clientY); +} + +/** True for the page's own root containers, which are too broad to be a meaningful hover target. */ +function isPageRoot(element) { + return element === document.body || element === document.documentElement || element.id === 'root'; +} + +function updateTypeTooltip() { + // A drag keeps firing mousemove, which would otherwise clear the + // suppress-until-move flag and flash the tooltip over the band. + if (!isEditModeEnabled() || suppressTypeTooltipUntilMove || getIsDragging()) { + hideTypeTooltip(); + return; + } + + const rawTarget = document.elementFromPoint(lastCursorClientX, lastCursorClientY); + if (!rawTarget || isInsideEditorUi(rawTarget) || isPageRoot(rawTarget)) { + hideTypeTooltip(); + return; + } + + const editing = getEditing(); + if (editing?.targetElement && (editing.targetElement === rawTarget || editing.targetElement.contains(rawTarget))) { + hideTypeTooltip(); + return; + } + + if (rawTarget !== lastRawHitTestElement) { + lastRawHitTestElement = rawTarget; + lastResolvedTypeTooltipTarget = resolveHoverTarget(rawTarget, lastCursorClientX, lastCursorClientY); + } + + showTypeTooltip(lastResolvedTypeTooltipTarget, lastCursorClientX, lastCursorClientY); +} + +/** Throttles type-tooltip recomputation to at most once per animation frame. */ +function scheduleTypeTooltipUpdate() { + if (typeTooltipAnimationFrameId !== null) return; + typeTooltipAnimationFrameId = requestAnimationFrame(() => { + typeTooltipAnimationFrameId = null; + updateTypeTooltip(); + }); +} + +function handleTypeTooltipMouseMove(event) { + lastCursorClientX = event.clientX; + lastCursorClientY = event.clientY; + suppressTypeTooltipUntilMove = false; + scheduleTypeTooltipUpdate(); +} + +/** Wheel-scroll fires no `mousemove`, so recompute from the last known cursor position. */ +function handleTypeTooltipScroll() { + if (!isEditModeEnabled()) return; + scheduleTypeTooltipUpdate(); +} + +/** Suppresses the tooltip for the duration of a text-selection or element drag. */ +function handleTypeTooltipMouseDown() { + suppressTypeTooltipUntilMove = true; + hideTypeTooltipImmediately(); +} + +/* ------------------------------------------------------------------ * + * Annotation ↔ DOM sync (client-side route changes, re-renders) + * ------------------------------------------------------------------ */ + +let annotationDomObserver = null; +let annotationDomSyncFrameId = null; + +/** + * Re-anchors markers to their (re-mounted) elements and hides the ones whose + * element left the page, so a comment made on one route stays on that route. + * Also re-applies unsaved edits to any element a route change or React + * re-render replaced, so they don't visually revert to the unedited source. + */ +function syncAnnotationsWithDom() { + annotationDomSyncFrameId = null; + reapplyCommittedEdits(); + refreshAnnotationMarkers(); + + // A group only ever spans one page: navigating away unmounts its members, and + // an outline can't be drawn for an element that is no longer there. + if (pruneDetachedSelection()) { + const group = getSelectedElements(); + if (group.length) lockHoverOutline(group); + else clearMultiSelection(); + } + + const pendingElements = getPendingElements(); + if (pendingElements.length + && pendingElements.every(element => !element.isConnected) + && getAnnotationPanelEl()?.classList.contains('active')) { + hideAnnotationPanel({ restoreFocus: false }); + hideTextFormatToolbar(); + } +} + +/** Throttles the annotation sync to at most once per animation frame. */ +function scheduleAnnotationDomSync() { + if (annotationDomSyncFrameId !== null) return; + annotationDomSyncFrameId = requestAnimationFrame(syncAnnotationsWithDom); +} + +/* ------------------------------------------------------------------ * + * Undo / redo keyboard shortcuts + * ------------------------------------------------------------------ */ + +/** True for native text-editing surfaces where the browser should own undo. */ +function isNativeEditingContext(target) { + if (!target) return false; + const tag = target.tagName?.toLowerCase(); + return tag === "input" || tag === "textarea" || !!target.isContentEditable; +} + +function handleHistoryKeydown(event) { + if (!isEditModeEnabled()) return; + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return; + + // While an inline edit is active, let the browser handle native text undo + // inside the contenteditable region instead of stepping the edit history. + if (getEditing()?.targetElement?.hasAttribute("contenteditable") || isNativeEditingContext(event.target)) { + return; + } + + event.preventDefault(); + if (event.shiftKey) { + applySessionRedo(); + } else { + applySessionUndo(); + } +} + +/* Keeps host-page modals open and host-page key handlers quiet in edit mode. */ + +function handleModalPointerDownGuard(event) { + if (isInsideEditorUi(event.target)) return; + + const openModals = getOpenModals(); + if (!openModals.length) return; + if (openModals.some((modal) => modal.contains(event.target))) return; + + event.stopImmediatePropagation(); +} + +/** + * Registered on window capture after the editor's own key handlers: `stopPropagation` + * still lets those sibling window listeners run, but nothing on document or below + * (host shortcuts, dismiss-on-Escape) ever sees the key. + */ +function handleHostKeyGuard(event) { + if (isInsideEditorUi(event.target) || getEditing()?.targetElement?.contains(event.target)) { + // Dismissable layers honour `defaultPrevented`, so an Escape aimed at the editor stays with it. + if (event.key === "Escape") event.preventDefault(); + return; + } + + event.stopPropagation(); +} + +function handleEditorFocusGuard(event) { + if (isInsideEditorUi(event.target) || isInsideEditorUi(event.relatedTarget)) { + event.stopImmediatePropagation(); + } +} + +/* ------------------------------------------------------------------ * + * Enable / disable edit mode + * ------------------------------------------------------------------ */ + +function enableEditMode() { + // Guard React reconciliation before any inline edit rewrites DOM subtrees. + patchReactDomMutations(); + + document.getElementById("root")?.setAttribute("data-edit-mode-enabled", "true"); + + if (!globalClickHandler) { + globalClickHandler = handleClick; + document.addEventListener("click", globalClickHandler, true); + } + + window.addEventListener("keydown", handleHistoryKeydown, true); + window.addEventListener("keydown", handleSelectionKeydown, true); + window.addEventListener("keydown", handleHostKeyGuard, true); + window.addEventListener("keyup", handleHostKeyGuard, true); + + document.addEventListener("mousemove", handleTypeTooltipMouseMove, true); + document.addEventListener("mousedown", handleTypeTooltipMouseDown, true); + document.addEventListener("dragstart", hideTypeTooltipImmediately, true); + document.addEventListener("mouseleave", hideTypeTooltipImmediately); + document.addEventListener("scroll", handleTypeTooltipScroll, true); + window.addEventListener("blur", hideTypeTooltipImmediately); + + document.addEventListener("mousedown", handleSelectMouseDown, true); + document.addEventListener("mousemove", handleSelectMouseMove, true); + document.addEventListener("mouseup", handleSelectMouseUp, true); + // A mouseup outside the iframe never reaches us, so the band would stay up. + window.addEventListener("blur", cancelDragSelect); + document.addEventListener("visibilitychange", handleVisibilityChange); + + window.addEventListener("pointerdown", handleModalPointerDownGuard, true); + window.addEventListener("pointerup", handleModalPointerDownGuard, true); + window.addEventListener("focusin", handleEditorFocusGuard, true); + window.addEventListener("focusout", handleEditorFocusGuard, true); + + if (!annotationDomObserver && typeof MutationObserver !== "undefined") { + annotationDomObserver = new MutationObserver(scheduleAnnotationDomSync); + annotationDomObserver.observe(document.body, { childList: true, subtree: true }); + } + + postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState() }); +} + +function disableEditMode() { + const editing = getEditing(); + if (editing?.targetElement?.hasAttribute("contenteditable")) { + commitCurrentEdit(); + } + + document.getElementById("root")?.removeAttribute("data-edit-mode-enabled"); + + currentImageEdit = null; + hideAnnotationPanel(); + annotationDomObserver?.disconnect(); + annotationDomObserver = null; + if (annotationDomSyncFrameId !== null) { + cancelAnimationFrame(annotationDomSyncFrameId); + annotationDomSyncFrameId = null; + } + clearAllAnnotationMarkers(); + setComments([]); + + if (globalClickHandler) { + document.removeEventListener("click", globalClickHandler, true); + globalClickHandler = null; + } + + window.removeEventListener("keydown", handleHistoryKeydown, true); + window.removeEventListener("keydown", handleSelectionKeydown, true); + window.removeEventListener("keydown", handleHostKeyGuard, true); + window.removeEventListener("keyup", handleHostKeyGuard, true); + + document.removeEventListener("mousemove", handleTypeTooltipMouseMove, true); + document.removeEventListener("mousedown", handleTypeTooltipMouseDown, true); + document.removeEventListener("dragstart", hideTypeTooltipImmediately, true); + document.removeEventListener("mouseleave", hideTypeTooltipImmediately); + document.removeEventListener("scroll", handleTypeTooltipScroll, true); + window.removeEventListener("blur", hideTypeTooltipImmediately); + + document.removeEventListener("mousedown", handleSelectMouseDown, true); + document.removeEventListener("mousemove", handleSelectMouseMove, true); + document.removeEventListener("mouseup", handleSelectMouseUp, true); + window.removeEventListener("blur", cancelDragSelect); + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("pointerdown", handleModalPointerDownGuard, true); + window.removeEventListener("pointerup", handleModalPointerDownGuard, true); + window.removeEventListener("focusin", handleEditorFocusGuard, true); + window.removeEventListener("focusout", handleEditorFocusGuard, true); + endDragSelect(); + destroySelectionRect(); + resetMultiSelectState(); + unlockHoverOutline(); + hideTypeTooltipImmediately(); + suppressTypeTooltipUntilMove = false; + lastRawHitTestElement = null; + lastResolvedTypeTooltipTarget = null; + + discardAllEdits(); + postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState() }); +} + +/* ------------------------------------------------------------------ * + * Parent <-> iframe messaging + * ------------------------------------------------------------------ */ + +window.addEventListener("message", function (event) { + if (!ALLOWED_PARENT_ORIGINS.includes(event.origin)) return; + + if (event.data?.type === ChildMessage.ENABLE_EDIT_MODE) { + if (event.data?.translations) { + setEditorTranslations(event.data.translations); + } + enableEditMode(); + } + + if (event.data?.type === ChildMessage.DISABLE_EDIT_MODE) { + disableEditMode(); + } + + // Text editing now happens in-iframe via contenteditable; `edit-save` only + // remains to keep the existing parent-driven image picker working. + if (event.data?.type === ChildMessage.EDIT_SAVE) { + handleImageEditSave(event.data?.payload?.newText); + } + + if (event.data?.type === ChildMessage.EDIT_CANCEL) { + clearImageEdit(); + } + + if (event.data?.type === ChildMessage.DRAFT_SAVE) { + handleDraftSave(); + } + + if (event.data?.type === ChildMessage.DRAFT_DISCARD) { + handleDraftDiscard(); + } + + if (event.data?.type === ChildMessage.DRAFT_RESTORE) { + applyDraftSnapshot(event.data?.payload?.snapshot, { onMarkerClick: openPanelForEdit }); + postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState(), isDraftRestored: true }); + } + + if (event.data?.type === ChildMessage.DRAFT_SNAPSHOT_REQUEST) { + notifyDraftStateChanged(); + } + + if (event.data?.type === ChildMessage.EDIT_UNDO) { + applySessionUndo(); + } + + if (event.data?.type === ChildMessage.EDIT_REDO) { + applySessionRedo(); + } + + if (event.data?.type === ChildMessage.ANNOTATION_IMAGE_ATTACHED) { + addPendingAttachments(event.data?.payload?.attachments); + } + + if (event.data?.type === ChildMessage.ANNOTATION_OPEN_FOR_IMAGE) { + if (!currentImageEdit) return; + const { targetElement } = currentImageEdit; + clearImageEdit(); + + const clickX = (window.innerWidth - ANNOTATION_PANEL_WIDTH) / 2; + const clickY = (window.innerHeight - ANNOTATION_PANEL_ESTIMATED_HEIGHT) / 2 - PANEL_GAP; + openOrCreateAnnotation(targetElement, clickX, clickY); + } +}); diff --git a/apps/web/plugins/visual-editor/server/source-writer.js b/apps/web/plugins/visual-editor/server/source-writer.js new file mode 100644 index 0000000..8571931 --- /dev/null +++ b/apps/web/plugins/visual-editor/server/source-writer.js @@ -0,0 +1,423 @@ +import path from 'path'; +import traverseBabel from '@babel/traverse'; +import * as t from '@babel/types'; +import { + validateFilePath, + findJSXElementAtPosition, + generateSourceWithMap, + VITE_PROJECT_ROOT, +} from '../../utils/ast-utils.js'; +import { decodeHtmlEntities, cssPropToCamel, toJsxAttributeName } from '../utils/html-utils.js'; + +const WRITABLE_ATTRIBUTE_TARGETS = new Set(['src', 'href', 'to']); +const JSX_STYLE_PROPERTY_PATTERN = /^[A-Za-z_$][\w$]*$/; + +/** + * Parses `filePath:line:column` edit IDs produced by the transform plugin. + * + * @param {string} editId + * @returns {{ filePath: string, line: number, column: number }|null} + */ +function parseEditId(editId) { + const segments = editId.split(':'); + if (segments.length < 3) return null; + const column = parseInt(segments.at(-1), 10); + const line = parseInt(segments.at(-2), 10); + const filePath = segments.slice(0, -2).join(':'); + if (!filePath || isNaN(line) || isNaN(column)) return null; + return { filePath, line, column }; +} + +/** + * Converts an HTML `style="..."` string into a Babel JSX style object expression. + * + * @param {string} styleString + * @returns {import('@babel/types').JSXExpressionContainer} + */ +function parseStyleString(styleString) { + const decoded = decodeHtmlEntities(styleString); + const properties = []; + for (const declaration of decoded.split(';')) { + const colonIndex = declaration.indexOf(':'); + if (colonIndex < 0) continue; + const key = declaration.slice(0, colonIndex).trim(); + const value = declaration.slice(colonIndex + 1).trim(); + if (!key || !value) continue; + properties.push( + t.objectProperty(t.identifier(cssPropToCamel(key)), t.stringLiteral(value)) + ); + } + return t.jsxExpressionContainer(t.objectExpression(properties)); +} + +/** + * Parses HTML attribute string from saved innerHTML into JSX attribute nodes. + * Skips `data-edit-*` attributes; maps names via {@link toJsxAttributeName}. + * + * @param {string} rawAttributes + * @returns {import('@babel/types').JSXAttribute[]} + */ +function parseHtmlAttributesToJsx(rawAttributes) { + if (!rawAttributes || !rawAttributes.trim()) return []; + const attributes = []; + const ATTRIBUTE_REGEX = /([\w-]+)\s*=\s*"([^"]*)"/g; + let match; + while ((match = ATTRIBUTE_REGEX.exec(rawAttributes)) !== null) { + const [, rawName, value] = match; + // data-edit-* are injected by the visual-editor transform; never write them to source + if (rawName.startsWith('data-edit-')) continue; + const name = toJsxAttributeName(rawName); + if (name === 'style') { + attributes.push(t.jsxAttribute(t.jsxIdentifier(name), parseStyleString(value))); + } else { + attributes.push(t.jsxAttribute(t.jsxIdentifier(name), t.stringLiteral(decodeHtmlEntities(value)))); + } + } + return attributes; +} + +/** + * Rebuilds JSX children from saved innerHTML, reconstructing every tag structurally. + * Rendered `<svg>` blocks are matched back, in order, to `iconQueue` (original icon nodes). + * + * @param {string} html + * @param {import('@babel/types').JSXElement[]} iconQueue - consumed as rendered `<svg>` blocks are matched. + * @returns {import('@babel/types').JSXChild[]} + */ +function buildChildrenFromText(html, iconQueue) { + if (!html || html.trim() === '') return []; + + // Decode </> back to literal tags (not ", which TAG_REGEX relies on as a delimiter). + html = html.replace(/</g, '<').replace(/>/g, '>'); + + const TAG_REGEX = /<(\/?)(\w+)((?:\s+[\w-]+(?:\s*=\s*"[^"]*")?)*)\s*(\/?)>/g; + + const result = []; + const stack = [{ children: result }]; + let lastIndex = 0; + /** Depth inside a rendered `<svg>` block being skipped because it was matched to a preserved icon. */ + let skippedSvgDepth = 0; + let match; + + while ((match = TAG_REGEX.exec(html)) !== null) { + const [full, closing, tagName, rawAttributes, selfClose] = match; + const lower = tagName.toLowerCase(); + + if (skippedSvgDepth > 0) { + if (lower === 'svg' && selfClose !== '/') { + skippedSvgDepth += closing === '/' ? -1 : 1; + } + lastIndex = match.index + full.length; + continue; + } + + if (match.index > lastIndex) { + const text = html.slice(lastIndex, match.index); + if (text) stack[stack.length - 1].children.push(t.jsxText(text)); + } + lastIndex = match.index + full.length; + + if (closing === '/') { + if (stack.length > 1 && stack[stack.length - 1].tag === lower) { + const frame = stack.pop(); + const attributes = parseHtmlAttributesToJsx(frame.rawAttrs); + stack[stack.length - 1].children.push( + t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier(frame.tag), attributes), + t.jsxClosingElement(t.jsxIdentifier(frame.tag)), + frame.children + ) + ); + } + continue; + } + + if (lower === 'svg' && selfClose !== '/' && iconQueue.length > 0) { + stack[stack.length - 1].children.push(iconQueue.shift()); + skippedSvgDepth = 1; + continue; + } + + // Void elements (img, br, ...) never get a closing tag in serialized HTML; others always do. + const hasClosingTag = new RegExp(`</${lower}\\s*>`, 'i').test(html.slice(lastIndex)); + if (selfClose === '/' || !hasClosingTag) { + const attributes = parseHtmlAttributesToJsx(rawAttributes); + stack[stack.length - 1].children.push( + t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(lower), attributes, true), null, [], true) + ); + continue; + } + + stack.push({ tag: lower, children: [], rawAttrs: rawAttributes || '' }); + } + + if (lastIndex < html.length) { + const text = html.slice(lastIndex); + if (text) stack[stack.length - 1].children.push(t.jsxText(text)); + } + + // Auto-close any unclosed tags (defensive: malformed/truncated captured HTML). + while (stack.length > 1) { + const frame = stack.pop(); + const attributes = parseHtmlAttributesToJsx(frame.rawAttrs); + stack[stack.length - 1].children.push( + t.jsxElement( + t.jsxOpeningElement(t.jsxIdentifier(frame.tag), attributes), + t.jsxClosingElement(t.jsxIdentifier(frame.tag)), + frame.children + ) + ); + } + + return result; +} + +/** + * Merges or creates a JSX `style={{ ... }}` object on the opening element. + * An empty-string value means the editor removed the inline property, so the + * matching JSX style entry is dropped instead of written as `prop: ""`. + * + * @param {import('@babel/types').JSXOpeningElement} openingElement + * @param {Record<string, string>} styleObject + * @returns {boolean} Whether the style shape was safe to update. + */ +function setJsxStyleProperty(openingElement, styleObject) { + const existing = openingElement.attributes.find(attribute => + t.isJSXAttribute(attribute) && attribute.name?.name === 'style' + ); + if (existing && t.isJSXExpressionContainer(existing.value) && t.isObjectExpression(existing.value.expression)) { + const properties = existing.value.expression.properties; + for (const [key, value] of Object.entries(styleObject)) { + const propertyIndex = properties.findIndex(property => + t.isObjectProperty(property) && t.isIdentifier(property.key) && property.key.name === key + ); + if (!value) { + if (propertyIndex !== -1) properties.splice(propertyIndex, 1); + } else if (propertyIndex !== -1) { + properties[propertyIndex].value = t.stringLiteral(value); + } else { + properties.push(t.objectProperty(t.identifier(key), t.stringLiteral(value))); + } + } + return true; + } else if (!existing) { + const properties = Object.entries(styleObject) + .filter(([, value]) => !!value) + .map(([key, value]) => t.objectProperty(t.identifier(key), t.stringLiteral(value))); + if (properties.length === 0) return true; + openingElement.attributes.push( + t.jsxAttribute(t.jsxIdentifier('style'), t.jsxExpressionContainer(t.objectExpression(properties))) + ); + return true; + } + return false; +} + +/** + * Sets or removes a static JSX attribute. + * + * @param {import('@babel/types').JSXOpeningElement} openingElement + * @param {string} attributeName + * @param {string|null} value - `null` removes the attribute. + * @returns {{ modified: true }|{ modified: false, error: string }} + */ +function setJsxAttribute(openingElement, attributeName, value) { + const attributeIndex = openingElement.attributes.findIndex(attribute => + t.isJSXAttribute(attribute) && attribute.name?.name === attributeName + ); + const existingAttribute = openingElement.attributes[attributeIndex]; + + if (value === null) { + if (attributeIndex !== -1) openingElement.attributes.splice(attributeIndex, 1); + return { modified: true }; + } + + const newLiteral = t.stringLiteral(value); + if (!existingAttribute) { + openingElement.attributes.push(t.jsxAttribute(t.jsxIdentifier(attributeName), newLiteral)); + return { modified: true }; + } + if (t.isStringLiteral(existingAttribute.value)) { + existingAttribute.value = newLiteral; + return { modified: true }; + } + if (t.isJSXExpressionContainer(existingAttribute.value) && t.isStringLiteral(existingAttribute.value.expression)) { + existingAttribute.value = newLiteral; + return { modified: true }; + } + return { modified: false, error: `Cannot write '${attributeName}' — source value is not a static string literal` }; +} + +/** + * Parse + validate every draftKey; group valid edits by absolute file path; + * push error entries for invalid ones. + * @param {Record<string, object>} draft + * @returns {{ editsByFile: Map<string, object[]>, results: object[] }} + */ +export function groupEditsByFile(draft) { + const results = []; + const editsByFile = new Map(); + + for (const [draftKey, edit] of Object.entries(draft)) { + let editId = draftKey; + const atIndex = editId.indexOf('@'); + if (atIndex >= 0) editId = editId.slice(0, atIndex); + + const parsedId = parseEditId(editId); + if (!parsedId) { + results.push({ draftKey, success: false, error: 'Invalid editId format' }); + continue; + } + + const validation = validateFilePath(parsedId.filePath); + if (!validation.isValid) { + results.push({ draftKey, success: false, error: validation.error }); + continue; + } + + if (!editsByFile.has(validation.absolutePath)) { + editsByFile.set(validation.absolutePath, []); + } + editsByFile.get(validation.absolutePath).push({ draftKey, editId, edit, parsedId }); + } + + return { editsByFile, results }; +} + +/** + * Find the JSX opening element and apply its atomic content, attribute, and style changes. + * @param {object} babelAst + * @param {object} edit + * @param {{ filePath: string, line: number, column: number }} parsedId + * @returns {{ modified: true }|{ modified: false, error: string }} + */ +export function applyElementEdit(babelAst, edit, parsedId) { + const targetNodePath = findJSXElementAtPosition(babelAst, parsedId.line, parsedId.column + 1); + if (!targetNodePath) return { modified: false, error: 'Target node not found' }; + + const targetOpeningElement = targetNodePath.node; + const parentElementNode = targetNodePath.parentPath?.node; + const changes = edit.instruction?.changes; + if (!Array.isArray(changes) || changes.length === 0) { + return { modified: false, error: 'Edit has no changes' }; + } + + const contentChanges = changes.filter(change => change.kind === 'content'); + const attributeChanges = changes.filter(change => change.kind === 'attribute'); + const styleChanges = changes.filter(change => change.kind === 'style'); + if (contentChanges.length + attributeChanges.length + styleChanges.length !== changes.length) { + return { modified: false, error: 'Unsupported visual edit change kind' }; + } + if (contentChanges.length > 1) { + return { modified: false, error: 'Edit contains multiple content changes' }; + } + if (changes.some(change => + (change.before !== null && typeof change.before !== 'string') + || (change.after !== null && typeof change.after !== 'string') + )) { + return { modified: false, error: 'Visual edit change values must be strings or null' }; + } + if (contentChanges.some(change => change.target !== null)) { + return { modified: false, error: 'Content changes must have a null target' }; + } + if (contentChanges.length && (!parentElementNode || !t.isJSXElement(parentElementNode))) { + return { modified: false, error: 'Could not apply content change to AST' }; + } + if ([...attributeChanges, ...styleChanges].some(change => typeof change.target !== 'string' || !change.target)) { + return { modified: false, error: 'Attribute and style changes require a target' }; + } + if (attributeChanges.some(change => !WRITABLE_ATTRIBUTE_TARGETS.has(change.target))) { + return { modified: false, error: 'Unsupported visual edit attribute target' }; + } + if (styleChanges.some(change => !JSX_STYLE_PROPERTY_PATTERN.test(change.target))) { + return { modified: false, error: 'Unsupported visual edit style target' }; + } + if (styleChanges.length) { + const styleAttribute = targetOpeningElement.attributes.find(attribute => + t.isJSXAttribute(attribute) && attribute.name?.name === 'style' + ); + if ( + styleAttribute + && !( + t.isJSXExpressionContainer(styleAttribute.value) + && t.isObjectExpression(styleAttribute.value.expression) + ) + ) { + return { modified: false, error: 'Cannot write style — source value is not a static object literal' }; + } + } + + // Validate every attribute before mutating the AST so a later unsupported + // dynamic value cannot leave an earlier change partially applied. + for (const change of attributeChanges) { + const existingAttribute = targetOpeningElement.attributes.find(attribute => + t.isJSXAttribute(attribute) && attribute.name?.name === change.target + ); + const isWritable = !existingAttribute + || t.isStringLiteral(existingAttribute.value) + || ( + t.isJSXExpressionContainer(existingAttribute.value) + && t.isStringLiteral(existingAttribute.value.expression) + ); + if (!isWritable) { + return { + modified: false, + error: `Cannot write '${change.target}' — source value is not a static string literal`, + }; + } + } + + for (const change of attributeChanges) { + const result = setJsxAttribute(targetOpeningElement, change.target, change.after); + if (!result.modified) return result; + } + + if (contentChanges.length) { + // Icon components can't be rebuilt from rendered HTML, so their original nodes pass through. + const preservedIcons = parentElementNode.children.filter(child => + t.isJSXElement(child) + && child.openingElement.selfClosing + && child.openingElement.name?.name + && /^[A-Z]/.test(child.openingElement.name.name) + ); + parentElementNode.children = buildChildrenFromText(contentChanges[0].after ?? '', preservedIcons); + } + + if (styleChanges.length) { + const style = Object.fromEntries(styleChanges.map(change => [change.target, change.after ?? ''])); + if (!setJsxStyleProperty(targetOpeningElement, style)) { + return { modified: false, error: 'Cannot write style — source value is not a static object literal' }; + } + } + + return { modified: true }; +} + +/** + * Strip any lingering data-edit-* attributes from the AST and generate the new + * source, returning the result entry for the API response. + * + * Must never write to disk: `/api/draft-save` is reachable unauthenticated from + * the public preview origin, so persisting is the authenticated backend's job + * via `POST /api/websites/:id/visual-edits`. + * + * @param {object} babelAst + * @param {string} absoluteFilePath + * @param {string} originalContent + * @returns {{ filePath: string, newFileContent: string }} + */ +export function renderEditedSource(babelAst, absoluteFilePath, originalContent) { + // Strip any data-edit-* attributes before generating source. + const traverseFunction = traverseBabel.default || traverseBabel; + traverseFunction(babelAst, { + JSXOpeningElement(openingElementPath) { + openingElementPath.node.attributes = openingElementPath.node.attributes.filter(attribute => + !(t.isJSXAttribute(attribute) && typeof attribute.name?.name === 'string' + && attribute.name.name.startsWith('data-edit-')) + ); + }, + }); + const webRelativeFilePath = path.relative(VITE_PROJECT_ROOT, absoluteFilePath).split(path.sep).join('/'); + const output = generateSourceWithMap(babelAst, webRelativeFilePath, originalContent); + return { filePath: webRelativeFilePath, newFileContent: output.code }; +} diff --git a/apps/web/plugins/visual-editor/state/annotation-state.js b/apps/web/plugins/visual-editor/state/annotation-state.js new file mode 100644 index 0000000..955979a --- /dev/null +++ b/apps/web/plugins/visual-editor/state/annotation-state.js @@ -0,0 +1,60 @@ +/** + * A reference attachment the user added to a comment. + * @typedef {{ url: string, fileName: string, size: number, type: 'image' }} AnnotationAttachment + */ + +/** + * A comment always targets a group: `elements` and `selections` are parallel, + * one entry per annotated element. A single annotation is just a group of one. + * @typedef {{ + * elements: HTMLElement[], + * selections: object[], + * text: string, + * attachments: AnnotationAttachment[], + * clickX: number, + * clickY: number, + * fixed: boolean, + * marker?: HTMLElement, + * }} AnnotationComment + */ + +let _editorTranslations = {}; +/** @type {AnnotationComment[]} */ +let _comments = []; +let _panelMode = 'create'; +let _editingComment = null; +/** @type {HTMLElement[]} */ +let _pendingElements = []; +/** @type {object[]} */ +let _pendingSelections = []; +/** @type {AnnotationAttachment[]} */ +let _pendingAttachments = []; +let _annotationClickX = 0; +let _annotationClickY = 0; + +export function getComments() { return _comments; } +export function setComments(value) { _comments = value; } + +export function getPanelMode() { return _panelMode; } +export function setPanelMode(value) { _panelMode = value; } + +export function getEditingComment() { return _editingComment; } +export function setEditingComment(value) { _editingComment = value; } + +export function getPendingElements() { return _pendingElements; } +export function setPendingElements(value) { _pendingElements = value ?? []; } + +export function getPendingSelections() { return _pendingSelections; } +export function setPendingSelections(value) { _pendingSelections = value ?? []; } + +export function getPendingAttachments() { return _pendingAttachments; } +export function setPendingAttachments(value) { _pendingAttachments = value ?? []; } + +export function getAnnotationClickX() { return _annotationClickX; } +export function setAnnotationClickX(value) { _annotationClickX = value; } + +export function getAnnotationClickY() { return _annotationClickY; } +export function setAnnotationClickY(value) { _annotationClickY = value; } + +export function getEditorTranslations() { return _editorTranslations; } +export function setEditorTranslations(value) { _editorTranslations = value; } diff --git a/apps/web/plugins/visual-editor/state/editing-state.js b/apps/web/plugins/visual-editor/state/editing-state.js new file mode 100644 index 0000000..6e33102 --- /dev/null +++ b/apps/web/plugins/visual-editor/state/editing-state.js @@ -0,0 +1,25 @@ +/** Active inline edit session; `null` when no element is being edited. */ +let current = null; + +/** + * @returns {{ editId: string, targetElement: HTMLElement, sessionId: number, originalContent: string, + * originalTextAlign: string, originalInlineStyles: object, + * savedChildren: Array, hasSoftSaved?: boolean }|null} + */ +export function getEditing() { + return current; +} + +/** + * @param {{ editId: string, targetElement: HTMLElement, originalContent: string, + * originalTextAlign: string, originalInlineStyles: object, + * savedChildren: Array }} info + */ +export function setEditing(info) { + current = info; +} + +/** Clears the active edit session (called on commit or discard). */ +export function clearEditing() { + current = null; +} diff --git a/apps/web/plugins/visual-editor/state/history-state.js b/apps/web/plugins/visual-editor/state/history-state.js new file mode 100644 index 0000000..28c7cd3 --- /dev/null +++ b/apps/web/plugins/visual-editor/state/history-state.js @@ -0,0 +1,439 @@ +import { sanitizeText, patchRemoveChild } from "../utils/html-utils.js"; +import { getComments } from "./annotation-state.js"; +import { getEditId } from "../constants/selectors.js"; + +/** Maximum undo/redo entries; oldest entry is dropped when the log exceeds this. */ +const MAX_HISTORY = 25; + +/** Committed and redoable edit actions for the current session. */ +const actionLog = []; + +/** Index of the last committed entry in `actionLog`; -1 when nothing is committed. */ +let actionPointer = -1; + +/** Monotonic id assigned at the start of each inline edit session. */ +let nextEditSessionId = 1; + +/** @returns {number} A new session id for the next inline edit burst. */ +export function beginEditSession() { + return nextEditSessionId++; +} + +/** @returns {number} index of the last committed entry; -1 if nothing committed */ +export function getActionPointer() { + return actionPointer; +} + +/** @returns {number} total entries in the log, including undone future entries */ +export function getActionLogLength() { + return actionLog.length; +} + +/** Wipes all undo/redo history (e.g. on discard or new session). */ +export function clearHistory() { + actionLog.length = 0; + actionPointer = -1; + nextEditSessionId = 1; +} + +/** + * Stable deduplication key for an action. + * Direct edits target one source location (AST writer), so they key by editId only. + * Assisted edits can target separate rendered instances of the same JSX expression, + * so they also include the captured selector. + * @param {{ editId: string, attribute?: string|null, isAssisted?: boolean, selectionMode?: object|null }} action + * @returns {string} + */ +export function getActionKey(action) { + const attributeSuffix = action.attribute ? `@${action.attribute}` : ''; + const selector = action.isAssisted ? action.selectionMode.selector : null; + const instanceSuffix = selector ? `@selector:${selector}` : ''; + return `${action.editId}${attributeSuffix}${instanceSuffix}`; +} + +/** + * Plain text from a stored HTML snapshot (beforeContent), for matching mapped + * siblings that share one edit id. + * @param {string|null|undefined} html + * @returns {string|null} + */ +function plainTextFromStoredHtml(html) { + if (typeof html !== "string" || !html) return null; + const text = html.replace(/<[^>]*>/g, "").trim(); + return text || null; +} + +/** + * Resolves the DOM element for an action. Prefers the stored element reference + * (survives HMR position shifts). When several nodes share one JSX edit id + * (e.g. letter spans from `.map()`), disambiguates with selectionMode text / + * beforeContent — same approach as annotation `resolveByEditId`. Never falls + * back to the first querySelector match when duplicates exist. + * @param {object} action + * @returns {HTMLElement|null} + */ +function resolveElement(action) { + if (action.element?.isConnected) return action.element; + + const matches = Array.from(document.querySelectorAll( + `[data-edit-id="${action.editId}"], [data-edit-assisted-id="${action.editId}"]`, + )); + if (matches.length <= 1) return matches[0] ?? null; + + const text = action.selectionMode?.textContent?.trim() + || plainTextFromStoredHtml(action.instruction?.beforeContent); + if (!text) return null; + + return matches.find((match) => match.textContent?.trim() === text) ?? null; +} + +/** + * Finds the DOM element for an edit and writes a value to it (used by undo/redo). + * @param {object} action - The history action entry (has element ref + editId). + * @param {string} value + */ +function applyValueToElement(action, value) { + const element = resolveElement(action); + if (!element) return; + + if (action.attribute) { + const domAttribute = action.attribute === 'to' ? 'href' : action.attribute; + if (value === null) { + element.removeAttribute(domAttribute); + } else { + element.setAttribute(domAttribute, value); + } + return; + } + + if (element.tagName.toLowerCase() === "img") { + element.setAttribute("src", value); + } else { + patchRemoveChild(element); + element.innerHTML = value; + } +} + +/** + * Applies element-level inline style props for undo/redo. Setting a prop to '' + * removes the inline value so CSS-class styling takes over again. + * @param {object} action - The history action entry. + * @param {object|null} styleProperties - camelCase property → value (or '' to clear) + */ +function applyStyleToElement(action, styleProperties) { + if (!styleProperties) return; + const element = resolveElement(action); + if (!element) return; + for (const [key, value] of Object.entries(styleProperties)) { + element.style[key] = value || ''; + } +} + +/** + * Records an edit, coalescing same-session changes to the same element into one + * undo/redo step. Attribute edits (e.g. href) are keyed separately via {@link getActionKey}. + * + * @param {string} editId + * @param {{ beforeContent: string|null, afterContent: string|null }} instruction - Internal undo/redo snapshot. + * @param {object} [options] + * @param {object|null} [options.style] - Net element-level style props applied. + * @param {object|null} [options.oldStyle] - Previous inline values of those props ('' = unset). + * @param {string|null} [options.attribute] + * @param {HTMLElement|null} [options.element] + * @param {number|null} [options.sessionId] - Inline edit session; coalesces only when equal. + * @param {boolean} [options.isAssisted] - True when the source-writer can't safely apply this edit and the AI must. + * @param {object|null} [options.selectionMode] - Rich DOM context captured before mutation (see `captureElementMetadata`). + */ +export function recordEdit(editId, instruction, { style = null, oldStyle = null, attribute = null, element = null, sessionId = null, isAssisted = false, selectionMode = null } = {}) { + const composite = getActionKey({ editId, attribute, isAssisted, selectionMode }); + const lastForEdit = [...actionLog].slice(0, actionPointer + 1).findLast(action => getActionKey(action) === composite); + if (lastForEdit && lastForEdit.instruction.afterContent === instruction.afterContent && !style) return; + + if (lastForEdit + && actionLog.lastIndexOf(lastForEdit) === actionPointer + && lastForEdit.sessionId === sessionId) { + lastForEdit.instruction = { ...lastForEdit.instruction, afterContent: instruction.afterContent }; + if (style) { + lastForEdit.style = { ...lastForEdit.style, ...style }; + lastForEdit.oldStyle = { ...oldStyle, ...lastForEdit.oldStyle }; + } + if (element) lastForEdit.element = element; + return; + } + + actionLog.length = actionPointer + 1; + actionLog.push({ editId, instruction, style, oldStyle, attribute, element, sessionId, isAssisted, selectionMode }); + + if (actionLog.length > MAX_HISTORY) { + actionLog.shift(); + } + + actionPointer = actionLog.length - 1; +} + +/** + * Re-applies every committed edit's content/attribute/style to its element, + * for entries whose stored element reference went stale (a route change or + * React re-render replaced it with a freshly-mounted, un-edited node). + */ +export function reapplyCommittedEdits() { + for (let index = 0; index <= actionPointer; index++) { + const action = actionLog[index]; + if (action.element?.isConnected) continue; + + const element = resolveElement(action); + if (!element) continue; + + action.element = element; + applyValueToElement(action, action.instruction.afterContent); + if (action.style) applyStyleToElement(action, action.style); + } +} + +/** + * Reverts the most recent committed edit (content and element-level styles) + * and moves the pointer back. + * @returns {{ editId: string, instruction: { beforeContent: string, afterContent: string } }|null} + */ +export function undo() { + if (actionPointer < 0) return null; + const action = actionLog[actionPointer]; + applyValueToElement(action, action.instruction.beforeContent); + if (action.style) { + const revert = {}; + for (const key of Object.keys(action.style)) { + revert[key] = action.oldStyle?.[key] ?? ''; + } + applyStyleToElement(action, revert); + } + actionPointer--; + return action; +} + +/** + * Re-applies the next undone edit (content and element-level styles) and + * advances the pointer. + * @returns {{ editId: string, instruction: { beforeContent: string, afterContent: string } }|null} + */ +export function redo() { + if (actionPointer >= actionLog.length - 1) return null; + actionPointer++; + const action = actionLog[actionPointer]; + applyValueToElement(action, action.instruction.afterContent); + applyStyleToElement(action, action.style); + return action; +} + +/** + * Aggregates the committed log into one draft per target: the first entry for a key + * captures the original instruction/style, later entries update `afterContent`/style. + * @returns {Map<string, { editId: string, attribute: string|null, instruction: { beforeContent: string, afterContent: string }, originalStyle: Record<string, string>, currentStyle: Record<string, string>, isAssisted: boolean, selectionMode: object|null }>} + */ +function collectEditDrafts() { + const draftsByKey = new Map(); + for (let index = 0; index <= actionPointer; index++) { + const action = actionLog[index]; + const key = getActionKey(action); + let draft = draftsByKey.get(key); + if (!draft) { + draft = { + editId: action.editId, + attribute: action.attribute ?? null, + instruction: { + beforeContent: action.instruction.beforeContent, + afterContent: action.instruction.afterContent, + }, + originalStyle: {}, + currentStyle: {}, + isAssisted: action.isAssisted ?? false, + selectionMode: action.selectionMode ?? null, + }; + draftsByKey.set(key, draft); + } + draft.instruction.afterContent = action.instruction.afterContent; + for (const [property, value] of Object.entries(action.style ?? {})) { + if (!(property in draft.originalStyle)) { + draft.originalStyle[property] = action.oldStyle?.[property] ?? ''; + } + draft.currentStyle[property] = value; + } + } + return draftsByKey; +} + +/** + * Collapses the committed log into one net edit per target, shaped like the wire + * `VisualEditItem`, keyed separately for source persistence and history aggregation. + * Content, attribute, and individual style-property changes are emitted atomically + * so downstream consumers do not have to infer what a before/after pair represents. + * @returns {Record<string, { editId: string, instruction: { changes: Array<{ kind: string, target: string|null, before: string|null, after: string|null }> }, isAssisted: boolean, selectionMode: object|null }>} + */ +export function getCurrentEdits() { + const edits = {}; + for (const [key, draft] of collectEditDrafts()) { + const changes = []; + const beforeContent = draft.attribute + ? draft.instruction.beforeContent ?? null + : sanitizeText(draft.instruction.beforeContent); + const afterContent = draft.attribute + ? draft.instruction.afterContent ?? null + : sanitizeText(draft.instruction.afterContent); + + if (beforeContent !== afterContent) { + changes.push({ + kind: draft.attribute ? 'attribute' : 'content', + target: draft.attribute, + before: beforeContent, + after: afterContent, + }); + } + + for (const property of Object.keys(draft.currentStyle)) { + if ((draft.currentStyle[property] ?? '') !== (draft.originalStyle[property] ?? '')) { + changes.push({ + kind: 'style', + target: property, + before: draft.originalStyle[property] || null, + after: draft.currentStyle[property] || null, + }); + } + } + + if (!changes.length) continue; + + const edit = { + editId: draft.editId, + instruction: { changes }, + isAssisted: !!draft.isAssisted, + selectionMode: draft.selectionMode, + }; + edits[key] = edit; + } + return edits; +} + +/** + * Categorizes the current, uncommitted edit set — used to report which kinds of + * changes a save actually carries, as opposed to which edit surfaces were merely + * opened during the session (a text/image edit entered and then reverted leaves + * no trace here). + * @param {ReturnType<typeof getCurrentEdits>} edits + * @param {ReturnType<typeof getComments>} annotations + * @returns {string[]} distinct categories: "text" | "image" | "annotation" + */ +function getEditTypes(edits, annotations) { + const editTypes = new Set(); + for (const edit of Object.values(edits)) { + const isImageEdit = edit.instruction.changes.some(change => change.kind === 'attribute' && change.target === 'src'); + editTypes.add(isImageEdit ? 'image' : 'text'); + } + if (annotations.length) editTypes.add('annotation'); + return [...editTypes]; +} + +/** + * Undo/redo availability plus the count of distinct elements that differ from their original, + * including elements that only carry an annotation (no history edit). + * @returns {{ canUndo: boolean, canRedo: boolean, editedElementsCount: number, editTypes: string[] }} + */ +export function getEditState() { + const edits = getCurrentEdits(); + const annotations = getComments(); + + const changedElementIds = new Set( + Object.values(edits).map(edit => getActionKey({ ...edit, attribute: null })), + ); + + annotations.forEach((annotation, annotationIndex) => { + if (!annotation.elements.length) { + changedElementIds.add(`annotation-${annotationIndex}`); + return; + } + + annotation.elements.forEach((element, elementIndex) => { + changedElementIds.add(getEditId(element) || `annotation-${annotationIndex}-${elementIndex}`); + }); + }); + + return { + canUndo: actionPointer >= 0, + canRedo: actionPointer < actionLog.length - 1, + editedElementsCount: changedElementIds.size, + editTypes: getEditTypes(edits, annotations), + }; +} + +/** + * Serializes the full undo/redo stack for persistence across reloads. + * Strips non-serializable DOM element refs. + * @returns {{ actionLog: object[], actionPointer: number, nextEditSessionId: number }} + */ +export function exportDraftSnapshot() { + return { + actionLog: actionLog.map(({ element: _element, ...action }) => ({ + ...action, + instruction: { ...action.instruction }, + style: action.style ? { ...action.style } : null, + oldStyle: action.oldStyle ? { ...action.oldStyle } : null, + selectionMode: action.selectionMode ? structuredClone(action.selectionMode) : null, + })), + actionPointer, + nextEditSessionId, + }; +} + +/** + * Rebuilds the undo/redo stack from a persisted snapshot and re-applies committed + * edits to the current DOM. Skips actions whose elements cannot be resolved. + * @param {{ actionLog?: object[], actionPointer?: number, nextEditSessionId?: number }|null|undefined} snapshot + * @returns {{ restoredCount: number, skippedCount: number }} + */ +export function importDraftSnapshot(snapshot) { + clearHistory(); + + if (!snapshot || !Array.isArray(snapshot.actionLog)) { + return { restoredCount: 0, skippedCount: 0 }; + } + + let restoredCount = 0; + let skippedCount = 0; + + for (const action of snapshot.actionLog) { + if (!action?.editId || !action?.instruction) { + skippedCount++; + continue; + } + actionLog.push({ + editId: action.editId, + instruction: { + beforeContent: action.instruction.beforeContent ?? null, + afterContent: action.instruction.afterContent ?? null, + }, + style: action.style ?? null, + oldStyle: action.oldStyle ?? null, + attribute: action.attribute ?? null, + element: null, + sessionId: action.sessionId ?? null, + isAssisted: !!action.isAssisted, + selectionMode: action.selectionMode ?? null, + }); + restoredCount++; + } + + const maxPointer = actionLog.length - 1; + const requestedPointer = typeof snapshot.actionPointer === 'number' ? snapshot.actionPointer : maxPointer; + actionPointer = Math.min(Math.max(requestedPointer, -1), maxPointer); + nextEditSessionId = typeof snapshot.nextEditSessionId === 'number' && snapshot.nextEditSessionId > 0 + ? snapshot.nextEditSessionId + : 1; + + reapplyCommittedEdits(); + + for (let index = 0; index <= actionPointer; index++) { + if (!actionLog[index].element) { + skippedCount++; + } + } + + return { restoredCount, skippedCount }; +} diff --git a/apps/web/plugins/visual-editor/state/multi-select-state.js b/apps/web/plugins/visual-editor/state/multi-select-state.js new file mode 100644 index 0000000..202f1d4 --- /dev/null +++ b/apps/web/plugins/visual-editor/state/multi-select-state.js @@ -0,0 +1,78 @@ +/** + * Group selection built by a rubber-band drag or shift+click. The committed + * group is document-ordered and never nested; `_drag*` lives for one gesture. + */ + +/** @type {HTMLElement[]} */ +let _selectedElements = []; +let _dragStartX = null; +let _dragStartY = null; +let _isDragging = false; +/** Set on mouseup so the synthetic click a drag leaves behind is swallowed. */ +let _hadDrag = false; + +export function getSelectedElements() { return _selectedElements; } +export function setSelectedElements(value) { _selectedElements = value; } + +export function getDragStartX() { return _dragStartX; } +export function getDragStartY() { return _dragStartY; } +export function setDragStart(x, y) { _dragStartX = x; _dragStartY = y; } +export function clearDragStart() { _dragStartX = null; _dragStartY = null; } + +export function getIsDragging() { return _isDragging; } +export function setIsDragging(value) { _isDragging = value; } + +export function getHadDrag() { return _hadDrag; } +export function setHadDrag(value) { _hadDrag = value; } + +/** True when a mousedown is pending and could still become a drag. */ +export function isDragPending() { return _dragStartX !== null; } + +/** Drops the current group without touching an in-flight gesture. */ +export function clearSelectedElements() { _selectedElements = []; } + +/** + * Drops members that have left the page, so a group never outlives the route it + * was made on or holds nodes a re-render replaced. + * @returns {boolean} True when the group changed. + */ +export function pruneDetachedSelection() { + const connected = _selectedElements.filter(element => element.isConnected); + if (connected.length === _selectedElements.length) return false; + + _selectedElements = connected; + return true; +} + +/** Resets everything, including any gesture in progress. */ +export function resetMultiSelectState() { + _selectedElements = []; + _dragStartX = null; + _dragStartY = null; + _isDragging = false; + _hadDrag = false; +} + +/** + * Dedupes and puts the group in document order, which fixes the marker's anchor + * element and the order selections reach the AI. + * @param {HTMLElement[]} elements + * @returns {HTMLElement[]} + */ +export function orderSelection(elements) { + return [...new Set(elements)] + .sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)); +} + +/** + * Adds the element to the group, or removes it when already a member. Nesting is + * allowed: picking a parent on top of its children is an explicit choice. + * @param {HTMLElement} element + * @returns {HTMLElement[]} The new group. + */ +export function toggleSelectedElement(element) { + _selectedElements = _selectedElements.includes(element) + ? _selectedElements.filter(member => member !== element) + : orderSelection([..._selectedElements, element]); + return _selectedElements; +} diff --git a/apps/web/plugins/visual-editor/state/panel-state.js b/apps/web/plugins/visual-editor/state/panel-state.js new file mode 100644 index 0000000..7aeff08 --- /dev/null +++ b/apps/web/plugins/visual-editor/state/panel-state.js @@ -0,0 +1,65 @@ +/** @enum {string} Unique identifier for each action panel. */ +export const PanelId = Object.freeze({ + COLOR: 'color', + FONT: 'font', + SIZE: 'size', + LINK: 'link', +}); + +/** + * Tracks whether each panel is currently open. + * @type {Map<string, boolean>} + */ +const visibility = new Map(); + +/** + * The hide function supplied by each panel at registration time. + * @type {Map<string, () => void>} + */ +const hideFunctions = new Map(); + +/** + * Register an action panel so it participates in bulk show/hide operations. + * Called once at module load time by each panel. + * @param {{ id: string, hide: () => void }} panel + */ +export function registerPanel({ id, hide }) { + hideFunctions.set(id, hide); + visibility.set(id, false); +} + +/** + * Update the visibility state for a panel. + * Called by the panel's own show/hide functions. + * @param {string} id - A {@link PanelId} value. + * @param {boolean} visible + */ +export function setPanelVisible(id, visible) { + visibility.set(id, visible); +} + +/** + * @param {string} id - A {@link PanelId} value. + * @returns {boolean} + */ +export function isPanelVisible(id) { + return visibility.get(id) ?? false; +} + +/** + * Hide a single panel by ID. + * @param {string} id - A {@link PanelId} value. + */ +export function hidePanelById(id) { + hideFunctions.get(id)?.(); +} + +/** Hide every registered panel. */ +export function hideAllPanels() { + for (const id of hideFunctions.keys()) hidePanelById(id); +} + +/** @returns {boolean} True if at least one panel is currently visible. */ +export function anyPanelVisible() { + return [...visibility.values()].some(Boolean); +} diff --git a/apps/web/plugins/visual-editor/transform/editability.js b/apps/web/plugins/visual-editor/transform/editability.js new file mode 100644 index 0000000..09a6951 --- /dev/null +++ b/apps/web/plugins/visual-editor/transform/editability.js @@ -0,0 +1,301 @@ +import * as t from '@babel/types'; +import { ELEMENT_TYPE_MAP } from '../constants/selectors.js'; + +export const EDITABLE_HTML_TAGS = ["a", "Link", "button", "Button", "p", "span", "time", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"]; + +export const INLINE_EDIT_HTML_TAGS = ["br", "strong", "em", "b", "i", "u", "span", "a"]; + +/** + * @param {import('@babel/types').JSXOpeningElement} openingElementNode + * @param {string[]} editableTagsList + * @returns {boolean} + */ +export function checkTagNameEditable(openingElementNode, editableTagsList) { + if (!openingElementNode || !openingElementNode.name) return false; + const nameNode = openingElementNode.name; + + if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) { + return true; + } + + if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) { + return true; + } + + return false; +} + +/** + * @param {import('@babel/types').JSXOpeningElement} openingNode + * @returns {{ isValid: boolean, reason: string|null }} + */ +export function validateImageSrc(openingNode) { + if (!openingNode || !openingNode.name || (openingNode.name.name !== 'img' && openingNode.name.property?.name !== 'img')) { + return { isValid: true, reason: null }; + } + + const hasPropsSpread = openingNode.attributes.some(attribute => + t.isJSXSpreadAttribute(attribute) && + attribute.argument && + t.isIdentifier(attribute.argument) && + attribute.argument.name === 'props' + ); + + if (hasPropsSpread) return { isValid: false, reason: 'props-spread' }; + + const sourceAttribute = openingNode.attributes.find(attribute => + t.isJSXAttribute(attribute) && attribute.name && attribute.name.name === 'src' + ); + + if (!sourceAttribute) return { isValid: false, reason: 'missing-src' }; + if (!t.isStringLiteral(sourceAttribute.value)) return { isValid: false, reason: 'dynamic-src' }; + if (!sourceAttribute.value.value || sourceAttribute.value.value.trim() === '') return { isValid: false, reason: 'empty-src' }; + + return { isValid: true, reason: null }; +} + +// `children` and capitalised identifiers hold rendered nodes, not text, so an +// element whose only content is one of those is a layout slot rather than a target. +function isNodeSlotExpression(expression) { + if (t.isIdentifier(expression)) return expression.name === 'children' || /^[A-Z]/.test(expression.name); + return t.isMemberExpression(expression) && expression.property?.name === 'children'; +} + +function isLikelyTextExpression(expression) { + if (isNodeSlotExpression(expression)) return false; + // A ternary or `&&` still renders text through whichever branch wins. Without + // this, `{ready ? `${n} entries` : 'Loading'}` reads as an empty element and + // the span is skipped entirely instead of being marked assisted. + if (t.isConditionalExpression(expression)) { + return isLikelyTextExpression(expression.consequent) || isLikelyTextExpression(expression.alternate); + } + if (t.isLogicalExpression(expression)) { + return isLikelyTextExpression(expression.right); + } + return t.isIdentifier(expression) + || t.isMemberExpression(expression) + || t.isStringLiteral(expression) + || t.isNumericLiteral(expression) + || t.isTemplateLiteral(expression); +} + +/** + * True when an element has direct text children worth editing + * @param {import('@babel/types').JSXElement} elementNode + * @returns {boolean} + */ +export function hasMeaningfulTextContent(elementNode) { + if (!t.isJSXElement(elementNode) || !elementNode.children?.length) return false; + for (const child of elementNode.children) { + if (t.isJSXText(child) && child.value.trim().length > 0) return true; + if (t.isJSXExpressionContainer(child) + && !t.isJSXEmptyExpression(child.expression) + && isLikelyTextExpression(child.expression)) return true; + } + return false; +} + +/** + * Like {@link hasMeaningfulTextContent} but walks the full subtree. + * Used to distinguish decorative inline children (color swatches, icons) + * from ones that carry actual text. + */ +function subtreeHasText(elementNode) { + if (!t.isJSXElement(elementNode)) return false; + for (const child of elementNode.children ?? []) { + if (t.isJSXText(child) && child.value.trim().length > 0) return true; + if (t.isJSXExpressionContainer(child) + && !t.isJSXEmptyExpression(child.expression) + && isLikelyTextExpression(child.expression)) return true; + if (t.isJSXElement(child) && subtreeHasText(child)) return true; + } + return false; +} + +/** + * Returns true if the element should receive an assisted-edit id due to spread + * props or dynamic children that contain text. Icon-only buttons are excluded. + * @param {import('@babel/types').JSXElement} elementNode + * @param {import('@babel/types').JSXOpeningElement} openingNode + * @returns {boolean} + */ +export function shouldDisableDueToChildren(elementNode, openingNode) { + if (!t.isJSXElement(elementNode) || !elementNode.children) return false; + + const hasPropsSpread = openingNode.attributes.some(attribute => + t.isJSXSpreadAttribute(attribute) + && attribute.argument + && t.isIdentifier(attribute.argument) + && attribute.argument.name === 'props' + ); + if (hasPropsSpread) return true; + + const hasDynamicChild = elementNode.children.some(child => t.isJSXExpressionContainer(child)); + if (hasDynamicChild) { + const hasStaticText = elementNode.children.some( + child => t.isJSXText(child) && child.value.trim().length > 0 + ); + if (hasStaticText) return true; + + const hasTextLikeExpression = elementNode.children.some(child => + t.isJSXExpressionContainer(child) + && !t.isJSXEmptyExpression(child.expression) + && isLikelyTextExpression(child.expression) + ); + if (hasTextLikeExpression) return true; + } + + return false; +} + +/** + * Analyses child composition to decide whether the element can be edited. + * @param {import('@babel/types').JSXElement} elementNode + * @param {import('@babel/types').JSXOpeningElement} openingNode + * @returns {'skip'|'disabled'|'allow'} + */ +export function evalChildComposition(elementNode, openingNode) { + if (!t.isJSXElement(elementNode)) return 'allow'; + if (!elementNode.children?.length) { + const name = openingNode?.name?.name || openingNode?.name?.property?.name || ''; + return name === 'img' ? 'allow' : 'skip'; + } + + let hasTextContent = false; + let hasNonEditableJsxChild = false; + let hasNonSelfClosingChild = false; + let hasInlineTagChild = false; + let hasIdIneligibleInlineChild = false; + + for (const child of elementNode.children) { + if (t.isJSXText(child)) { + if (child.value.trim().length > 0) hasTextContent = true; + continue; + } + if (t.isJSXExpressionContainer(child)) { + if (!t.isJSXEmptyExpression(child.expression) && isLikelyTextExpression(child.expression)) { + hasTextContent = true; + } + continue; + } + if (t.isJSXElement(child)) { + const childNode = child.openingElement; + if (childNode.selfClosing) { + const childName = childNode.name?.name || ''; + if (!/^[A-Z]/.test(childName) && !checkTagNameEditable(childNode, EDITABLE_HTML_TAGS) && !INLINE_EDIT_HTML_TAGS.includes(childName)) { + hasNonEditableJsxChild = true; + } + continue; + } + const childTagName = child.openingElement.name?.name; + if (INLINE_EDIT_HTML_TAGS.includes(childTagName)) { + if (!subtreeHasText(child)) { + hasNonEditableJsxChild = true; + continue; + } + hasInlineTagChild = true; + // `strong`/`em`/… are inline-editable inside a parent but cannot + // receive their own edit id (Condition 2 returns early). Only + // skip the parent when every inline child could own an id. + if (!EDITABLE_HTML_TAGS.includes(childTagName)) { + hasIdIneligibleInlineChild = true; + } + continue; + } + hasNonSelfClosingChild = true; + if (!checkTagNameEditable(childNode, EDITABLE_HTML_TAGS)) { + hasNonEditableJsxChild = true; + } + } + } + + if (!hasTextContent && !hasNonSelfClosingChild && !hasInlineTagChild) return 'skip'; + if (hasNonEditableJsxChild) { + if (!hasTextContent && !hasInlineTagChild) return 'skip'; + return 'disabled'; + } + if (hasNonSelfClosingChild && !hasTextContent && !hasInlineTagChild) { + if (openingNode && checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)) return 'allow'; + return 'skip'; + } + // Layout wrappers that only nest id-eligible inline tags (span/a) skip so + // the child receives the id. Keep 'allow' when an inline child cannot own + // an id (`<p><strong>text</strong></p>`), or when the parent is itself an + // editable tag (h1, p, a, button, …) that should own the edit target. + if (!hasTextContent && hasInlineTagChild && !hasIdIneligibleInlineChild) { + const parentName = openingNode?.name?.name || openingNode?.name?.property?.name || ''; + const isFormattingWrapper = INLINE_EDIT_HTML_TAGS.includes(parentName) && parentName !== 'a'; + if (openingNode && checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS) + && !isFormattingWrapper) return 'allow'; + return 'skip'; + } + return 'allow'; +} + +/** + * Recursively checks whether an element or any of its descendants contain + * dynamic content, spread props, or conditional expressions that a direct + * inline edit would silently overwrite. When true the element must be routed + * through AI-assisted editing instead. + * @param {import('@babel/types').JSXElement} elementNode + * @returns {boolean} + */ +export function subtreeRequiresAssistance(elementNode) { + if (!t.isJSXElement(elementNode)) return false; + + const opening = elementNode.openingElement; + if (opening.attributes.some(attribute => t.isJSXSpreadAttribute(attribute))) { + return true; + } + + for (const child of elementNode.children ?? []) { + if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)) { + return true; + } + + if (t.isJSXElement(child) && subtreeRequiresAssistance(child)) { + return true; + } + } + + return false; +} + +/** + * Returns true if an ancestor JSXElement already received a target attribute. + * @param {import('@babel/traverse').NodePath<import('@babel/types').JSXOpeningElement>} openingPath + * @returns {boolean} + */ +export function hasEditableAncestor(openingPath) { + const selfElementPath = openingPath.parentPath; + if (!selfElementPath?.isJSXElement()) return false; + + let ancestorJsxElementPath = selfElementPath.findParent(path => path.isJSXElement()); + while (ancestorJsxElementPath) { + const ancestorOpening = ancestorJsxElementPath.node.openingElement; + if (ancestorOpening.attributes.some(attribute => + t.isJSXAttribute(attribute) && (attribute.name?.name === 'data-edit-id' || attribute.name?.name === 'data-edit-assisted-id') + )) { + return true; + } + ancestorJsxElementPath = ancestorJsxElementPath.findParent(path => path.isJSXElement()); + } + return false; +} + +/** + * Maps a JSX opening element tag name to a semantic edit type. + * @param {import('@babel/types').JSXOpeningElement} openingNode + * @returns {string|null} + */ +export function getElementTypeFromNode(openingNode) { + if (!openingNode || !openingNode.name) return null; + const name = openingNode.name?.name || openingNode.name?.property?.name || ''; + if (!name) return null; + + for (const [type, tags] of Object.entries(ELEMENT_TYPE_MAP)) { + if (tags.includes(name)) return type; + } + + return null; +} diff --git a/apps/web/plugins/visual-editor/transform/inject-attributes.js b/apps/web/plugins/visual-editor/transform/inject-attributes.js new file mode 100644 index 0000000..daea340 --- /dev/null +++ b/apps/web/plugins/visual-editor/transform/inject-attributes.js @@ -0,0 +1,54 @@ +import * as t from '@babel/types'; + +/** + * For an editable `<a>` / `<Link>` / `<NavLink>`, returns the name of the URL + * attribute (`href` or `to`) when it holds a static string literal. + * Returns `null` for non-link tags or dynamic/expression URLs. + * @param {import('@babel/types').JSXOpeningElement} openingNode + * @returns {string|null} + */ +export function detectUrlAttribute(openingNode) { + const tagName = openingNode.name?.name || openingNode.name?.property?.name || ''; + let attributeName = null; + if (tagName === 'a') attributeName = 'href'; + else if (tagName === 'Link' || tagName === 'NavLink') attributeName = 'to'; + if (!attributeName) return null; + + const matchedAttribute = openingNode.attributes.find(attribute => + t.isJSXAttribute(attribute) && attribute.name?.name === attributeName + ); + if (!matchedAttribute || !matchedAttribute.value) return null; + + if (t.isStringLiteral(matchedAttribute.value)) return attributeName; + if (t.isJSXExpressionContainer(matchedAttribute.value) && t.isStringLiteral(matchedAttribute.value.expression)) return attributeName; + + return null; +} + +/** + * Pushes `data-edit-id` (and optionally `data-edit-url-attr`) onto the element. + * @param {import('@babel/types').JSXOpeningElement} openingNode + * @param {string} editId + * @param {string|null} urlAttributeName + */ +export function attachEditAttributes(openingNode, editId, urlAttributeName) { + openingNode.attributes.push( + t.jsxAttribute(t.jsxIdentifier('data-edit-id'), t.stringLiteral(editId)) + ); + if (urlAttributeName) { + openingNode.attributes.push( + t.jsxAttribute(t.jsxIdentifier('data-edit-url-attr'), t.stringLiteral(urlAttributeName)) + ); + } +} + +/** + * Pushes only `data-edit-assisted-id` onto the element. + * @param {import('@babel/types').JSXOpeningElement} openingNode + * @param {string} editId + */ +export function attachAssistedEditId(openingNode, editId) { + openingNode.attributes.push( + t.jsxAttribute(t.jsxIdentifier('data-edit-assisted-id'), t.stringLiteral(editId)) + ); +} diff --git a/apps/web/plugins/visual-editor/ui/annotation-panel/annotation-markers.js b/apps/web/plugins/visual-editor/ui/annotation-panel/annotation-markers.js new file mode 100644 index 0000000..0700470 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/annotation-panel/annotation-markers.js @@ -0,0 +1,404 @@ +import { ICON_SPARKLES } from '../../constants/icons.js'; +import { ANNOTATION_MARKER_STYLES } from './styles.js'; +import { getEditId } from '../../constants/selectors.js'; +import { setComments } from '../../state/annotation-state.js'; +import { getAppRoot, isolateEditorUiEvents } from '../../utils/dom-utils.js'; +import { + showHoverOutline, holdHoverOutline, releaseHoverOutline, lockHoverOutline, + HOVER_OUTLINE_CLASS, LOCKED_OUTLINE_CLASS, +} from '../overlays/hover-outline.js'; + +/** + * `class` attribute with our own hover/locked outline classes removed, so an + * anchor captured while the panel had the element locked never bakes in a + * class the element won't carry once re-mounted on a route round-trip. + * @param {HTMLElement} element + * @returns {string} + */ +function stableClassName(element) { + return Array.from(element.classList) + .filter(className => className !== HOVER_OUTLINE_CLASS && className !== LOCKED_OUTLINE_CLASS) + .join(' '); +} + +const _markers = []; +let _scrollListenerActive = false; +let _stylesInjected = false; + +function ensureStyles() { + if (_stylesInjected) return; + const style = document.createElement('style'); + style.id = 'annotation-marker-styles'; + style.textContent = ANNOTATION_MARKER_STYLES; + document.head.appendChild(style); + _stylesInjected = true; +} + +/** Route an annotation belongs to; it is never re-anchored on any other one. */ +function currentRoute() { + return location.pathname + location.hash; +} + +/** + * Structural anchor: the element's child-index path from the app root plus the + * tag and classes expected there. Its captured `selector` is unusable for + * re-querying — Tailwind variants (`md:py-32`) make it invalid CSS. + * @param {HTMLElement} element + * @returns {{path: number[], tagName: string, className: string}|null} + */ +function captureAnchor(element) { + const root = getAppRoot(); + const path = []; + let node = element; + + while (node && node !== root) { + const parent = node.parentElement; + if (!parent) return null; + path.unshift(Array.prototype.indexOf.call(parent.children, node)); + node = parent; + } + + const anchor = { path, tagName: element.tagName, className: stableClassName(element) }; + + return anchor; +} + +/** + * Corrects for a reordered list: items rendered from one `.map()` are identical + * in tag and classes, so only their text tells them apart. When no sibling + * matches either, the text was edited since and the position stands. + * @param {HTMLElement} candidate + * @param {object|null} selection - `captureElementMetadata` output. + * @returns {HTMLElement} + */ +function disambiguateSiblings(candidate, selection) { + const text = selection?.textContent; + if (!text || candidate.textContent?.trim() === text) return candidate; + + const siblings = Array.from(candidate.parentElement?.children ?? []); + return siblings.find(sibling => + sibling.tagName === candidate.tagName + && stableClassName(sibling) === stableClassName(candidate) + && sibling.textContent?.trim() === text) ?? candidate; +} + +/** + * Node currently sitting at the anchor's tree position, or null when the + * position is empty or now holds a different element. + * @param {{path: number[], tagName: string, className: string}|null} anchor + * @param {object|null} selection - `captureElementMetadata` output. + * @returns {HTMLElement|null} + */ +function resolveByAnchor(anchor, selection) { + if (!anchor) return null; + + let candidate = getAppRoot(); + for (const index of anchor.path) { + candidate = candidate?.children[index]; + } + if (!candidate) return null; + + const actualClassName = stableClassName(candidate); + const sameElement = candidate.tagName === anchor.tagName && actualClassName === anchor.className; + + return sameElement ? disambiguateSiblings(candidate, selection) : null; +} + +/** + * An edit id is a JSX source location, so every node rendered from one `.map()` + * call site carries the same one; the anchor picks the annotated instance. + * @param {{editId: string, anchor: object|null, selection: object|null}} target + * @returns {HTMLElement|null} + */ +function resolveByEditId(target) { + const matches = Array.from(document.querySelectorAll(`[data-edit-id="${target.editId}"], [data-edit-assisted-id="${target.editId}"]`)); + if (matches.length <= 1) return matches[0] ?? null; + + const anchored = resolveByAnchor(target.anchor, target.selection); + if (anchored && matches.includes(anchored)) return anchored; + + const text = target.selection?.textContent; + return (text && matches.find(match => match.textContent?.trim() === text)) || null; +} + +/** + * Element one annotated target currently points at, re-resolved when the stored + * node was unmounted (re-render, route change); null when it is gone. + * @param {object} target + * @param {string} route - Route the annotation was made on. + * @returns {HTMLElement|null} + */ +function resolveTarget(target, route) { + if (target.element?.isConnected) return target.element; + if (currentRoute() !== route) return null; + + const element = target.editId ? resolveByEditId(target) : resolveByAnchor(target.anchor, target.selection); + if (element) target.element = element; + return element; +} + +/** + * Elements a marker's annotation still points at. A group keeps its pin as long + * as at least one member survives, so a partial re-render doesn't drop it. + * @param {object} entry + * @returns {HTMLElement[]} + */ +function resolveMarkerElements(entry) { + const elements = entry.targets + .map(target => resolveTarget(target, entry.route)) + .filter(Boolean); + + entry.elements = elements; + if (elements.length) entry.comment.elements = elements; + return elements; +} + +/** + * Places one marker over its annotation's current elements. + * @param {object} entry + * @param {HTMLElement[]} elements - `resolveMarkerElements` output, never empty. + */ +function positionMarker(entry, elements) { + const anchorElement = elements[0]; + const rect = anchorElement.getBoundingClientRect(); + const fixed = entry.fixed; + + const anchored = elements.length === 1 + && Array.from(anchorElement.attributes || []).some(attr => attr.name.startsWith('data-edit-')); + const relativeX = anchored ? 0 : entry.relativeX; + const relativeY = anchored ? rect.height / 2 : entry.relativeY; + + const scrollX = fixed ? 0 : window.scrollX; + const scrollY = fixed ? 0 : window.scrollY; + entry.marker.style.left = `${rect.left + relativeX + scrollX}px`; + entry.marker.style.top = `${rect.top + relativeY + scrollY}px`; +} + +/** + * Repositions every marker over its annotation, hiding the ones whose elements + * are no longer on the page so annotations never bleed onto another route. This + * is also what anchors a restored annotation, once its route renders. + */ +export function refreshAnnotationMarkers() { + for (const entry of _markers) { + const elements = resolveMarkerElements(entry); + if (!elements.length) { + entry.marker.style.display = 'none'; + continue; + } + entry.marker.style.display = ''; + positionMarker(entry, elements); + } +} + +function ensureScrollListener() { + if (_scrollListenerActive) return; + _scrollListenerActive = true; + window.addEventListener('scroll', refreshAnnotationMarkers, true); + window.addEventListener('resize', refreshAnnotationMarkers); +} + +/** + * Builds and registers the pin of one annotation. `targets` may describe elements + * that are not on the page: the pin stays hidden until a refresh resolves them. + * @param {{ + * comment: import('../../state/annotation-state.js').AnnotationComment, + * targets: Array<{element: HTMLElement|null, editId: string|null, anchor: object|null, selection: object|null}>, + * relativeX: number, + * relativeY: number, + * fixed: boolean, + * route: string, + * onMarkerClick: (comment: object, clientX: number, clientY: number) => void, + * }} options + * @returns {object} The marker entry. + */ +function createMarkerEntry({ comment, targets, relativeX, relativeY, fixed, route, onMarkerClick }) { + ensureStyles(); + ensureScrollListener(); + + const marker = document.createElement('div'); + marker.className = 'selection-mode-annotation-marker'; + marker.style.position = fixed ? 'fixed' : 'absolute'; + marker.style.display = 'none'; + marker.innerHTML = ICON_SPARKLES; + + // The outline and panel target `entry.elements`, not the captured ones: + // re-resolution after a re-render swaps in the freshly mounted nodes. + const entry = { + marker, + elements: targets.map(target => target.element).filter(Boolean), + relativeX, + relativeY, + fixed, + comment, + targets, + route, + }; + + marker.addEventListener('mouseenter', () => { + holdHoverOutline(); + showHoverOutline(entry.elements); + }); + + marker.addEventListener('mouseleave', () => { + releaseHoverOutline(); + }); + + marker.addEventListener('click', (event) => { + event.stopPropagation(); + event.preventDefault(); + lockHoverOutline(entry.elements); + onMarkerClick(comment, event.clientX, event.clientY); + }); + + isolateEditorUiEvents(marker); + document.body.appendChild(marker); + + _markers.push(entry); + comment.marker = marker; + + return entry; +} + +/** + * Adds the single pin that represents one annotation, tracked against the first of its elements + * @param {import('../../state/annotation-state.js').AnnotationComment} comment + * @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick + */ +export function addAnnotationMarker(comment, onMarkerClick) { + const { elements, selections, clickX, clickY, fixed } = comment; + const anchorElement = elements[0]; + const rect = anchorElement.getBoundingClientRect(); + + const anchorToLeftBorder = elements.length === 1 + && (anchorElement.hasAttribute('data-edit-id') || anchorElement.hasAttribute('data-edit-assisted-id')); + + const entry = createMarkerEntry({ + comment, + targets: elements.map((element, index) => ({ + element, + editId: getEditId(element), + anchor: captureAnchor(element), + selection: selections[index] ?? null, + })), + relativeX: anchorToLeftBorder ? 0 : clickX - rect.left, + relativeY: anchorToLeftBorder ? rect.height / 2 : clickY - rect.top, + fixed, + route: currentRoute(), + onMarkerClick, + }); + + entry.marker.style.display = ''; + positionMarker(entry, elements); +} + +/** + * Recreates the pin of a persisted annotation + * @param {import('../../state/annotation-state.js').AnnotationComment} comment + * @param {ReturnType<typeof exportAnnotationMarkers>[number]} serialized + * @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick + */ +function addRestoredAnnotationMarker(comment, serialized, onMarkerClick) { + createMarkerEntry({ + comment, + targets: (serialized.targets ?? []).map(target => ({ + element: null, + editId: target.editId ?? null, + anchor: target.anchor ?? null, + selection: target.selection ?? null, + })), + relativeX: serialized.relativeX ?? 0, + relativeY: serialized.relativeY ?? 0, + fixed: !!serialized.fixed, + route: serialized.route ?? currentRoute(), + onMarkerClick, + }); +} + +/** + * Rebuilds annotation comments and their pins from a snapshot. Nothing is + * resolved here: the refresh pass anchors each annotation as its route renders + * @param {ReturnType<typeof exportAnnotationMarkers>|null|undefined} serialized + * @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick + */ +export function importAnnotationMarkers(serialized, onMarkerClick) { + clearAllAnnotationMarkers(); + setComments([]); + + if (!Array.isArray(serialized)) return; + + // Without targets an annotation can never be anchored again, so it would sit + // in the draft as a change the user cannot see, reach or remove. + const annotations = serialized.filter(annotation => annotation?.targets?.length); + if (!annotations.length) return; + + const comments = annotations.map(annotation => ({ + elements: [], + selections: annotation.targets.map(target => target.selection).filter(Boolean), + text: annotation.text ?? '', + attachments: Array.isArray(annotation.attachments) ? annotation.attachments : [], + clickX: annotation.clickX ?? 0, + clickY: annotation.clickY ?? 0, + fixed: !!annotation.fixed, + })); + + setComments(comments); + comments.forEach((comment, index) => addRestoredAnnotationMarker(comment, annotations[index], onMarkerClick)); + refreshAnnotationMarkers(); +} + +/** + * Serializes every annotation for persistence + * @returns {Array<{ + * targets: Array<{editId: string|null, anchor: object|null, selection: object|null}>, + * text: string, clickX: number, clickY: number, + * relativeX: number, relativeY: number, fixed: boolean, route: string, + * attachments: Array<{url: string, fileName: string, size: number}>, + * }>} + */ +export function exportAnnotationMarkers() { + return _markers.map(entry => ({ + targets: entry.targets.map(({ editId, anchor, selection }) => ({ editId, anchor, selection })), + text: entry.comment.text ?? '', + clickX: entry.comment.clickX ?? 0, + clickY: entry.comment.clickY ?? 0, + relativeX: entry.relativeX, + relativeY: entry.relativeY, + fixed: entry.fixed, + route: entry.route, + attachments: entry.comment.attachments ?? [], + })); +} + +export function removeMarker(comment) { + const index = _markers.findIndex(e => e.comment === comment); + if (index === -1) return; + const entry = _markers[index]; + entry.marker.remove(); + _markers.splice(index, 1); +} + +/** Highlights the pin of whichever annotation covers the element, group or not. */ +export function highlightMarkerForElement(element) { + for (const entry of _markers) { + if (entry.elements.includes(element)) { + entry.marker.classList.add('highlighted'); + } + } +} + +export function unhighlightAllMarkers() { + for (const entry of _markers) { + entry.marker.classList.remove('highlighted'); + } +} + +export function clearAllAnnotationMarkers() { + for (const entry of _markers) { + entry.marker.remove(); + } + _markers.length = 0; + window.removeEventListener('scroll', refreshAnnotationMarkers, true); + window.removeEventListener('resize', refreshAnnotationMarkers); + _scrollListenerActive = false; +} diff --git a/apps/web/plugins/visual-editor/ui/annotation-panel/panel.js b/apps/web/plugins/visual-editor/ui/annotation-panel/panel.js new file mode 100644 index 0000000..bdd8be1 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/annotation-panel/panel.js @@ -0,0 +1,544 @@ +import { ANNOTATION_PANEL_HTML } from './template.js'; +import { ANNOTATION_PANEL_STYLES } from './styles.js'; +import { addAnnotationMarker, removeMarker, unhighlightAllMarkers } from './annotation-markers.js'; +import { lockHoverOutline, unlockHoverOutline } from '../overlays/hover-outline.js'; +import { isInFixedContext, getElementType, isolateEditorUiEvents } from '../../utils/dom-utils.js'; +import { captureElementMetadata } from '../../utils/selection-mode-metadata.js'; +import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT, ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT, MAX_ANNOTATION_ATTACHMENTS, TOOLTIP_VIEWPORT_MARGIN } from '../../constants/layout.js'; +import { + getComments, setComments, + getPanelMode, setPanelMode, + getEditingComment, setEditingComment, + getPendingElements, setPendingElements, + getPendingSelections, setPendingSelections, + getPendingAttachments, setPendingAttachments, + getAnnotationClickX, setAnnotationClickX, + getAnnotationClickY, setAnnotationClickY, + getEditorTranslations, +} from '../../state/annotation-state.js'; +import { ICON_DELETE, ICON_IMAGE } from '../../constants/icons.js'; +import { ParentMessage } from '../../constants/messages.js'; +import { postToParent } from '../../utils/parent-frame.js'; +import { clearSelectedElements } from '../../state/multi-select-state.js'; +import { notifyDraftStateChanged } from '../../api/draft-snapshot.js'; +import { getEditing } from '../../state/editing-state.js'; +import { startInlineEdit, commitCurrentEdit } from '../inline-edit/edit-action.js'; +import { showTextFormatToolbar, hideTextFormatToolbar, getToolbarEl } from '../text-format/toolbar/toolbar.js'; + +const DEFAULT_LABELS = { + annotationAddComment: 'Add comment', + annotationSaveComment: 'Save comment', + annotationPlaceholder: 'Tell AI what to change', + annotationElementSelected: '{count} element selected', + annotationElementsSelected: '{count} elements selected', + annotationAttachImage: 'Add images', + annotationRemoveImage: 'Remove image', + annotationDeleteComment: 'Delete comment', +}; + +/** Gap (px) between a panel button and the tooltip floating above it. */ +const BUTTON_TOOLTIP_GAP = 8; + +function label(key, replacements = {}) { + const raw = getEditorTranslations()[key] || DEFAULT_LABELS[key] || key; + return Object.entries(replacements).reduce( + (text, [token, value]) => text.replace(`{${token}}`, value), raw + ); +} + +let _annotationPanelEl = null; +let _buttonTooltipEl = null; +let _stylesInjected = false; +let _panelKeydownHandler = null; +let _isAttachmentUploadInProgress = false; +// Attachments already stored on the comment when the panel opened, so cancelling +// can tell apart images added this session from ones the user saved earlier. +let _savedAttachments = []; + +function selectionLabel(elements) { + const count = elements.length; + const key = count === 1 ? 'annotationElementSelected' : 'annotationElementsSelected'; + return label(key, { count }); +} + +function ensureStyles() { + if (_stylesInjected) return; + const style = document.createElement('style'); + style.id = 'annotation-panel-styles'; + style.textContent = ANNOTATION_PANEL_STYLES; + document.head.appendChild(style); + _stylesInjected = true; +} + +function ensurePanelElement() { + if (_annotationPanelEl) return _annotationPanelEl; + ensureStyles(); + + const wrapper = document.createElement('div'); + wrapper.innerHTML = ANNOTATION_PANEL_HTML; + _annotationPanelEl = wrapper.firstElementChild; + document.body.appendChild(_annotationPanelEl); + + const textarea = _annotationPanelEl.querySelector('#selection-mode-annotation-textarea'); + const commentBtn = _annotationPanelEl.querySelector('#selection-mode-comment-btn'); + const discardBtn = _annotationPanelEl.querySelector('#selection-mode-discard-btn'); + const deleteBtn = _annotationPanelEl.querySelector('#selection-mode-delete-btn'); + const attachImageBtn = _annotationPanelEl.querySelector('#selection-mode-attach-image-btn'); + + textarea.placeholder = label('annotationPlaceholder'); + commentBtn.textContent = label('annotationAddComment'); + attachImageBtn.setAttribute('aria-label', label('annotationAttachImage')); + deleteBtn.setAttribute('aria-label', label('annotationDeleteComment')); + bindButtonTooltip(attachImageBtn); + bindButtonTooltip(deleteBtn); + + textarea.addEventListener('input', () => { + commentBtn.disabled = !textarea.value.trim(); + }); + + commentBtn.addEventListener('click', handleCommentClick); + discardBtn.addEventListener('click', handleDiscardClick); + deleteBtn.addEventListener('click', handleDeleteClick); + attachImageBtn.addEventListener('click', () => { + if (attachImageBtn.disabled) return; + setAttachmentUploadInProgress(true); + postToParent(ParentMessage.ANNOTATION_IMAGE_ATTACH_REQUESTED); + }); + + isolateEditorUiEvents(_annotationPanelEl); + + return _annotationPanelEl; +} + +/** + * Shows the editor's hover tooltip centred above a panel button, reusing the + * `element-type-tooltip` look. The tooltip is a child of the panel: the panel + * already carries the highest z-index the editor has, so a tooltip anywhere + * else on the page could never paint over it. Offsets are therefore measured + * against the panel's own box rather than the viewport. + * @param {HTMLButtonElement} button + */ +function showButtonTooltip(button) { + const text = button.getAttribute('aria-label'); + if (!text || !_annotationPanelEl) return; + + if (!_buttonTooltipEl) { + _buttonTooltipEl = document.createElement('div'); + _buttonTooltipEl.className = 'element-type-tooltip selection-mode-button-tooltip'; + _annotationPanelEl.appendChild(_buttonTooltipEl); + } + + _buttonTooltipEl.textContent = text; + _buttonTooltipEl.classList.add('active'); + + const panelRect = _annotationPanelEl.getBoundingClientRect(); + const buttonRect = button.getBoundingClientRect(); + const { width, height } = _buttonTooltipEl.getBoundingClientRect(); + const centeredLeft = buttonRect.left + (buttonRect.width - width) / 2; + const clampedLeft = Math.min( + Math.max(centeredLeft, TOOLTIP_VIEWPORT_MARGIN), + Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerWidth - width - TOOLTIP_VIEWPORT_MARGIN) + ); + + _buttonTooltipEl.style.left = `${clampedLeft - panelRect.left}px`; + _buttonTooltipEl.style.top = `${buttonRect.top - panelRect.top - height - BUTTON_TOOLTIP_GAP}px`; +} + +function hideButtonTooltip() { + _buttonTooltipEl?.classList.remove('active'); +} + +function bindButtonTooltip(button) { + button.addEventListener('mouseenter', () => { + if (button.disabled) return; + showButtonTooltip(button); + }); + button.addEventListener('mouseleave', hideButtonTooltip); + button.addEventListener('blur', hideButtonTooltip); + button.addEventListener('click', hideButtonTooltip); +} + +function setAttachmentUploadInProgress(isUploading) { + _isAttachmentUploadInProgress = Boolean(isUploading); + updateAttachImageButtonState(); +} + +function releaseAttachments(attachments) { + attachments.forEach(({ url }) => postToParent(ParentMessage.ANNOTATION_IMAGE_REMOVED, { url })); +} + +function attachmentsNotIn(attachments, others) { + const urls = new Set(others.map(({ url }) => url)); + return attachments.filter(({ url }) => !urls.has(url)); +} + +/** + * Adds attachments the parent frame finished uploading to the open panel. + * Clears the paperclip loading state started when attach was requested. + * @param {import('../../state/annotation-state.js').AnnotationAttachment[]} attachments + */ +export function addPendingAttachments(attachments) { + setAttachmentUploadInProgress(false); + + if (!attachments?.length) return; + + if (!_annotationPanelEl?.classList.contains('active')) { + releaseAttachments(attachments); + return; + } + + setPendingAttachments([...getPendingAttachments(), ...attachments]); + renderAttachments(); +} + +function removePendingAttachment(url) { + const removed = getPendingAttachments().filter(attachment => attachment.url === url); + setPendingAttachments(getPendingAttachments().filter(attachment => attachment.url !== url)); + renderAttachments(); + + // An image already stored on the comment is only released once Save confirms + // the removal, so cancelling the panel can bring it back. + if (!_savedAttachments.some(attachment => attachment.url === url)) { + releaseAttachments(removed); + } +} + +function getVisualEditorAttachmentCount() { + const editing = getEditingComment(); + const savedAttachmentsCount = getComments().reduce((total, comment) => { + if (comment === editing) { + return total; + } + + return total + (comment.attachments?.length ?? 0); + }, 0); + + return savedAttachmentsCount + getPendingAttachments().length; +} + +function updateAttachImageButtonState() { + const attachImageBtn = _annotationPanelEl?.querySelector('#selection-mode-attach-image-btn'); + if (!attachImageBtn) return; + + const isAtLimit = getVisualEditorAttachmentCount() >= MAX_ANNOTATION_ATTACHMENTS; + const isUploading = _isAttachmentUploadInProgress; + + attachImageBtn.disabled = isAtLimit || isUploading; + attachImageBtn.classList.toggle('is-loading', isUploading); + attachImageBtn.innerHTML = isUploading + ? '<span class="selection-mode-attach-spinner" aria-hidden="true"></span>' + : ICON_IMAGE; + if (attachImageBtn.disabled) hideButtonTooltip(); +} + +function renderAttachments() { + const container = _annotationPanelEl?.querySelector('#selection-mode-annotation-attachments'); + if (!container) return; + + const attachments = getPendingAttachments(); + container.textContent = ''; + container.classList.toggle('active', attachments.length > 0); + + attachments.forEach(({ url, fileName }) => { + const chip = document.createElement('div'); + chip.className = 'selection-mode-annotation-attachment'; + + const thumbnail = document.createElement('img'); + thumbnail.className = 'selection-mode-annotation-attachment-thumb'; + thumbnail.src = url; + thumbnail.alt = fileName; + thumbnail.title = fileName; + thumbnail.loading = 'lazy'; + thumbnail.decoding = 'async'; + thumbnail.addEventListener('click', () => { + const allAttachments = getPendingAttachments().map(({ url: attachmentUrl, fileName: attachmentName }) => ({ + url: attachmentUrl, + fileName: attachmentName, + })); + postToParent(ParentMessage.ANNOTATION_IMAGE_PREVIEW_REQUESTED, { url, attachments: allAttachments }); + }); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'selection-mode-annotation-attachment-remove'; + removeBtn.title = label('annotationRemoveImage'); + removeBtn.innerHTML = ICON_DELETE; + removeBtn.addEventListener('click', () => removePendingAttachment(url)); + + chip.append(thumbnail, removeBtn); + container.appendChild(chip); + }); + + updateAttachImageButtonState(); +} + +function handleCommentClick() { + const textarea = _annotationPanelEl.querySelector('#selection-mode-annotation-textarea'); + const text = textarea.value.trim(); + if (!text) return; + + if (getPanelMode() === 'edit') { + const editing = getEditingComment(); + if (editing) { + editing.text = text; + editing.attachments = getPendingAttachments(); + notifyDraftStateChanged(); + } + hideAnnotationPanel({ commitPendingAttachments: true }); + return; + } + + const elements = getPendingElements(); + const selections = getPendingSelections(); + const clickX = getAnnotationClickX(); + const clickY = getAnnotationClickY(); + + const comment = { + elements, + selections, + text, + attachments: getPendingAttachments(), + clickX, + clickY, + fixed: elements.length ? isInFixedContext(elements[0]) : false, + }; + setComments([...getComments(), comment]); + addAnnotationMarker(comment, openPanelForEdit); + notifyDraftStateChanged(); + hideAnnotationPanel({ commitPendingAttachments: true }); +} + +function handleDiscardClick() { + hideAnnotationPanel(); +} + +function handleDeleteClick() { + const editing = getEditingComment(); + if (!editing) return; + + releaseAttachments(editing.attachments ?? []); + + removeMarker(editing); + setComments(getComments().filter(c => c !== editing)); + notifyDraftStateChanged(); + hideAnnotationPanel(); +} + +function positionPanel(clickX, clickY, elements, anchorTop = clickY) { + const panel = _annotationPanelEl; + const fixed = elements.length ? isInFixedContext(elements[0]) : false; + panel.style.position = fixed ? 'fixed' : 'absolute'; + + const scrollX = fixed ? 0 : window.scrollX; + const scrollY = fixed ? 0 : window.scrollY; + + let left = clickX; + let top = clickY + PANEL_GAP; + + if (left + ANNOTATION_PANEL_WIDTH > window.innerWidth - PANEL_MARGIN) { + left = window.innerWidth - ANNOTATION_PANEL_WIDTH - PANEL_MARGIN; + } + if (left < PANEL_MARGIN) left = PANEL_MARGIN; + + if (top + ANNOTATION_PANEL_ESTIMATED_HEIGHT > window.innerHeight - PARENT_TOOLBAR_HEIGHT - PANEL_MARGIN) { + // anchorTop is the anchor's top edge (equals clickY for a plain click + // point, but the toolbar's top when anchored to it) so the panel + // flips fully above the anchor instead of overlapping its bottom half. + top = anchorTop - ANNOTATION_PANEL_ESTIMATED_HEIGHT - PANEL_GAP; + } + if (top < PANEL_MARGIN) top = PANEL_MARGIN; + + panel.style.left = `${left + scrollX}px`; + panel.style.top = `${top + scrollY}px`; +} + +/** + * Opens the panel in create mode for a group of one or more elements. + * @param {HTMLElement[]} elements + * @param {object[]} selections - `captureElementMetadata` output, one per element. + */ +export function showAnnotationPanel(elements, selections, clickX, clickY, anchorTop = clickY) { + const panel = ensurePanelElement(); + + setPendingElements(elements); + setPendingSelections(selections); + setPendingAttachments([]); + _savedAttachments = []; + setAnnotationClickX(clickX); + setAnnotationClickY(clickY); + setPanelMode('create'); + setEditingComment(null); + + const textarea = panel.querySelector('#selection-mode-annotation-textarea'); + const commentBtn = panel.querySelector('#selection-mode-comment-btn'); + const deleteWrapper = panel.querySelector('#selection-mode-delete-wrapper'); + const countEl = panel.querySelector('#selection-mode-annotation-count'); + + textarea.value = ''; + commentBtn.disabled = true; + commentBtn.textContent = label('annotationAddComment'); + deleteWrapper.style.display = 'none'; + countEl.textContent = selectionLabel(elements); + renderAttachments(); + + lockHoverOutline(elements); + positionPanel(clickX, clickY, elements, anchorTop); + panel.classList.add('active'); + textarea.focus(); + + attachPanelKeydown(); +} + +export function openPanelForEdit(comment, clickX, clickY) { + const panel = ensurePanelElement(); + const elements = comment.elements; + const single = elements.length === 1 ? elements[0] : null; + + const currentEditing = getEditing(); + if (currentEditing && currentEditing.targetElement !== single) { + hideTextFormatToolbar(); + commitCurrentEdit(); + } + + const elementType = single ? getElementType(single) : null; + const openToolbar = !!single + && (elementType === 'text' || elementType === 'button') + && (single.hasAttribute('data-edit-id') || single.hasAttribute('data-edit-assisted-id')); + + setPanelMode('edit'); + setEditingComment(comment); + setPendingElements(elements); + setPendingSelections(comment.selections); + setPendingAttachments(comment.attachments ? [...comment.attachments] : []); + _savedAttachments = comment.attachments ? [...comment.attachments] : []; + setAnnotationClickX(clickX); + setAnnotationClickY(clickY); + + const textarea = panel.querySelector('#selection-mode-annotation-textarea'); + const commentBtn = panel.querySelector('#selection-mode-comment-btn'); + const deleteWrapper = panel.querySelector('#selection-mode-delete-wrapper'); + const countEl = panel.querySelector('#selection-mode-annotation-count'); + + countEl.textContent = selectionLabel(elements); + textarea.value = comment.text; + commentBtn.disabled = !comment.text.trim(); + commentBtn.textContent = label('annotationSaveComment'); + deleteWrapper.style.display = 'flex'; + renderAttachments(); + + let anchorTop = clickY; + if (openToolbar) { + if (!getEditing() || getEditing().targetElement !== single) { + const editId = single.getAttribute('data-edit-id') || single.getAttribute('data-edit-assisted-id'); + if (editId) { + startInlineEdit(single, editId, single.innerHTML || "", { focusTarget: false }); + } + } + showTextFormatToolbar(single); + const toolbarRect = getToolbarEl()?.getBoundingClientRect(); + if (toolbarRect) { + clickX = toolbarRect.left; + clickY = toolbarRect.bottom; + anchorTop = toolbarRect.top; + } + } + + panel.classList.add('active'); + lockHoverOutline(elements); + unhighlightAllMarkers(); + if (comment.marker) comment.marker.classList.add('highlighted'); + positionPanel(clickX, clickY, elements, anchorTop); + + textarea.focus(); + attachPanelKeydown(); +} + +/** + * `restoreFocus: false` skips re-focusing the inline edit, whose `selectionchange` + * would close a panel the caller opens next; `keepSelection` keeps the group. + * `commitPendingAttachments` keeps the images the caller just saved onto a comment + * and releases the ones it dropped; without it the panel is cancelling, so images + * added during this session are released and previously saved ones are kept. + * @param {{ restoreFocus?: boolean, keepSelection?: boolean, commitPendingAttachments?: boolean }} [options] + */ +export function hideAnnotationPanel({ restoreFocus = true, keepSelection = false, commitPendingAttachments = false } = {}) { + if (!_annotationPanelEl?.classList.contains('active')) return; + + releaseAttachments(commitPendingAttachments + ? attachmentsNotIn(_savedAttachments, getPendingAttachments()) + : attachmentsNotIn(getPendingAttachments(), _savedAttachments)); + + _annotationPanelEl.classList.remove('active'); + hideButtonTooltip(); + setAttachmentUploadInProgress(false); + + setPendingElements([]); + setPendingSelections([]); + setPendingAttachments([]); + _savedAttachments = []; + setEditingComment(null); + setPanelMode('create'); + if (!keepSelection) clearSelectedElements(); + + unlockHoverOutline(); + unhighlightAllMarkers(); + + const editing = getEditing(); + if (restoreFocus && editing?.targetElement?.hasAttribute('contenteditable')) { + editing.targetElement.focus({ preventScroll: true }); + const selection = window.getSelection(); + const range = document.createRange(); + range.selectNodeContents(editing.targetElement); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } + + detachPanelKeydown(); +} + +export function getAnnotationPanelEl() { + return _annotationPanelEl; +} + + +function attachPanelKeydown() { + detachPanelKeydown(); + _panelKeydownHandler = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + hideAnnotationPanel(); + } + }; + window.addEventListener('keydown', _panelKeydownHandler, true); +} + +function detachPanelKeydown() { + if (_panelKeydownHandler) { + window.removeEventListener('keydown', _panelKeydownHandler, true); + _panelKeydownHandler = null; + } +} + +/** + * Opens the panel for one element or a group, resuming the existing comment when + * the same target is already annotated and capturing fresh metadata otherwise. + * @param {HTMLElement|HTMLElement[]} target + * @param {number} clickX + * @param {number} clickY + * @param {number} [anchorTop] - Top edge of the anchor (e.g. a toolbar) used + * when flipping the panel above; defaults to clickY for a plain click point. + */ +export function openOrCreateAnnotation(target, clickX, clickY, anchorTop = clickY) { + const elements = (Array.isArray(target) ? target : [target]).filter(Boolean); + if (!elements.length) return; + + const existing = getComments().find(comment => comment.elements.length === elements.length + && comment.elements.every(element => elements.includes(element))); + + if (existing) { + openPanelForEdit(existing, existing.clickX, existing.clickY); + } else { + showAnnotationPanel(elements, elements.map(captureElementMetadata), clickX, clickY, anchorTop); + } +} diff --git a/apps/web/plugins/visual-editor/ui/annotation-panel/styles.js b/apps/web/plugins/visual-editor/ui/annotation-panel/styles.js new file mode 100644 index 0000000..41954c2 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/annotation-panel/styles.js @@ -0,0 +1,288 @@ +import { ANNOTATION_COLOR, PANEL_BG, PANEL_INPUT_BG, BORDER_COLOR, HOVER_BG, TEXT_MUTED, COLOR_WHITE, BUTTON_LIGHT_TEXT, BUTTON_LIGHT_BG_HOVER, BOX_SHADOW_TOOLBAR, SCROLLBAR_THUMB, SCROLLBAR_THUMB_HOVER, Z_INDEX_EDITOR_PANEL, Z_INDEX_EDITOR_MARKER } from '../../constants/theme.js'; + +export const ANNOTATION_PANEL_STYLES = ` + #selection-mode-annotation-panel { + display: none; + flex-direction: column; + gap: 8px; + position: fixed; + background: ${PANEL_BG}; + border-radius: 16px; + border: 1px solid ${BORDER_COLOR}; + padding: 8px; + width: 320px; + box-shadow: ${BOX_SHADOW_TOOLBAR}; + pointer-events: all; + box-sizing: border-box; + font-family: DM Sans, sans-serif; + z-index: ${Z_INDEX_EDITOR_PANEL}; + + &.active { + display: flex; + } + } + + #selection-mode-annotation-header { + display: flex; + align-items: center; + gap: 16px; + padding-left: 8px; + } + + #selection-mode-annotation-count { + flex: 1 0 0; + min-width: 0; + color: ${TEXT_MUTED}; + font-size: 14px; + line-height: 20px; + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + #selection-mode-annotation-input-wrapper { + display: flex; + flex-direction: column; + background: ${PANEL_INPUT_BG}; + border: 1px solid ${BORDER_COLOR}; + border-radius: 8px; + box-sizing: border-box; + padding: 16px; + } + + #selection-mode-annotation-textarea { + width: 100%; + min-height: 88px; + max-height: 160px; + background: transparent; + border: none; + outline: none; + padding: 0 4px; + color: ${COLOR_WHITE}; + font-size: 16px; + font-family: inherit; + resize: none; + box-sizing: border-box; + caret-color: ${COLOR_WHITE}; + line-height: 24px; + overflow-y: auto; + } + + #selection-mode-annotation-textarea::placeholder { + color: ${TEXT_MUTED}; + } + + #selection-mode-annotation-buttons { + display: flex; + justify-content: space-between; + align-items: center; + } + + /* Mirrors the chat file previews: chips scroll sideways instead of stacking into rows. */ + #selection-mode-annotation-attachments { + display: none; + overflow-x: auto; + padding-top: 4px; + margin-top: -4px; + margin-bottom: 16px; + scrollbar-width: thin; + scrollbar-color: ${SCROLLBAR_THUMB} transparent; + + &.active { + display: flex; + } + + &::-webkit-scrollbar { + height: 8px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: ${SCROLLBAR_THUMB}; + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; + } + + &::-webkit-scrollbar-thumb:hover { + background: ${SCROLLBAR_THUMB_HOVER}; + } + } + + .selection-mode-annotation-attachment { + position: relative; + display: inline-block; + flex-shrink: 0; + padding-right: 12px; + } + + .selection-mode-annotation-attachment-thumb { + width: 48px; + height: 48px; + border: 1px solid ${BORDER_COLOR}; + border-radius: 8px; + object-fit: cover; + cursor: pointer; + user-select: none; + display: block; + box-sizing: border-box; + } + + .selection-mode-annotation-attachment-remove { + position: absolute; + top: -4px; + right: 8px; + background: ${PANEL_BG}; + border: 1px solid ${BORDER_COLOR}; + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + color: ${COLOR_WHITE}; + + & svg { + width: 16px; + height: 16px; + } + + &:hover { + background: ${BORDER_COLOR}; + } + } + + #selection-mode-annotation-actions, + #selection-mode-annotation-primary-actions { + display: flex; + align-items: center; + gap: 4px; + } + + /* Overrides the hover tooltip's fixed positioning; the id keeps this winning + no matter which of the two stylesheets is injected last. */ + #selection-mode-annotation-panel .selection-mode-button-tooltip { + position: absolute; + } + + #selection-mode-discard-btn, + #selection-mode-attach-image-btn, + #selection-mode-delete-btn { + background: none; + border: none; + cursor: pointer; + padding: 8px; + border-radius: 8px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + box-sizing: border-box; + } + + #selection-mode-discard-btn:hover, + #selection-mode-attach-image-btn:hover:not(:disabled), + #selection-mode-delete-btn:hover { + background: ${HOVER_BG}; + } + + #selection-mode-attach-image-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + } + + #selection-mode-attach-image-btn.is-loading:disabled { + opacity: 1; + cursor: wait; + } + + /* Mirrors horizons-frontend Spinner.vue (w-4 border-1 border-light border-t-transparent, 2s spin). */ + .selection-mode-attach-spinner { + box-sizing: border-box; + width: 16px; + height: 16px; + border-radius: 9999px; + border: 1px solid ${COLOR_WHITE}; + border-top-color: transparent; + animation: selection-mode-attach-spin 2s linear infinite; + } + + @keyframes selection-mode-attach-spin { + to { + transform: rotate(360deg); + } + } + + #selection-mode-delete-wrapper { + position: relative; + display: flex; + align-items: center; + width: 32px; + height: 32px; + } + + #selection-mode-comment-btn { + background: ${COLOR_WHITE}; + border: none; + color: ${BUTTON_LIGHT_TEXT}; + font-family: inherit; + font-size: 14px; + line-height: 20px; + font-weight: 600; + cursor: pointer; + padding: 6px 12px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + white-space: nowrap; + } + + #selection-mode-comment-btn:hover { + background: ${BUTTON_LIGHT_BG_HOVER}; + } + + #selection-mode-comment-btn:disabled { + opacity: 0.2; + cursor: not-allowed; + background: ${COLOR_WHITE}; + } +`; + +export const ANNOTATION_MARKER_STYLES = ` + .selection-mode-annotation-marker > * { + pointer-events: none; + } + + .selection-mode-annotation-marker { + width: 28px; + height: 28px; + border-radius: 50%; + background: ${ANNOTATION_COLOR}; + border: 1px solid ${COLOR_WHITE}25; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + pointer-events: auto; + cursor: pointer; + user-select: none; + transform: translate(-50%, -50%); + transition: transform 0.15s ease; + z-index: ${Z_INDEX_EDITOR_MARKER}; + } + + .selection-mode-annotation-marker:hover, + .selection-mode-annotation-marker.highlighted { + transform: translate(-50%, -50%) scale(1.1); + } +`; diff --git a/apps/web/plugins/visual-editor/ui/annotation-panel/template.js b/apps/web/plugins/visual-editor/ui/annotation-panel/template.js new file mode 100644 index 0000000..d7d99ea --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/annotation-panel/template.js @@ -0,0 +1,25 @@ +import { ICON_DELETE, ICON_CLOSE, ICON_IMAGE } from '../../constants/icons.js'; + +export const ANNOTATION_PANEL_HTML = ` +<div id="selection-mode-annotation-panel"> + <div id="selection-mode-annotation-header"> + <span id="selection-mode-annotation-count"></span> + <button id="selection-mode-discard-btn" title="Close">${ICON_CLOSE}</button> + </div> + <div id="selection-mode-annotation-input-wrapper"> + <div id="selection-mode-annotation-attachments"></div> + <textarea id="selection-mode-annotation-textarea" placeholder="Tell AI what to change" rows="1"></textarea> + </div> + <div id="selection-mode-annotation-buttons"> + <div id="selection-mode-annotation-actions"> + <button id="selection-mode-attach-image-btn" aria-label="Add images">${ICON_IMAGE}</button> + </div> + <div id="selection-mode-annotation-primary-actions"> + <div id="selection-mode-delete-wrapper"> + <button id="selection-mode-delete-btn" aria-label="Delete comment">${ICON_DELETE}</button> + </div> + <button id="selection-mode-comment-btn" disabled>Add comment</button> + </div> + </div> +</div> +`; diff --git a/apps/web/plugins/visual-editor/ui/inline-edit/cursor-styles.js b/apps/web/plugins/visual-editor/ui/inline-edit/cursor-styles.js new file mode 100644 index 0000000..bb7262d --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/inline-edit/cursor-styles.js @@ -0,0 +1,93 @@ +import { getEditing } from "../../state/editing-state.js"; +import { stopFontSizeEdit, updateTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js"; +import { hideAllPanels } from "../../state/panel-state.js"; +import { resolveInlineStylesAtRange } from "../../utils/inline-style-state.js"; +import { EDITOR_UI_SELECTOR } from "../../constants/selectors.js"; + +/** + * @param {HTMLElement} element + * @param {HTMLElement} container + * @returns {{ value: string, isGradient: boolean }} + */ +function resolveTextColor(element, container) { + const color = getComputedStyle(element).color; + + if (color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') { + return { value: color, isGradient: false }; + } + + let current = element; + while (current && current !== container.parentElement) { + const computedStyle = getComputedStyle(current); + const backgroundImage = computedStyle.backgroundImage; + const backgroundClip = computedStyle.backgroundClip || computedStyle['webkitBackgroundClip']; + + if (backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('gradient') && backgroundClip === 'text') { + return { value: backgroundImage, isGradient: true }; + } + current = current.parentElement; + } + + return { value: color, isGradient: false }; +} + +/** + * Resolves caret/selection presentation state for the formatting toolbar. + * Bold, italic, and underline are detected independently via ancestor walks + * so nested markup (`<em><u><strong>…</strong></u></em>`) reports all active styles. + * @returns {object|null} + */ +export function getStylesAtCursor() { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + let node = range.startContainer; + + if (node.nodeType === Node.ELEMENT_NODE) { + const child = node.childNodes[range.startOffset]; + if (child) node = child; + } + + if (node.nodeType === Node.TEXT_NODE) { + node = node.parentElement; + } + + if (!node || node.nodeType !== Node.ELEMENT_NODE) { + node = getEditing()?.targetElement; + } + + if (!node) return null; + + const container = getEditing()?.targetElement || node; + const presentation = resolveInlineStylesAtRange(range, container); + const styles = getComputedStyle(node); + const containerStyles = getComputedStyle(container); + const resolvedColor = resolveTextColor(node, container); + + return { + color: resolvedColor.value, + isGradient: resolvedColor.isGradient, + fontFamily: styles.fontFamily, + fontSize: styles.fontSize, + textAlign: containerStyles.textAlign, + ...presentation, + }; +} + +/** Updates toolbar and parent when the selection moves during inline edit. */ +export function onSelectionChange(force = false) { + const editing = getEditing(); + if (!editing?.targetElement?.hasAttribute("contenteditable")) return; + if (!force && document.activeElement?.closest(EDITOR_UI_SELECTOR)) return; + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0 && !editing.targetElement.contains(selection.getRangeAt(0).commonAncestorContainer)) { + return; + } + stopFontSizeEdit(); + hideAllPanels(); + const styles = getStylesAtCursor(); + if (!styles) return; + updateTextFormatToolbar(styles); + scheduleRepositionEditOverlays(); +} diff --git a/apps/web/plugins/visual-editor/ui/inline-edit/edit-action.js b/apps/web/plugins/visual-editor/ui/inline-edit/edit-action.js new file mode 100644 index 0000000..5a632d7 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/inline-edit/edit-action.js @@ -0,0 +1,368 @@ +import { getEditing, setEditing, clearEditing } from "../../state/editing-state.js"; +import { normalizeContentEditableHtml, patchRemoveChild, serializeContentEditableHtml } from "../../utils/html-utils.js"; +import { postToParent } from "../../utils/parent-frame.js"; +import { ParentMessage } from "../../constants/messages.js"; +import { beginEditSession, recordEdit } from "../../state/history-state.js"; +import { notifyDraftStateChanged } from "../../api/draft-snapshot.js"; +import { showTextFormatToolbar, hideTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js"; +import { onSelectionChange } from "./cursor-styles.js"; +import { captureElementMetadata } from "../../utils/selection-mode-metadata.js"; + + +/** + * Converts a CSS property name to its camelCase JS form + * (e.g. `background-clip` → `backgroundClip`), matching the keys used by the + * JSX style objects in the source write-back. + */ +function cssPropertyToCamelCase(cssProperty) { + return cssProperty.replace(/^-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +const TRANSPARENT_TEXT_CARET_COLOR = "#6B7280"; + +function isTransparentColor(color) { + if (!color || color === "transparent") return true; + const match = color.replace(/\s+/g, "").match(/^rgba?\(([^)]+)\)$/i); + if (!match) return false; + const segments = match[1].split(","); + return segments.length === 4 && parseFloat(segments[3]) === 0; +} + +/** + * Reports whether the element or any descendant renders transparent text + * (e.g. gradient text via `background-clip: text` + `color: transparent`), + * where the caret would otherwise be invisible. + * @param {HTMLElement} element + * @returns {boolean} + */ +function hasTransparentText(element) { + if (isTransparentColor(getComputedStyle(element).color)) return true; + return Array.from(element.querySelectorAll("*")).some( + descendant => isTransparentColor(getComputedStyle(descendant).color) + ); +} + +/** `caret-color: currentColor` is invisible on transparent/gradient text, so use a visible caret there. */ +function applyEditingCaretColor(element) { + if (hasTransparentText(element)) { + element.style.caretColor = TRANSPARENT_TEXT_CARET_COLOR; + } else { + element.style.removeProperty("caret-color"); + } +} + +/** + * Snapshots every style property set inline on the element itself. + * @param {HTMLElement} element + * @returns {Record<string, string>} camelCase property → value + */ +function captureInlineStyles(element) { + const styles = {}; + for (let index = 0; index < element.style.length; index++) { + const cssProperty = element.style.item(index); + styles[cssPropertyToCamelCase(cssProperty)] = element.style.getPropertyValue(cssProperty); + } + return styles; +} + +/** + * Diffs the element's current inline styles against a snapshot, covering any + * panel action that styles the element itself ('' marks a removed property). + * `textAlign` is excluded here — it is compared against computed style instead, + * so re-applying an alignment inherited from CSS is not logged as a change. + * @param {HTMLElement} element + * @param {Record<string, string>} original + * @returns {{ changed: Record<string, string>, previous: Record<string, string> }|null} + */ +function getInlineStyleDiff(element, original) { + const current = captureInlineStyles(element); + const changed = {}; + const previous = {}; + for (const key of new Set([...Object.keys(current), ...Object.keys(original)])) { + if (key === 'textAlign') continue; + // Transient editing caret-color must never be written back to source. + if (key === 'caretColor') continue; + if ((current[key] ?? '') !== (original[key] ?? '')) { + changed[key] = current[key] ?? ''; + previous[key] = original[key] ?? ''; + } + } + return Object.keys(changed).length > 0 ? { changed, previous } : null; +} + +/** + * Computes element-level style changes (alignment plus any other inline style + * the panel set on the element) since the edit session's last snapshot. + * @param {object} editing - Current editing state. + * @returns {{ style: object|null }} + */ +function buildStyleDiff(editing) { + const { targetElement, originalTextAlign, originalInlineTextAlign, originalInlineStyles } = editing; + const alignChanged = getComputedStyle(targetElement).textAlign !== originalTextAlign; + const inlineDiff = getInlineStyleDiff(targetElement, originalInlineStyles); + if (!alignChanged && !inlineDiff) return { style: null, oldStyle: null }; + + const style = {}; + const oldStyle = {}; + if (alignChanged) { + style.textAlign = targetElement.style.textAlign; + // Keep previous inline value around so re-applying the inherited default still serializes. + if (!style.textAlign) style.textAlign = originalInlineTextAlign ?? ''; + oldStyle.textAlign = originalInlineTextAlign ?? ''; + } + if (inlineDiff) { + Object.assign(style, inlineDiff.changed); + Object.assign(oldStyle, inlineDiff.previous); + } + return { style, oldStyle }; +} + +/** Re-snapshots the editing state's style baselines after a soft-save. */ +function refreshStyleSnapshots(editing) { + const { targetElement } = editing; + editing.originalTextAlign = getComputedStyle(targetElement).textAlign; + editing.originalInlineTextAlign = targetElement.style.textAlign || ''; + editing.originalInlineStyles = captureInlineStyles(targetElement); +} + +/** + * Snapshots child nodes so DOM mutations during edit can be restored if needed. + * @param {HTMLElement} element + * @returns {Array<{ node: Node, value: string|null }>} + */ +export function saveChildNodes(element) { + const saved = []; + for (const child of element.childNodes) { + saved.push({ + node: child, + value: child.nodeType === Node.TEXT_NODE ? child.nodeValue : null, + }); + } + return saved; +} + +/** + * Places a collapsed caret at the end of the editable container's contents. + * @param {HTMLElement} container + */ +export function placeCursorAtEnd(container) { + if (!container.childNodes.length) { + container.appendChild(document.createTextNode('')); + } + + let target = container; + while (target.lastChild) { + target = target.lastChild; + } + + const range = document.createRange(); + if (target.nodeType === Node.TEXT_NODE) { + range.setStart(target, target.textContent.length); + } else { + range.selectNodeContents(target); + } + range.collapse(false); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/** + * Places the text caret at viewport coordinates, clamped to the editable container. + * @param {number} x + * @param {number} y + * @param {HTMLElement} [container] + */ +export function placeCursorAtPoint(x, y, container) { + let range = null; + if (document.caretPositionFromPoint) { + const position = document.caretPositionFromPoint(x, y); + if (position) { + range = document.createRange(); + range.setStart(position.offsetNode, position.offset); + range.collapse(true); + } + } else if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(x, y); + } + + // If the caret resolved outside our editable target (e.g. an absolute overlay + // sat on top of the click point), fall back to placing it at the end of the + // target so typing actually goes into the contenteditable. + if (container && (!range || !container.contains(range.startContainer))) { + range = document.createRange(); + range.selectNodeContents(container); + range.collapse(false); + } + + if (!range) return; + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/** + * Enables contenteditable on the target and wires edit listeners and toolbar. + * @param {HTMLElement} targetElement + * @param {string} editId + * @param {string} currentContent - Initial HTML content. + * @param {{ focusTarget?: boolean }} [options] - Pass `focusTarget: false` when the caller focuses its own UI instead. + */ +export function startInlineEdit(targetElement, editId, currentContent, { focusTarget = true } = {}) { + if (getEditing()?.targetElement === targetElement) return; + + const savedChildren = saveChildNodes(targetElement); + const normalizedOriginal = normalizeContentEditableHtml(currentContent); + const sessionId = beginEditSession(); + const selectionMode = captureElementMetadata(targetElement); + setEditing({ + editId, + targetElement, + sessionId, + originalContent: normalizedOriginal, + originalTextAlign: getComputedStyle(targetElement).textAlign, + originalInlineTextAlign: targetElement.style.textAlign || '', + originalInlineStyles: captureInlineStyles(targetElement), + savedChildren, + selectionMode, + }); + document.getElementById("root")?.setAttribute("data-inline-editing", "true"); + targetElement.setAttribute("data-editing", "true"); + patchRemoveChild(targetElement); + + targetElement.setAttribute("contenteditable", "true"); + applyEditingCaretColor(targetElement); + + if (focusTarget) { + targetElement.focus(); + placeCursorAtEnd(targetElement); + + requestAnimationFrame(() => { + if (getEditing()?.targetElement !== targetElement) return; + placeCursorAtEnd(targetElement); + targetElement.focus(); + }); + } + + postToParent(ParentMessage.EDIT_ENTER, { + currentText: currentContent, + elementType: targetElement.tagName.toLowerCase(), + isDirectlyEditable: targetElement.hasAttribute('data-edit-id'), + }); + showTextFormatToolbar(targetElement); + targetElement.addEventListener("keydown", onInlineEditKeyDown); + targetElement.addEventListener("beforeinput", onInlineEditBeforeInput); + targetElement.addEventListener("input", onInlineEditInput); + targetElement.addEventListener("tft-color-commit", saveCurrentEdit); + document.addEventListener("selectionchange", onSelectionChange); + setTimeout(onSelectionChange, 0); +} + +/** + * Soft-saves the current edit without tearing down contenteditable (e.g. color commit), + * recording it into the in-iframe edit history so it can be undone/redone and + * later batch-persisted on draft save. + */ +export function saveCurrentEdit() { + const editing = getEditing(); + if (!editing) return; + + const { editId, targetElement, originalContent, sessionId, selectionMode } = editing; + const newHTML = serializeContentEditableHtml(targetElement); + const { style, oldStyle } = buildStyleDiff(editing); + const isAssisted = targetElement.hasAttribute('data-edit-assisted-id'); + + if (newHTML !== originalContent || style) { + recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode }); + notifyDraftStateChanged(); + editing.originalContent = newHTML; + editing.hasSoftSaved = true; + refreshStyleSnapshots(editing); + } +} + +/** Finalizes the active inline edit, records it into history, and clears editing state. */ +export function commitCurrentEdit() { + const editing = getEditing(); + if (!editing) return; + + const { editId, targetElement, originalContent, sessionId, selectionMode } = editing; + const newHTML = serializeContentEditableHtml(targetElement); + const { style, oldStyle } = buildStyleDiff(editing); + const isAssisted = targetElement.hasAttribute('data-edit-assisted-id'); + + targetElement.removeAttribute("contenteditable"); + targetElement.removeAttribute("data-editing"); + targetElement.style.removeProperty("caret-color"); + document.getElementById("root")?.removeAttribute("data-inline-editing"); + targetElement.removeEventListener("keydown", onInlineEditKeyDown); + targetElement.removeEventListener("beforeinput", onInlineEditBeforeInput); + targetElement.removeEventListener("input", onInlineEditInput); + targetElement.removeEventListener("tft-color-commit", saveCurrentEdit); + document.removeEventListener("selectionchange", onSelectionChange); + hideTextFormatToolbar(); + + patchRemoveChild(targetElement); + targetElement.innerHTML = newHTML; + + const { hasSoftSaved } = editing; + + if (newHTML !== originalContent || style) { + recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode }); + notifyDraftStateChanged(); + } else if (!hasSoftSaved) { + postToParent(ParentMessage.EDIT_CANCEL, {}); + } + + clearEditing(); +} + +/** + * Blocks browser replacement-text input during inline edit. + * @param {InputEvent} e + */ +export function onInlineEditBeforeInput(event) { + if (event.inputType === "insertReplacementText") { + event.preventDefault(); + return; + } + scheduleRepositionEditOverlays(); +} + +/** Repositions the toolbar as the editable content grows/shrinks. */ +export function onInlineEditInput() { + if (!getEditing()) return; + saveCurrentEdit(); + scheduleRepositionEditOverlays(); +} + +/** + * Handles Escape/Enter to commit and stops propagation for other keys. + * @param {KeyboardEvent} e + */ +export function onInlineEditKeyDown(event) { + if (event.key === "Escape") { + event.preventDefault(); + commitCurrentEdit(); + return; + } + if (event.key === "Enter") { + if (event.shiftKey) { + event.stopPropagation(); + return; + } + event.preventDefault(); + commitCurrentEdit(); + return; + } + if (event.key === " " && event.currentTarget.tagName.toLowerCase() === "button") { + // Native <button> treats Space as activation, not text input, so the + // browser never inserts a character. Prevent the activation and insert + // the space manually (execCommand keeps the input event). + event.preventDefault(); + document.execCommand("insertText", false, " "); + return; + } + event.stopPropagation(); +} + diff --git a/apps/web/plugins/visual-editor/ui/inline-edit/styles.js b/apps/web/plugins/visual-editor/ui/inline-edit/styles.js new file mode 100644 index 0000000..2cf706a --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/inline-edit/styles.js @@ -0,0 +1,85 @@ +import { EDIT_BORDER_COLOR, EDIT_BACKGROUND_COLOR, PANEL_BG, BORDER_COLOR, COLOR_WHITE, Z_INDEX_EDITOR_OVERLAY } from '../../constants/theme.js'; +import { ICON_TEXT_EDIT_CURSOR } from '../../constants/icons.js'; +import { HOVER_OUTLINE_STROKE_WIDTH } from '../../constants/layout.js'; +import { EDITOR_UI_SELECTOR } from '../../constants/selectors.js'; + +/** Injected CSS for edit-mode hover outlines, contenteditable affordances, and the element-type tooltip. */ +export const EDIT_MODE_STYLES = ` + #root[data-edit-mode-enabled="true"] [data-edit-id], + #root[data-edit-mode-enabled="true"] [data-edit-assisted-id] { + cursor: default !important; + overflow-wrap: anywhere; + } + #root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"], + #root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] { + outline: 1px solid ${EDIT_BORDER_COLOR}; + caret-color: currentColor; + user-select: text; + -webkit-user-select: text; + cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important; + } + #root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"] *, + #root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] * { + cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important; + } + + /* Suppress native cursor on actionable elements while editing. */ + #root[data-edit-mode-enabled="true"] a, + #root[data-edit-mode-enabled="true"] button, + #root[data-edit-mode-enabled="true"] [role="button"], + #root[data-edit-mode-enabled="true"] [type="submit"] { + cursor: default !important; + } + + .inline-editor-hover-outline, + .inline-editor-locked-outline { + outline-offset: -${HOVER_OUTLINE_STROKE_WIDTH}px; + box-shadow: inset 0 0 0 999px ${EDIT_BACKGROUND_COLOR}; + } + + .inline-editor-hover-outline { + outline: ${HOVER_OUTLINE_STROKE_WIDTH}px dashed ${EDIT_BORDER_COLOR}; + } + + .inline-editor-locked-outline { + outline: ${HOVER_OUTLINE_STROKE_WIDTH}px solid ${EDIT_BORDER_COLOR}; + } + + @keyframes element-type-tooltip-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + .element-type-tooltip { + display: none; + opacity: 0; + position: fixed; + top: 0; + left: 0; + background-color: ${PANEL_BG}; + color: ${COLOR_WHITE}; + padding: 0 4px; + border-radius: 6px; + font-family: DM Sans, sans-serif; + font-size: 12px; + font-weight: 400; + line-height: 20px; + border: 1px solid ${BORDER_COLOR}; + pointer-events: none; + white-space: nowrap; + z-index: ${Z_INDEX_EDITOR_OVERLAY}; + } + + .element-type-tooltip.active { + display: block; + animation: element-type-tooltip-fade-in 0.2s ease-out forwards; + } + + ${EDITOR_UI_SELECTOR} { + pointer-events: auto; + } +`; diff --git a/apps/web/plugins/visual-editor/ui/overlays/hover-outline.js b/apps/web/plugins/visual-editor/ui/overlays/hover-outline.js new file mode 100644 index 0000000..b073f23 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/overlays/hover-outline.js @@ -0,0 +1,71 @@ +export const HOVER_OUTLINE_CLASS = 'inline-editor-hover-outline'; +export const LOCKED_OUTLINE_CLASS = 'inline-editor-locked-outline'; + +/** @param {HTMLElement|HTMLElement[]|null} value @returns {HTMLElement[]} */ +function toArray(value) { + if (!value) return []; + return Array.isArray(value) ? value.filter(Boolean) : [value]; +} + +/** @param {HTMLElement[]} elements @param {string} className */ +function addClassTo(elements, className) { + for (const element of elements) element.classList.add(className); +} + +/** @param {HTMLElement[]} elements @param {string} className */ +function removeClassFrom(elements, className) { + for (const element of elements) element.classList.remove(className); +} + +/** @type {HTMLElement[]} */ +let _hoveredElements = []; +/** @type {HTMLElement[]} */ +let _lockedElements = []; +let _holdCount = 0; + +/** + * Dashed outline for the transient hover. Elements already carrying a locked + * outline are skipped so they aren't outlined twice. + * @param {HTMLElement|HTMLElement[]} elements + */ +export function showHoverOutline(elements) { + const targets = toArray(elements).filter(element => !_lockedElements.includes(element)); + if (!targets.length) return; + removeClassFrom(_hoveredElements, HOVER_OUTLINE_CLASS); + _hoveredElements = targets; + addClassTo(_hoveredElements, HOVER_OUTLINE_CLASS); +} + +/** Hides the hover outline (no-op while held). */ +export function hideHoverOutline() { + if (_holdCount > 0) return; + removeClassFrom(_hoveredElements, HOVER_OUTLINE_CLASS); + _hoveredElements = []; +} + +export function holdHoverOutline() { _holdCount++; } +export function releaseHoverOutline() { + _holdCount = Math.max(0, _holdCount - 1); + if (_holdCount === 0) hideHoverOutline(); +} + +/** + * Solid outline for a committed selection — a drag preview, a shift+click + * group, or the element(s) an open annotation panel targets. + * @param {HTMLElement|HTMLElement[]} elements + */ +export function lockHoverOutline(elements) { + const targets = toArray(elements); + if (!targets.length) { + unlockHoverOutline(); + return; + } + removeClassFrom(_lockedElements, LOCKED_OUTLINE_CLASS); + _lockedElements = targets; + addClassTo(_lockedElements, LOCKED_OUTLINE_CLASS); +} + +export function unlockHoverOutline() { + removeClassFrom(_lockedElements, LOCKED_OUTLINE_CLASS); + _lockedElements = []; +} diff --git a/apps/web/plugins/visual-editor/ui/overlays/selection-rect.js b/apps/web/plugins/visual-editor/ui/overlays/selection-rect.js new file mode 100644 index 0000000..2ab2c79 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/overlays/selection-rect.js @@ -0,0 +1,140 @@ +import { SELECTION_RECT_ID, isInsideEditorUi, getEditId } from '../../constants/selectors.js'; +import { EDIT_BACKGROUND_COLOR, Z_INDEX_EDITOR_OVERLAY, EDIT_BORDER_COLOR } from '../../constants/theme.js'; +import { MIN_SELECTION_CANDIDATE_SIZE, MAX_SELECTED_ELEMENTS } from '../../constants/layout.js'; +import { getAppRoot, getOpenModals } from '../../utils/dom-utils.js'; +import { orderSelection } from '../../state/multi-select-state.js'; + +/** Set on `<body>` for the duration of a selection gesture, to keep the browser from painting text over it. */ +export const NO_TEXT_SELECT_CLASS = 'selection-mode-no-text-select'; + +const SELECTION_RECT_STYLES = ` +#${SELECTION_RECT_ID} { + position: fixed; + display: none; + pointer-events: none; + background: ${EDIT_BACKGROUND_COLOR}; + border: 1px solid ${EDIT_BORDER_COLOR}; + z-index: ${Z_INDEX_EDITOR_OVERLAY}; +} + +body.${NO_TEXT_SELECT_CLASS}, body.${NO_TEXT_SELECT_CLASS} * { + user-select: none !important; +} +`; + +let _rectElement = null; +let _stylesInjected = false; + +function ensureStyles() { + if (_stylesInjected) return; + const style = document.createElement('style'); + style.id = 'selection-rect-styles'; + style.textContent = SELECTION_RECT_STYLES; + document.head.appendChild(style); + _stylesInjected = true; +} + +/* ------------------------------------------------------------------ * + * Geometry + * ------------------------------------------------------------------ */ + +/** + * Normalises two drag corners into a viewport-space rect. + * @returns {{x: number, y: number, w: number, h: number}} + */ +export function getSelectionRect(x1, y1, x2, y2) { + return { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) }; +} + +function intersects(bounds, rect) { + return bounds.right > rect.x && bounds.left < rect.x + rect.w + && bounds.bottom > rect.y && bounds.top < rect.y + rect.h; +} + +/* ------------------------------------------------------------------ * + * Candidate resolution + * ------------------------------------------------------------------ */ + +/** + * True when the element is a plausible annotation target: visible, big enough to + * aim at, and not part of the editor's own UI. + * @param {HTMLElement} element + * @param {DOMRect} bounds + * @returns {boolean} + */ +function isSelectableCandidate(element, bounds) { + if (bounds.width < MIN_SELECTION_CANDIDATE_SIZE || bounds.height < MIN_SELECTION_CANDIDATE_SIZE) return false; + if (isInsideEditorUi(element)) return false; + + const styles = window.getComputedStyle(element); + return styles.display !== 'none' && styles.visibility !== 'hidden'; +} + +/** Children the band also touches; an empty result means this element is as deep as it goes. */ +function intersectingChildren(element, rect) { + return Array.from(element.children).filter((child) => { + const bounds = child.getBoundingClientRect(); + return isSelectableCandidate(child, bounds) && intersects(bounds, rect); + }); +} + +/** + * Everything the band touches, taking the deepest element on each branch so a + * container never comes along with the children that made it a hit. + * @param {{x: number, y: number, w: number, h: number}} rect + * @returns {HTMLElement[]} Document-ordered, with no member nested in another. + */ +export function collectSelection(rect) { + const selected = []; + const openModals = getOpenModals(); + const roots = openModals.length ? openModals : [getAppRoot()]; + const queue = roots.flatMap((root) => [...root.children]); + + while (queue.length && selected.length < MAX_SELECTED_ELEMENTS) { + const element = queue.shift(); + const bounds = element.getBoundingClientRect(); + // No overlap prunes the whole subtree: children sit inside their parent's box. + if (!isSelectableCandidate(element, bounds) || !intersects(bounds, rect)) continue; + + // An edit id marks the editor's own annotation target, so stop there rather + // than splitting a button or heading into the spans it is built from. + const children = getEditId(element) ? [] : intersectingChildren(element, rect); + if (children.length) { + queue.push(...children); + } else { + selected.push(element); + } + } + + return orderSelection(selected); +} + +/* ------------------------------------------------------------------ * + * Band rendering + * ------------------------------------------------------------------ */ + +export function showSelectionRect(x1, y1, x2, y2) { + if (!_rectElement) { + ensureStyles(); + _rectElement = document.createElement('div'); + _rectElement.id = SELECTION_RECT_ID; + document.body.appendChild(_rectElement); + } + if (!_rectElement.isConnected) document.body.appendChild(_rectElement); + + const rect = getSelectionRect(x1, y1, x2, y2); + _rectElement.style.left = `${rect.x}px`; + _rectElement.style.top = `${rect.y}px`; + _rectElement.style.width = `${rect.w}px`; + _rectElement.style.height = `${rect.h}px`; + _rectElement.style.display = 'block'; +} + +export function hideSelectionRect() { + if (_rectElement) _rectElement.style.display = 'none'; +} + +export function destroySelectionRect() { + _rectElement?.remove(); + _rectElement = null; +} diff --git a/apps/web/plugins/visual-editor/ui/overlays/type-tooltip.js b/apps/web/plugins/visual-editor/ui/overlays/type-tooltip.js new file mode 100644 index 0000000..56f3378 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/overlays/type-tooltip.js @@ -0,0 +1,73 @@ +import { getElementType } from '../../utils/dom-utils.js'; +import { TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN } from '../../constants/layout.js'; +import { ELEMENT_TYPE_TRANSLATIONS } from '../../constants/selectors.js'; +import { showHoverOutline, hideHoverOutline } from './hover-outline.js'; +import { highlightMarkerForElement, unhighlightAllMarkers } from '../annotation-panel/annotation-markers.js'; +import { getEditorTranslations } from '../../state/annotation-state.js'; + +const ELEMENT_TYPE_TOOLTIP_CLASS = 'element-type-tooltip'; + +let typeTooltipElement = null; +let displayedType = null; +let cachedWidth = 0; +let cachedHeight = 0; + +function createTypeTooltip() { + if (typeTooltipElement) return; + typeTooltipElement = document.createElement('div'); + typeTooltipElement.className = ELEMENT_TYPE_TOOLTIP_CLASS; + document.body.appendChild(typeTooltipElement); +} + +/** + * Positions the tooltip's top-left corner near `(clientX, clientY)`, clamped + * per axis so it never crosses within `TOOLTIP_VIEWPORT_MARGIN` of the + * viewport edge. The cursor is free to keep moving over the parked tooltip — + * it is `pointer-events: none`, so hit-testing resolves straight through it. + * @param {number} clientX + * @param {number} clientY + */ +function positionAt(clientX, clientY) { + const maxLeft = Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerWidth - cachedWidth - TOOLTIP_VIEWPORT_MARGIN); + const maxTop = Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerHeight - cachedHeight - TOOLTIP_VIEWPORT_MARGIN); + + const left = Math.min(Math.max(clientX + TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN), maxLeft); + const top = Math.min(Math.max(clientY + TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN), maxTop); + + typeTooltipElement.style.transform = `translate3d(${left}px, ${top}px, 0)`; +} + +/** + * Shows the type tooltip near the cursor, labelling `element`'s logical type. + * @param {HTMLElement} element + * @param {number} clientX + * @param {number} clientY + */ +export function showTypeTooltip(element, clientX, clientY) { + const type = getElementType(element); + if (!typeTooltipElement) createTypeTooltip(); + if (!typeTooltipElement.isConnected) document.body.appendChild(typeTooltipElement); + + if (type !== displayedType) { + displayedType = type; + const translations = getEditorTranslations(); + const translationKey = ELEMENT_TYPE_TRANSLATIONS[type]?.key; + typeTooltipElement.textContent = (translationKey && translations[translationKey]) || type; + cachedWidth = typeTooltipElement.offsetWidth; + cachedHeight = typeTooltipElement.offsetHeight; + } + + typeTooltipElement.classList.add('active'); + positionAt(clientX, clientY); + showHoverOutline(element); + unhighlightAllMarkers(); + highlightMarkerForElement(element); +} + +/** Hides the type tooltip and its paired hover outline. */ +export function hideTypeTooltip() { + typeTooltipElement?.classList.remove('active'); + displayedType = null; + hideHoverOutline(); + unhighlightAllMarkers(); +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/color-action.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/color-action.js new file mode 100644 index 0000000..ccf9434 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/color-action.js @@ -0,0 +1,766 @@ +import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js'; +import { getToolbarEl, getToolbarTarget } from '../toolbar/toolbar.js'; +import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js'; +import { getActiveRange, wrapRange, syncUnderlineColorFromElement } from '../style-actions.js'; +import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js'; +import { COLOR_ACTION_HTML, SOLID_COLORS, GRADIENTS } from './template.js'; +import { COLOR_ACTION_STYLES } from './styles.js'; +import { hsvToHex, hsvToColor, hexToHsv, parseHexInput, parseColor, extractAlpha } from './color-conversions.js'; +import { parseGradientStops, buildGradient } from './gradient.js'; +import { + initSwatches, syncSolidSwatchActive, syncGradientSwatchActive, + ensureCustomSwatch, ensureCustomGradientSwatch, + appendCustomSwatch, appendCustomGradientSwatch, restorePersistedSwatches, + recolorSolidSwatch, recolorGradientSwatch, setActiveSwatch, +} from './swatches.js'; +import { initHexInput, setHexFromPicker, setHexInputActive, hexPointerDown, hexPointerMove, handleHexKeydown, handleHexPaste, handleHexCopy, selectAllHex } from './hex-input.js'; +import { initPickerSquare } from './picker-square.js'; + +let colorActionElement = null; +let savedColorRange = null; + +let pickerHue = 0, pickerSaturation = 1, pickerValue = 1, pickerAlpha = 1; +let pickerOnChange = null; +export let pickerDragging = false; +let hexDragging = false; +let activeSwatchOption = null; +let lastColorWrapper = null; +let _suppressHide = false; +let _colorEdited = false; +let _pickerSquare = null, _pickerIndicator = null; +let _pickerHueInput = null, _pickerAlphaInput = null; +let _eyedropperPickAbort = null; + +let gradientColors = ['#FF6B6B', '#FFE66D']; +let gradientAngle = 90; +let activeGradientStop = 0; + +/** + * Runs `handler` with pickerDragging forced on (so live previews skip expensive + * input events), restoring the previous flag afterwards. + * @param {() => void} handler + */ +function withPickerDragging(handler) { + const wasDragging = pickerDragging; + pickerDragging = true; + try { + handler(); + } finally { + pickerDragging = wasDragging; + } +} + +/** Applies a solid color to a wrapper element, clearing gradient props. */ +function setWrapperSolid(element, color) { + element.style.color = color; + element.style.removeProperty('background-image'); + element.style.removeProperty('background-clip'); + element.style.removeProperty('-webkit-background-clip'); +} + +/** Applies a gradient text fill to a wrapper element. */ +function setWrapperGradient(element, gradient) { + element.style.backgroundImage = gradient; + element.style.backgroundClip = 'text'; + element.style.webkitBackgroundClip = 'text'; + element.style.color = 'transparent'; +} + +/** Updates the toolbar's color swatch preview. */ +function setToolbarPreview(value) { + getToolbarEl()?.querySelector('.tft-swatch-preview')?.style.setProperty('background', value); +} + +/** + * Commits a solid color: restyles the existing wrapper when connected, + * otherwise wraps the saved selection silently (no input event). + * @param {string} color + */ +function applySolidFull(color) { + if (lastColorWrapper?.isConnected) { + setWrapperSolid(lastColorWrapper, color); + } else { + withPickerDragging(() => applyColorAction(color, false)); + } +} + +/** + * Commits a gradient like {@link applySolidFull}, re-resolving the wrapper + * after a fresh wrap so subsequent live updates can target it. + * @param {string} gradient + */ +function applyGradientFull(gradient) { + recoverGradientWrapper(); + if (lastColorWrapper?.isConnected) { + setWrapperGradient(lastColorWrapper, gradient); + } else { + withPickerDragging(() => applyColorAction(gradient, true)); + recoverGradientWrapper(); + } +} + +function isGradientTabActive() { + return !!colorActionElement?.querySelector('.tft-color-tab[data-tab="gradient"]')?.classList.contains('tca-tab-active'); +} + +function refreshGradientStopSwatches() { + if (!colorActionElement) return; + [0, 1].forEach(index => { + const element = colorActionElement.querySelector(`.tft-gradient-stop-swatch[data-stop="${index}"]`); + if (!element) return; + element.querySelector('.tft-color-swatch').style.background = gradientColors[index] || '#000000'; + element.classList.toggle('tca-active', index === activeGradientStop); + }); +} + +function selectGradientStop(stopIndex) { + activeGradientStop = stopIndex; + refreshGradientStopSwatches(); + const hex = parseColor(gradientColors[stopIndex]) || '#000000'; + seedPicker(hex); +} + +function updatePickerUI(skipHex = false) { + const hex = hsvToHex(pickerHue, pickerSaturation, pickerValue); + if (isGradientTabActive()) { + gradientColors[activeGradientStop] = hex; + refreshGradientStopSwatches(); + const gradient = buildGradient(gradientAngle, gradientColors[0], gradientColors[1]); + if (canApplyColor()) { + recoverGradientWrapper(); + if (lastColorWrapper?.isConnected) { + lastColorWrapper.style.backgroundImage = gradient; + } + } + setToolbarPreview(gradient); + // Edit the selected gradient swatch in place (keeps it highlighted); otherwise just highlight a match. + if (activeSwatchOption?.isConnected && activeSwatchOption.hasAttribute('data-gradient')) { + recolorGradientSwatch(activeSwatchOption, gradient); + setActiveSwatch(activeSwatchOption); + } else { + activeSwatchOption = syncGradientSwatchActive(gradient); + } + } else if (activeSwatchOption?.isConnected && activeSwatchOption.hasAttribute('data-color')) { + recolorSolidSwatch(activeSwatchOption, hex); + setActiveSwatch(activeSwatchOption); + } else { + activeSwatchOption = syncSolidSwatchActive(hex); + } + if (_pickerSquare) _pickerSquare.style.background = `hsl(${pickerHue}, 100%, 50%)`; + if (_pickerIndicator) { + const width = _pickerSquare.offsetWidth; + const height = _pickerSquare.offsetHeight; + _pickerIndicator.style.left = `${pickerSaturation * width}px`; + _pickerIndicator.style.top = `${(1 - pickerValue) * height}px`; + _pickerIndicator.style.background = hex; + } + if (_pickerHueInput) _pickerHueInput.value = pickerHue; + if (_pickerAlphaInput) { + const hslColor = `hsl(${pickerHue}, 100%, 50%)`; + _pickerAlphaInput.style.background = [ + `linear-gradient(to right, transparent, ${hslColor})`, + `linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 0 0 / 8px 8px`, + `linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 4px 4px / 8px 8px`, + `#2a2b2f`, + ].join(', '); + } + if (!skipHex) setHexFromPicker(hex); +} + +function seedPicker(hex) { + const hsv = hexToHsv(hex) ?? { h: 0, s: 0, v: 0 }; + pickerHue = hsv.h; + pickerSaturation = hsv.s; + pickerValue = hsv.v; + updatePickerUI(); +} + +function findGradientWrapper() { + if (!getToolbarTarget()) return null; + const selection = window.getSelection(); + let node = selection?.rangeCount > 0 ? selection.getRangeAt(0).commonAncestorContainer : null; + if (!node) node = savedColorRange?.commonAncestorContainer; + if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement; + while (node && node !== getToolbarTarget().parentElement) { + if (node === getToolbarTarget()) { node = node.parentElement; continue; } + if (node.nodeType === Node.ELEMENT_NODE) { + if (node.style?.backgroundClip === 'text' || node.style?.webkitBackgroundClip === 'text') { + return node; + } + try { + const computedStyle = getComputedStyle(node); + const backgroundClip = computedStyle.backgroundClip || computedStyle.webkitBackgroundClip; + if (computedStyle.backgroundImage?.includes('gradient') && backgroundClip === 'text') { + return node; + } + } catch { /* ignore */ } + } + node = node.parentElement; + } + return null; +} + +function recoverGradientWrapper() { + if (lastColorWrapper?.isConnected) return; + lastColorWrapper = findGradientWrapper(); + if (lastColorWrapper) { + try { + const range = document.createRange(); + range.selectNodeContents(lastColorWrapper); + savedColorRange = range.cloneRange(); + } catch { /* ignore */ } + } +} + +function applyLive(color) { + if (isGradientTabActive()) { + gradientColors[activeGradientStop] = color; + refreshGradientStopSwatches(); + const gradient = buildGradient(gradientAngle, gradientColors[0], gradientColors[1]); + if (canApplyColor()) { + recoverGradientWrapper(); + if (lastColorWrapper?.isConnected) { + lastColorWrapper.style.backgroundImage = gradient; + } else { + withPickerDragging(() => applyColorAction(gradient, true)); + } + } + setToolbarPreview(gradient); + } else { + if (canApplyColor()) { + if (lastColorWrapper?.isConnected) { + lastColorWrapper.style.color = color; + } else { + withPickerDragging(() => applyColorAction(color, false)); + } + } + setToolbarPreview(color); + } +} + +function notifyChange() { + if (!getToolbarTarget()) return; + // Swatches are only created via the "+" button now, never auto-added on commit. + getToolbarTarget().dispatchEvent(new CustomEvent('tft-color-commit', { bubbles: true })); +} + +function applyColorAction(value, isGradient) { + if (!savedColorRange || !getToolbarTarget() || !value) return; + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(savedColorRange); + applyColor(getToolbarTarget(), value, isGradient); + if (selection.rangeCount > 0) savedColorRange = selection.getRangeAt(0).cloneRange(); + if (!pickerDragging) onSelectionChange(); +} + +function stripColorProps(element) { + element.style.removeProperty('color'); + element.style.removeProperty('background-image'); + element.style.removeProperty('background-clip'); + element.style.removeProperty('-webkit-background-clip'); +} + +function hasColorProps(element) { + return !!(element.style.color || element.style.backgroundImage || element.style.backgroundClip || element.style.webkitBackgroundClip); +} + +function findReusableAncestor(range, editTarget) { + let element = range.commonAncestorContainer; + if (element.nodeType === Node.TEXT_NODE) element = element.parentElement; + while (element && element !== editTarget) { + if (element.nodeType === Node.ELEMENT_NODE) { + try { + const elementRange = document.createRange(); + elementRange.selectNodeContents(element); + const selectionText = range.toString(); + const elementText = elementRange.toString(); + if (selectionText && elementText && selectionText === elementText) return element; + } catch { /* ignore */ } + } + element = element.parentElement; + } + return null; +} + +function splitParentAroundWrapper(wrapper, parent, styleProperties, isGradient) { + const parentParent = parent.parentNode; + if (!parentParent) return; + + const before = parent.cloneNode(false); + const after = parent.cloneNode(false); + + let node = parent.firstChild; + while (node && node !== wrapper) { + const next = node.nextSibling; + before.appendChild(node); + node = next; + } + node = wrapper.nextSibling; + while (node) { + const next = node.nextSibling; + after.appendChild(node); + node = next; + } + parent.removeChild(wrapper); + + // Inherit parent styles then apply new color + wrapper.style.cssText = parent.style.cssText; + Object.assign(wrapper.style, styleProperties); + if (!isGradient) { + wrapper.style.removeProperty('background-image'); + wrapper.style.removeProperty('background-clip'); + wrapper.style.removeProperty('-webkit-background-clip'); + } + + if (before.hasChildNodes()) parentParent.insertBefore(before, parent); + parentParent.insertBefore(wrapper, parent); + if (after.hasChildNodes()) parentParent.insertBefore(after, parent); + parentParent.removeChild(parent); +} + +function applyColor(editTarget, value, isGradient) { + const range = getActiveRange(editTarget); + if (!range) return; + const styleProperties = isGradient + ? { backgroundImage: value, backgroundClip: 'text', webkitBackgroundClip: 'text', color: 'transparent' } + : { color: value }; + + const reuse = findReusableAncestor(range, editTarget); + if (reuse) { + Object.assign(reuse.style, styleProperties); + if (!isGradient) { + reuse.style.removeProperty('background-image'); + reuse.style.removeProperty('background-clip'); + reuse.style.removeProperty('-webkit-background-clip'); + } + lastColorWrapper = reuse; + reuse.querySelectorAll('*').forEach(element => { + if (hasColorProps(element)) stripColorProps(element); + }); + syncUnderlineColorFromElement(reuse, editTarget); + if (!pickerDragging) editTarget.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + + const wrapper = wrapRange(range, 'span', styleProperties); + lastColorWrapper = wrapper ?? null; + if (wrapper) { + wrapper.querySelectorAll('*').forEach(element => { + if (hasColorProps(element)) stripColorProps(element); + }); + const parent = wrapper.parentElement; + if (parent && parent !== editTarget && parent.tagName === 'SPAN' && hasColorProps(parent)) { + const parentRange = document.createRange(); + parentRange.selectNodeContents(parent); + if (parentRange.toString() === wrapper.textContent) { + stripColorProps(parent); + } else { + splitParentAroundWrapper(wrapper, parent, styleProperties, isGradient); + } + } + syncUnderlineColorFromElement(wrapper, editTarget); + } + if (!pickerDragging) { + requestAnimationFrame(() => { + editTarget.dispatchEvent(new Event('input', { bubbles: true })); + }); + } +} + +export function positionColorAction() { + if (!isPanelVisible(PanelId.COLOR) || !colorActionElement) return; + positionDropdownPanel(colorActionElement, { buttonAction: 'color' }); +} + +const outsideDismiss = createOutsideDismiss({ + getElement: () => colorActionElement, + dismiss: () => { + _suppressHide = false; + hideColorAction(); + }, + ignoreToolbar: false, + ignoreMouseDown: (event) => !!event.target.closest('[data-action="color"]'), + captureMouseDown: true, +}); + +/** + * @returns {boolean} Whether color can be applied to content. A highlighted + * range colors that selection; a collapsed caret inside the edit target colors + * the whole element (same as bold/italic via {@link getActiveRange}). + */ +function canApplyColor() { + if (!savedColorRange || !getToolbarTarget()) return false; + if (!savedColorRange.collapsed) return true; + return getToolbarTarget().contains(savedColorRange.commonAncestorContainer); +} + +/** True only when the user has highlighted text (not a bare caret). */ +function hasExpandedTextSelection() { + return !!savedColorRange && !savedColorRange.collapsed; +} + +function onSelectionChangeForColor() { + if (!isPanelVisible(PanelId.COLOR)) return; + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return; + const range = selection.getRangeAt(0); + if (!getToolbarTarget()?.contains(range.commonAncestorContainer)) return; + savedColorRange = range.cloneRange(); +} + +function switchColorTab(tab) { + if (!colorActionElement) return; + colorActionElement.querySelectorAll('.tft-color-tab').forEach(tabElement => + tabElement.classList.toggle('tca-tab-active', tabElement.dataset.tab === tab)); + colorActionElement.querySelectorAll('[data-tab-panel]').forEach(panelElement => + panelElement.classList.toggle('tca-hidden', panelElement.dataset.tabPanel !== tab)); + + if (tab === 'gradient' && isPanelVisible(PanelId.COLOR)) { + const firstPreset = parseGradientStops(GRADIENTS[0]); + if (firstPreset) { + gradientAngle = firstPreset.angle; + gradientColors = firstPreset.colors.map(color => parseColor(color) || color); + } + refreshGradientStopSwatches(); + selectGradientStop(0); + activeSwatchOption = syncGradientSwatchActive(GRADIENTS[0]); + if (hasExpandedTextSelection() && getToolbarTarget()) { + applyGradientFull(GRADIENTS[0]); + notifyChange(); + } + setToolbarPreview(GRADIENTS[0]); + } else if (tab === 'solid' && isPanelVisible(PanelId.COLOR)) { + const defaultSolid = SOLID_COLORS[0]; + seedPicker(defaultSolid); + activeSwatchOption = syncSolidSwatchActive(defaultSolid); + syncGradientSwatchActive(null); + if (hasExpandedTextSelection() && getToolbarTarget()) { + recoverGradientWrapper(); + applySolidFull(defaultSolid); + notifyChange(); + } + setToolbarPreview(defaultSolid); + } + + positionColorAction(); +} + +function initColorAction() { + if (colorActionElement) return; + colorActionElement = createPanelElement({ + styleId: 'text-format-color-action-styles', + styles: COLOR_ACTION_STYLES, + html: COLOR_ACTION_HTML, + }); + + initSwatches(colorActionElement); + restorePersistedSwatches(); + + colorActionElement.addEventListener('mousedown', (event) => { + if (event.target.closest('input[type="range"]')) return; + event.preventDefault(); + if (event.target.closest('.tft-picker-hex-input')) { + setHexInputActive(true); + hexPointerDown(event.clientX); + hexDragging = true; + } + }); + + document.addEventListener('mousemove', (event) => { + if (hexDragging) hexPointerMove(event.clientX); + }); + document.addEventListener('mouseup', () => { hexDragging = false; }); + + _pickerSquare = colorActionElement.querySelector('.tft-picker-square'); + _pickerIndicator = colorActionElement.querySelector('.tft-picker-indicator'); + _pickerHueInput = colorActionElement.querySelector('.tft-picker-hue'); + _pickerAlphaInput = colorActionElement.querySelector('.tft-picker-alpha'); + const hexElement = colorActionElement.querySelector('.tft-picker-hex-input'); + initHexInput(hexElement); + + initPickerSquare(_pickerSquare, { + onDragStart: (saturation, value) => { + pickerDragging = true; + _colorEdited = true; + if (canApplyColor() && !lastColorWrapper?.isConnected) { + if (isGradientTabActive()) { + applyColorAction(buildGradient(gradientAngle, gradientColors[0], gradientColors[1]), true); + } else { + applyColorAction(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha), false); + } + } + pickerSaturation = saturation; pickerValue = value; + updatePickerUI(); + }, + onPickChange: (saturation, value) => { + if (!pickerDragging) return; + pickerSaturation = saturation; pickerValue = value; + updatePickerUI(); + applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha)); + }, + onDragEnd: () => { + pickerDragging = false; + notifyChange(); + }, + }); + + _pickerHueInput.addEventListener('input', () => { + _colorEdited = true; + pickerHue = Number(_pickerHueInput.value); + updatePickerUI(); + applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha)); + }); + _pickerHueInput.addEventListener('change', () => notifyChange()); + + _pickerAlphaInput.addEventListener('input', () => { + _colorEdited = true; + pickerAlpha = Number(_pickerAlphaInput.value) / 100; + updatePickerUI(true); + applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha)); + }); + _pickerAlphaInput.addEventListener('change', () => notifyChange()); + + const eyedropperButton = colorActionElement.querySelector('.tft-picker-hex-picker'); + if (eyedropperButton) { + if (!('EyeDropper' in window)) { + eyedropperButton.style.display = 'none'; + } else { + eyedropperButton.addEventListener('click', async (event) => { + event.stopPropagation(); + _suppressHide = true; + _eyedropperPickAbort?.abort(); + _eyedropperPickAbort = new AbortController(); + try { + const { sRGBHex } = await new window.EyeDropper().open({ signal: _eyedropperPickAbort.signal }); + const parsed = parseHexInput(sRGBHex); + if (!parsed) return; + _colorEdited = true; + seedPicker(parsed); + applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha)); + notifyChange(); + } catch (error) { + if (error?.name !== 'AbortError') console.warn('EyeDropper failed', error); + } finally { + _eyedropperPickAbort = null; + requestAnimationFrame(() => { _suppressHide = false; }); + } + }); + } + } + + colorActionElement.addEventListener('dblclick', (event) => { + if (event.target.closest('.tft-picker-hex-input')) { + event.preventDefault(); + selectAllHex(); + } + }); + + colorActionElement.addEventListener('click', (event) => { + const tab = event.target.closest('.tft-color-tab'); + if (tab) { + switchColorTab(tab.dataset.tab); + return; + } + + if (event.target.closest('.tft-picker-hex-input')) return; + const stopSwatch = event.target.closest('.tft-gradient-stop-swatch'); + if (stopSwatch) { + selectGradientStop(Number(stopSwatch.dataset.stop)); + return; + } + + const addButton = event.target.closest('.tft-color-add'); + if (addButton) { + _colorEdited = true; + if (isGradientTabActive()) { + const gradient = activeSwatchOption?.dataset.gradient + || buildGradient(gradientAngle, gradientColors[0], gradientColors[1]); + const newSwatch = appendCustomGradientSwatch(gradient); + if (newSwatch) { activeSwatchOption = newSwatch; setActiveSwatch(newSwatch); } + setToolbarPreview(gradient); + } else { + const hex = activeSwatchOption?.dataset.color + || hsvToHex(pickerHue, pickerSaturation, pickerValue); + const newSwatch = appendCustomSwatch(hex); + if (newSwatch) { activeSwatchOption = newSwatch; setActiveSwatch(newSwatch); } + setToolbarPreview(hex); + } + return; + } + + const option = event.target.closest('.tft-color-option'); + if (!option) return; + + if (option.dataset.gradient) { + const gradient = option.dataset.gradient; + activeSwatchOption = option; + setActiveSwatch(option); + const parsed = parseGradientStops(gradient); + if (parsed) { + gradientAngle = parsed.angle; + gradientColors = [...parsed.colors]; + } + refreshGradientStopSwatches(); + selectGradientStop(0); + if (canApplyColor()) applyGradientFull(gradient); + setToolbarPreview(gradient); + notifyChange(); + } else { + const hex = option.dataset.color; + activeSwatchOption = option; + setActiveSwatch(option); + seedPicker(hex); + if (canApplyColor()) applySolidFull(hex); + setToolbarPreview(hex); + notifyChange(); + } + }); +} + +function tryLiveFromHex(rawValue) { + const hex = parseHexInput(rawValue); + if (!hex) return; + _colorEdited = true; + const hsv = hexToHsv(hex); + if (!hsv) return; + pickerHue = hsv.h; pickerSaturation = hsv.s; pickerValue = hsv.v; + updatePickerUI(true); + applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha)); +} + +function onHexCommit(hex) { + seedPicker(hex); + _suppressHide = false; + if (isGradientTabActive()) { + applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha)); + } else { + pickerOnChange?.(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha)); + } + hideColorAction(); +} + +function onHexCancel() { + _suppressHide = false; + hideColorAction(); +} + +function onHexKeydownHandler(event) { + if (!isPanelVisible(PanelId.COLOR)) return; + handleHexKeydown(event, { onLiveChange: tryLiveFromHex, onCommit: onHexCommit, onCancel: onHexCancel }); +} + +function onHexPasteHandler(event) { + if (!isPanelVisible(PanelId.COLOR)) return; + const newValue = handleHexPaste(event); + if (newValue !== undefined) tryLiveFromHex(newValue); +} + +function onHexCopyHandler(event) { + if (!isPanelVisible(PanelId.COLOR)) return; + const cut = handleHexCopy(event); + if (cut) tryLiveFromHex(''); +} + +/** Opens the color panel seeded from the current selection styles. */ +export function showColorAction() { + if (!colorActionElement) initColorAction(); + + savedColorRange = captureSelectionRange(); + + const styles = getStylesAtCursor(); + const currentValue = (styles?.color ?? '').toLowerCase(); + const isGradientActive = !!styles?.isGradient; + + _colorEdited = false; + + if (isGradientActive) { + ensureCustomGradientSwatch(currentValue); + activeSwatchOption = syncGradientSwatchActive(currentValue); + syncSolidSwatchActive(null); + } else { + const parsedColor = parseColor(currentValue); + if (parsedColor) ensureCustomSwatch(parsedColor); + activeSwatchOption = syncSolidSwatchActive(parsedColor); + syncGradientSwatchActive(null); + } + + switchColorTab(isGradientActive ? 'gradient' : 'solid'); + + colorActionElement.classList.add('tca-visible'); + setPanelVisible(PanelId.COLOR, true); + _suppressHide = true; + positionColorAction(); + + pickerAlpha = isGradientActive ? 1 : extractAlpha(currentValue); + if (_pickerAlphaInput) _pickerAlphaInput.value = Math.round(pickerAlpha * 100); + + pickerOnChange = (color) => { + if (canApplyColor()) applySolidFull(color); + setToolbarPreview(color); + notifyChange(); + }; + setHexInputActive(false); + + if (isGradientActive) { + const parsed = parseGradientStops(currentValue); + if (parsed) { + gradientAngle = parsed.angle; + gradientColors = parsed.colors.map(color => parseColor(color) || color); + } + refreshGradientStopSwatches(); + selectGradientStop(0); + } else { + activeGradientStop = 0; + seedPicker(parseColor(currentValue) || '#000000'); + } + + document.addEventListener('keydown', onHexKeydownHandler, true); + document.addEventListener('paste', onHexPasteHandler, true); + document.addEventListener('copy', onHexCopyHandler, true); + document.addEventListener('cut', onHexCopyHandler, true); + document.addEventListener('selectionchange', onSelectionChangeForColor); + outsideDismiss.attach(); +} + +/** + * Closes the color panel and resets picker state. + * @param {boolean} [force] - Close even when hide is suppressed (e.g. eyedropper). + */ +export function hideColorAction(force = false) { + if (!force && _suppressHide) return; + if (!colorActionElement) return; + _eyedropperPickAbort?.abort(); + _eyedropperPickAbort = null; + // Swatches are added/edited explicitly via the grid now — nothing to persist on close. + _colorEdited = false; + _suppressHide = false; + colorActionElement.classList.remove('tca-visible'); + setPanelVisible(PanelId.COLOR, false); + savedColorRange = null; + pickerOnChange = null; + activeSwatchOption = null; + lastColorWrapper = null; + setHexInputActive(false); + gradientColors = ['#FF6B6B', '#FFE66D']; + gradientAngle = 90; + activeGradientStop = 0; + document.removeEventListener('keydown', onHexKeydownHandler, true); + document.removeEventListener('paste', onHexPasteHandler, true); + document.removeEventListener('copy', onHexCopyHandler, true); + document.removeEventListener('cut', onHexCopyHandler, true); + document.removeEventListener('selectionchange', onSelectionChangeForColor); + outsideDismiss.detach(); +} + +/** Toggles the color panel visibility. */ +export function toggleColorAction() { + if (isPanelVisible(PanelId.COLOR)) { + _suppressHide = false; + hideColorAction(); + } else { + showColorAction(); + } +} + +registerPanel({ id: PanelId.COLOR, hide: hideColorAction }); diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/color-conversions.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/color-conversions.js new file mode 100644 index 0000000..9b8eb2a --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/color-conversions.js @@ -0,0 +1,104 @@ +/** + * @param {number} hue - Hue 0–360. + * @param {number} saturation - Saturation 0–1. + * @param {number} value - Value 0–1. + * @returns {string} + */ +export function hsvToHex(hue, saturation, value) { + const f = (n, k = (n + hue / 60) % 6) => value - value * saturation * Math.max(Math.min(k, 4 - k, 1), 0); + const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0'); + return `#${toHex(f(5))}${toHex(f(3))}${toHex(f(1))}`; +} + +/** + * @param {number} hue + * @param {number} saturation + * @param {number} value + * @param {number} [alpha] - Alpha 0–1. + * @returns {string} + */ +export function hsvToColor(hue, saturation, value, alpha = 1) { + const hex = hsvToHex(hue, saturation, value); + if (alpha >= 1) return hex; + const red = parseInt(hex.slice(1, 3), 16); + const green = parseInt(hex.slice(3, 5), 16); + const blue = parseInt(hex.slice(5, 7), 16); + return `rgba(${red}, ${green}, ${blue}, ${parseFloat(alpha.toFixed(3))})`; +} + +/** + * @param {string} hex + * @returns {{ h: number, s: number, v: number }|null} + */ +export function hexToHsv(hex) { + hex = hex.replace('#', ''); + if (hex.length === 3) hex = hex.split('').map(character => character + character).join(''); + if (hex.length !== 6) return null; + const red = parseInt(hex.slice(0, 2), 16) / 255; + const green = parseInt(hex.slice(2, 4), 16) / 255; + const blue = parseInt(hex.slice(4, 6), 16) / 255; + const max = Math.max(red, green, blue), min = Math.min(red, green, blue); + const value = max, delta = max - min; + const saturation = max === 0 ? 0 : delta / max; + let hue = 0; + if (delta !== 0) { + switch (max) { + case red: hue = ((green - blue) / delta + (green < blue ? 6 : 0)) / 6; break; + case green: hue = ((blue - red) / delta + 2) / 6; break; + default: hue = ((red - green) / delta + 4) / 6; break; + } + } + return { h: hue * 360, s: saturation, v: value }; +} + +/** + * @param {string} value + * @returns {string|null} Normalized #rrggbb or null. + */ +export function parseHexInput(value) { + value = value.trim().replace(/^#/, ''); + if (value.length === 3) value = value.split('').map(character => character + character).join(''); + if (!/^[0-9a-fA-F]{6}$/.test(value)) return null; + return `#${value.toLowerCase()}`; +} + +/** + * @param {string} value - Any CSS color string. + * @returns {string|null} + */ +export function cssColorToHex(value) { + const cleaned = value.replace(/\s+[\d.]+(%|px|em|rem)\s*$/, '').trim(); + try { + const context = document.createElement('canvas').getContext('2d'); + context.fillStyle = '#010203'; + context.fillStyle = cleaned; + const result = context.fillStyle; + if (result === '#010203' && cleaned !== '#010203') return null; + return parseHexInput(result); + } catch { return null; } +} + +/** + * @param {string} value + * @returns {string|null} Hex color when parseable. + */ +export function parseColor(value) { + if (!value) return null; + const hex = parseHexInput(value); + if (hex) return hex; + const rgbMatch = value.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/); + if (rgbMatch) return `#${[rgbMatch[1], rgbMatch[2], rgbMatch[3]].map(component => Number(component).toString(16).padStart(2, '0')).join('')}`; + const rgbaMatch = value.match(/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)/); + if (rgbaMatch) return `#${[rgbaMatch[1], rgbaMatch[2], rgbaMatch[3]].map(component => Number(component).toString(16).padStart(2, '00')).join('')}`; + return cssColorToHex(value); +} + +/** + * @param {string} value + * @returns {number} Alpha 0–1. + */ +export function extractAlpha(value) { + if (!value) return 1; + const match = value.match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/); + return match ? Math.min(1, Math.max(0, parseFloat(match[1]))) : 1; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/gradient.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/gradient.js new file mode 100644 index 0000000..b62d591 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/gradient.js @@ -0,0 +1,59 @@ +import { parseColor } from './color-conversions.js'; + +/** + * @param {string} value - CSS linear-gradient value. + * @returns {{ angle: number, colors: string[] }|null} + */ +export function parseGradientStops(value) { + if (!value) return null; + const match = value.match(/linear-gradient\(\s*([\s\S]+)\s*\)/); + if (!match) return null; + const segments = []; + let depth = 0, current = ''; + for (const character of match[1] + ',') { + if (character === '(') { depth++; current += character; } + else if (character === ')') { depth--; current += character; } + else if (character === ',' && depth === 0) { segments.push(current.trim()); current = ''; } + else { current += character; } + } + if (segments.length < 2) return null; + let angle = 90; + let colorStart = 0; + const first = segments[0]; + if (/^\d+(\.\d+)?deg$/.test(first)) { + angle = parseFloat(first); + colorStart = 1; + } else if (/^to\s+/i.test(first)) { + const map = { 'to right': 90, 'to left': 270, 'to top': 0, 'to bottom': 180, + 'to top right': 45, 'to top left': 315, 'to bottom right': 135, 'to bottom left': 225 }; + angle = map[first.toLowerCase()] ?? 90; + colorStart = 1; + } + const colors = segments.slice(colorStart); + if (colors.length < 2) return null; + return { angle, colors: [colors[0], colors[1]] }; +} + +/** + * @param {number} angle - Degrees. + * @param {string} firstColor + * @param {string} secondColor + * @returns {string} + */ +export function buildGradient(angle, firstColor, secondColor) { + return `linear-gradient(${angle}deg, ${firstColor}, ${secondColor})`; +} + +/** + * Normalized key for comparing gradient presets and custom swatches. + * @param {string} value + * @returns {string} + */ +export function canonicalGradientKey(value) { + if (!value) return ''; + const parsed = parseGradientStops(value); + if (!parsed) return value.replace(/\s/g, '').toLowerCase(); + const firstColor = (parseColor(parsed.colors[0]) || parsed.colors[0]).toLowerCase(); + const secondColor = (parseColor(parsed.colors[1]) || parsed.colors[1]).toLowerCase(); + return `${parsed.angle}|${firstColor}|${secondColor}`; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/hex-input.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/hex-input.js new file mode 100644 index 0000000..48cd965 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/hex-input.js @@ -0,0 +1,252 @@ +import { parseHexInput } from './color-conversions.js'; + +let _element = null; +let hexVirtualValue = ''; +let hexVirtualCursor = 0; +let hexAnchor = 0; +let hexInputActive = false; + +/** @param {HTMLElement} element */ +export function initHexInput(element) { _element = element; } + +/** @returns {boolean} */ +export function isHexInputActive() { return hexInputActive; } +/** @returns {string} */ +export function getHexValue() { return hexVirtualValue; } + +/** @returns {number} Lower selection bound. */ +function selectionStart() { return Math.min(hexAnchor, hexVirtualCursor); } +/** @returns {number} Upper selection bound. */ +function selectionEnd() { return Math.max(hexAnchor, hexVirtualCursor); } +/** @returns {boolean} Whether a (non-collapsed) selection exists. */ +function hasSelection() { return hexAnchor !== hexVirtualCursor; } +/** Collapses the selection onto the caret. */ +function collapseSelection() { hexAnchor = hexVirtualCursor; } + +/** + * @param {string} hex + */ +export function setHexFromPicker(hex) { + hexVirtualValue = hex; + hexVirtualCursor = hex.length; + hexAnchor = hex.length; + renderHexInput(); +} + +/** @param {boolean} active */ +export function setHexInputActive(active) { + hexInputActive = active; + if (!active) { + _element?.classList.remove('tca-focused'); + collapseSelection(); + } +} + +/** Selects the whole hex value (e.g. on double-click) so the next input/paste replaces it. */ +export function selectAllHex() { + hexInputActive = true; + _element?.classList.add('tca-focused'); + hexAnchor = 0; + hexVirtualCursor = hexVirtualValue.length; + renderHexInput(); +} + +/** Renders the virtual hex field as per-character spans (so selection can be highlighted). */ +export function renderHexInput() { + if (!_element) return; + if (!hexInputActive) { + _element.textContent = hexVirtualValue; + return; + } + const start = selectionStart(); + const end = selectionEnd(); + const caret = '<span class="tft-picker-hex-cursor"></span>'; + let html = ''; + for (let index = 0; index < hexVirtualValue.length; index++) { + if (index === hexVirtualCursor) html += caret; + const selectionClass = index >= start && index < end ? ' tft-picker-hex-csel' : ''; + html += `<span class="tft-picker-hex-char${selectionClass}" data-i="${index}">${hexVirtualValue[index]}</span>`; + } + if (hexVirtualCursor === hexVirtualValue.length) html += caret; + _element.innerHTML = html; +} + +/** + * Maps a viewport x coordinate to a caret index between characters. + * @param {number} pointerX + * @returns {number} + */ +function caretIndexFromPointerX(pointerX) { + if (!_element) return 0; + const characters = _element.querySelectorAll('.tft-picker-hex-char'); + for (let index = 0; index < characters.length; index++) { + const characterBounds = characters[index].getBoundingClientRect(); + if (pointerX < characterBounds.left + characterBounds.width / 2) return index; + } + return characters.length; +} + +/** + * Begins a click/drag in the field: activates it and drops a collapsed caret. + * @param {number} pointerX + */ +export function hexPointerDown(pointerX) { + hexInputActive = true; + _element?.classList.add('tca-focused'); + renderHexInput(); + const caretIndex = caretIndexFromPointerX(pointerX); + hexAnchor = caretIndex; + hexVirtualCursor = caretIndex; + renderHexInput(); +} + +/** + * Extends the selection to the dragged-to position. + * @param {number} pointerX + */ +export function hexPointerMove(pointerX) { + if (!hexInputActive) return; + hexVirtualCursor = caretIndexFromPointerX(pointerX); + renderHexInput(); +} + +/** + * @param {KeyboardEvent} event + * @param {{ onLiveChange: (hex: string) => void, onCommit: (hex: string) => void, onCancel: () => void }} callbacks + */ +export function handleHexKeydown(event, { onLiveChange, onCommit, onCancel }) { + if (event.key === 'Enter') { + event.preventDefault(); event.stopPropagation(); + const hex = parseHexInput(hexVirtualValue); + if (hex) onCommit(hex); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); event.stopPropagation(); + onCancel(); + return; + } + + if (!hexInputActive) return; + + if (event.key === 'a' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); event.stopPropagation(); + hexAnchor = 0; + hexVirtualCursor = hexVirtualValue.length; + renderHexInput(); + return; + } + + if (hasSelection()) { + const start = selectionStart(); + const end = selectionEnd(); + if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && /[#0-9a-fA-F]/.test(event.key)) { + event.preventDefault(); event.stopPropagation(); + hexVirtualValue = (hexVirtualValue.slice(0, start) + event.key + hexVirtualValue.slice(end)).slice(0, 7); + hexVirtualCursor = start + 1; + collapseSelection(); + renderHexInput(); + onLiveChange(hexVirtualValue); + return; + } + if (event.key === 'Backspace' || event.key === 'Delete') { + event.preventDefault(); event.stopPropagation(); + hexVirtualValue = hexVirtualValue.slice(0, start) + hexVirtualValue.slice(end); + hexVirtualCursor = start; + collapseSelection(); + renderHexInput(); + onLiveChange(hexVirtualValue); + return; + } + collapseSelection(); + } + + if (event.key === 'Backspace') { + event.preventDefault(); event.stopPropagation(); + if (hexVirtualCursor > 0) { + hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor - 1) + hexVirtualValue.slice(hexVirtualCursor); + hexVirtualCursor--; + collapseSelection(); + renderHexInput(); + onLiveChange(hexVirtualValue); + } + return; + } + if (event.key === 'Delete') { + event.preventDefault(); event.stopPropagation(); + if (hexVirtualCursor < hexVirtualValue.length) { + hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor) + hexVirtualValue.slice(hexVirtualCursor + 1); + collapseSelection(); + renderHexInput(); + onLiveChange(hexVirtualValue); + } + return; + } + if ((event.key === 'ArrowLeft' && (event.metaKey || event.ctrlKey)) || event.key === 'Home') { + event.preventDefault(); event.stopPropagation(); + hexVirtualCursor = 0; collapseSelection(); renderHexInput(); return; + } + if ((event.key === 'ArrowRight' && (event.metaKey || event.ctrlKey)) || event.key === 'End') { + event.preventDefault(); event.stopPropagation(); + hexVirtualCursor = hexVirtualValue.length; collapseSelection(); renderHexInput(); return; + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); event.stopPropagation(); + hexVirtualCursor = Math.max(0, hexVirtualCursor - 1); collapseSelection(); renderHexInput(); return; + } + if (event.key === 'ArrowRight') { + event.preventDefault(); event.stopPropagation(); + hexVirtualCursor = Math.min(hexVirtualValue.length, hexVirtualCursor + 1); collapseSelection(); renderHexInput(); return; + } + if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); event.stopPropagation(); + if (hexVirtualValue.length < 7 && /[#0-9a-fA-F]/.test(event.key)) { + hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor) + event.key + hexVirtualValue.slice(hexVirtualCursor); + hexVirtualCursor++; + collapseSelection(); + renderHexInput(); + onLiveChange(hexVirtualValue); + } + } +} + +/** + * Handles copy/cut for the virtual hex field: writes the selected text to the + * clipboard ourselves (the field isn't a real input, so the browser can't). + * @param {ClipboardEvent} event + * @returns {boolean} Whether the value changed (cut). + */ +export function handleHexCopy(event) { + if (!hexInputActive || !hasSelection()) return false; + const text = hexVirtualValue.slice(selectionStart(), selectionEnd()); + if (!text) return false; + event.preventDefault(); event.stopPropagation(); + event.clipboardData?.setData('text/plain', text); + if (event.type === 'cut') { + const start = selectionStart(); + hexVirtualValue = hexVirtualValue.slice(0, start) + hexVirtualValue.slice(selectionEnd()); + hexVirtualCursor = start; + collapseSelection(); + renderHexInput(); + return true; + } + return false; +} + +/** + * @param {ClipboardEvent} event + * @returns {string|undefined} Updated hex value after paste. + */ +export function handleHexPaste(event) { + event.preventDefault(); event.stopPropagation(); + const inserted = (event.clipboardData?.getData('text/plain') || '').replace(/[^#0-9a-fA-F]/g, ''); + // Replace the current selection (if any), otherwise insert at the caret. + const start = selectionStart(); + const end = selectionEnd(); + const next = (hexVirtualValue.slice(0, start) + inserted + hexVirtualValue.slice(end)).slice(0, 7); + hexVirtualCursor = Math.min(next.length, start + inserted.length); + hexVirtualValue = next; + collapseSelection(); + renderHexInput(); + return hexVirtualValue; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/picker-square.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/picker-square.js new file mode 100644 index 0000000..90b3f50 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/picker-square.js @@ -0,0 +1,35 @@ +/** + * Wires mousedown/drag on the SV square to saturation/value callbacks. + * @param {HTMLElement} squareElement + * @param {{ onDragStart: (s: number, v: number) => void, onPickChange: (s: number, v: number) => void, onDragEnd: () => void }} callbacks + */ +export function initPickerSquare(squareElement, callbacks) { + const { onDragStart, onPickChange, onDragEnd } = callbacks; + + squareElement.addEventListener('mousedown', (event) => { + const { s: saturation, v: value } = getSaturationValueFromPointer(event, squareElement); + onDragStart(saturation, value); + + function onMove(event) { + event.preventDefault(); + const { s: saturation, v: value } = getSaturationValueFromPointer(event, squareElement); + onPickChange(saturation, value); + } + function onUp(event) { + event.preventDefault(); + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + onDragEnd(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); +} + +function getSaturationValueFromPointer(event, squareElement) { + const bounds = squareElement.getBoundingClientRect(); + return { + s: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)), + v: Math.max(0, Math.min(1, 1 - (event.clientY - bounds.top) / bounds.height)), + }; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/styles.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/styles.js new file mode 100644 index 0000000..8c200b6 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/styles.js @@ -0,0 +1,305 @@ +import { COLOR_ACTION_ID } from './template.js'; +import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_FOCUS, HOVER_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js'; + +/** Injected CSS for the text color and gradient action panel. */ +export const COLOR_ACTION_STYLES = ` +#${COLOR_ACTION_ID} { + position: fixed; + display: none; + flex-direction: column; + width: 216px; + background: ${PANEL_BG}; + border: 1px solid ${BORDER_COLOR}; + border-radius: 20px; + padding: 12px; + box-shadow: ${BOX_SHADOW_DROPDOWN}; + font-family: DM Sans, sans-serif; + user-select: none; + z-index: ${Z_INDEX_EDITOR_PANEL}; +} + +#${COLOR_ACTION_ID}.tca-visible { + display: flex; +} + +.tft-color-tabs { + display: flex; + gap: 1px; + padding: 1px; + border: 1px solid ${BORDER_COLOR}; + border-radius: 8px; +} + +.tft-color-tab { + flex: 1; + background: transparent; + border: none; + color: ${COLOR_WHITE}; + padding: 4px 8px; + font-size: 12px; + font-weight: 500; + font-family: inherit; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 20px; +} + +.tft-color-tab:hover { + background: ${HOVER_BG}; +} + +.tft-color-tab.tca-tab-active { + background: ${BORDER_COLOR}; +} + +.tft-color-grid { + display: flex; + flex-wrap: wrap; + margin: 10px 0px; +} + +.tft-color-grid.tca-hidden { + display: none; +} + +.tft-color-option { + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + cursor: pointer; + border: 1px solid transparent; + padding: 2px; +} + +.tft-color-swatch { + display: block; + width: 20px; + height: 20px; + border-radius: 5px; + border: 1px solid #262831; + pointer-events: none; + transition: border-color 0.15s; +} + +/* Indicate hover/active by recoloring the swatch's own border (no extra ring). */ +.tft-color-option:hover .tft-color-swatch, +.tft-color-option.tca-active .tft-color-swatch { + border-color: ${COLOR_WHITE}; +} + +.tft-color-add { + display: flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + padding: 2px; + cursor: pointer; +} + +.tft-color-add-box { + box-sizing: border-box; + position: relative; + width: 22px; + height: 22px; + border-radius: 5px; + border: 1px solid ${COLOR_WHITE}4d; + transition: border-color 0.15s; +} + +/* Plus drawn with two bars so it is always geometrically centered. */ +.tft-color-add-box::before, +.tft-color-add-box::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: ${COLOR_WHITE}99; + transition: background 0.15s; +} + +.tft-color-add-box::before { width: 9px; height: 1px; } +.tft-color-add-box::after { width: 1px; height: 9px; } + +.tft-color-add:hover .tft-color-add-box { + border-color: ${COLOR_WHITE}; +} + +.tft-color-add:hover .tft-color-add-box::before, +.tft-color-add:hover .tft-color-add-box::after { + background: ${COLOR_WHITE}; +} + +.tft-picker { + display: flex; + flex-direction: column; + gap: 8px; +} + +.tft-picker-square { + position: relative; + width: 100%; + height: 116px; + border-radius: 6px; + overflow: hidden; + cursor: crosshair; +} + +.tft-picker-sq-white { + position: absolute; + inset: 0; + background: linear-gradient(to right, ${COLOR_WHITE}, transparent); + pointer-events: none; +} + +.tft-picker-sq-black { + position: absolute; + inset: 0; + background: linear-gradient(to top, #000, transparent); + pointer-events: none; +} + +.tft-picker-indicator { + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid ${COLOR_WHITE}; + box-shadow: 0 0 0 1px rgba(0,0,0,0.4); + transform: translate(-50%, -50%); + pointer-events: none; +} + +.tft-picker-hue, +.tft-picker-alpha { + width: 100%; + height: 10px; + -webkit-appearance: none; + appearance: none; + border-radius: 5px; + outline: none; + cursor: pointer; + border: none; +} + +.tft-picker-hue { + background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); +} + +.tft-picker-hue::-webkit-slider-thumb, +.tft-picker-alpha::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: ${COLOR_WHITE}; + border: 1.5px solid rgba(255,255,255,0.4); + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + cursor: pointer; +} + +.tft-picker-hue::-moz-range-thumb, +.tft-picker-alpha::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: ${COLOR_WHITE}; + border: 1.5px solid rgba(255,255,255,0.4); + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + cursor: pointer; +} + +.tft-picker-hex-row { + display: flex; + align-items: center; + gap: 6px; +} + +.tft-picker-hex-input { + flex: 1; + background: transparent; + border: 1px solid ${BORDER_COLOR}; + border-radius: 8px; + color: ${COLOR_WHITE}; + font-family: inherit; + font-size: 12px; + line-height: 20px; + padding: 6px 8px; + height: 28px; + box-sizing: border-box; + cursor: text; + position: relative; + white-space: nowrap; + overflow: hidden; + display: flex; + align-items: center; + transition: border-color 0.15s; +} + +.tft-picker-hex-input.tca-focused { + border-color: ${BORDER_COLOR_FOCUS}; +} + +.tft-picker-hex-cursor { + display: inline-block; + width: 1px; + height: 1em; + background: ${COLOR_WHITE}; + margin: 0 -1px; + vertical-align: text-bottom; + animation: tft-cursor-blink 1s step-end infinite; +} + +.tft-picker-hex-char { + white-space: pre; +} + +.tft-picker-hex-csel { + background: #357DF966; + border-radius: 1px; +} + +@keyframes tft-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.tft-picker-hex-picker { + background: transparent; + border: 1px solid ${BORDER_COLOR}; + padding: 0; + width: 28px; + height: 28px; + border-radius: 8px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.tft-picker-hex-picker:hover { background: rgba(255, 255, 255, 0.05); } + +.tft-gradient-stops { + display: flex; + gap: 4px; + margin: 0 0 8px; +} + +.tft-gradient-stops.tca-hidden { display: none; } +.tft-gradient-stop-swatch { + flex: 1; + height: 28px; + border-radius: 10px; +} + +.tft-gradient-stop-swatch .tft-color-swatch { + width: 100%; + height: 100%; + border-radius: 7px; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/swatches.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/swatches.js new file mode 100644 index 0000000..80f0838 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/swatches.js @@ -0,0 +1,295 @@ +import { SOLID_COLORS, GRADIENTS } from './template.js'; +import { canonicalGradientKey } from './gradient.js'; + +/** Maximum total swatches shown in a grid (presets + custom): 3 rows of 7. */ +export const COLOR_GRID_MAX = 21; + +/** Maximum custom solid/gradient swatches persisted in localStorage. */ +export const CUSTOM_SWATCHES_MAX = 32; + +/** localStorage key for user-added solid color swatches. */ +const CUSTOM_SOLIDS_KEY = 'tft-color-custom-solids'; + +/** localStorage key for user-added gradient swatches. */ +const CUSTOM_GRADIENTS_KEY = 'tft-color-custom-gradients'; + +/** Lowercase preset solid colors used to skip duplicate custom entries. */ +const PRESET_SOLIDS_LOWERCASE = SOLID_COLORS.map(color => color.toLowerCase()); + +/** Canonical keys for preset gradients used to skip duplicate custom entries. */ +const PRESET_GRADIENT_KEYS = GRADIENTS.map(gradient => canonicalGradientKey(gradient)); + +let _element = null; + +/** @param {HTMLElement} colorActionElement */ +export function initSwatches(colorActionElement) { + _element = colorActionElement; +} + +/** + * @param {HTMLElement} grid + * @returns {boolean} Whether the grid is already at the 21-swatch cap. + */ +function isGridFull(grid) { + return grid.querySelectorAll('.tft-color-option').length >= COLOR_GRID_MAX; +} + +/** @param {HTMLElement} option @returns {boolean} */ +export function isCustomSwatchEl(option) { + return !!option && option.classList?.contains('tft-custom'); +} + +/** Highlights `option` as the active swatch within its grid (and clears siblings). */ +export function setActiveSwatch(option) { + const grid = option?.parentElement; + if (!grid) return; + grid.querySelectorAll('.tft-color-option').forEach(swatchOption => swatchOption.classList.toggle('tca-active', swatchOption === option)); +} + +/** Shows/hides the grid's "+" add button based on the 21-swatch cap. */ +export function updateAddButtonVisibility(grid) { + const addButton = grid?.querySelector('.tft-color-add'); + if (!addButton) return; + addButton.style.display = isGridFull(grid) ? 'none' : ''; +} + +/** Inserts a swatch before the trailing "+" add button (or appends it). */ +function appendSwatch(grid, element) { + const addButton = grid.querySelector('.tft-color-add'); + if (addButton) grid.insertBefore(element, addButton); + else grid.appendChild(element); + updateAddButtonVisibility(grid); +} + +/** + * @param {string} key - localStorage key. + * @returns {string[]} + */ +export function loadCustomList(key) { + try { + const raw = localStorage.getItem(key); + const array = raw ? JSON.parse(raw) : []; + return Array.isArray(array) ? array.filter(value => typeof value === 'string') : []; + } catch { return []; } +} + +/** + * @param {string} key + * @param {string[]} array + */ +export function saveCustomList(key, array) { + try { localStorage.setItem(key, JSON.stringify(array.slice(-CUSTOM_SWATCHES_MAX))); } catch { /* quota / disabled */ } +} + +/** Rewrites the persisted custom solids list from the swatches currently in the grid. */ +function persistSolidsFromDom(grid) { + const customs = Array.from(grid.querySelectorAll('.tft-color-option.tft-custom')) + .map(option => (option.dataset.color || '').toLowerCase()) + .filter(Boolean); + saveCustomList(CUSTOM_SOLIDS_KEY, customs); +} + +/** Rewrites the persisted custom gradients list from the swatches currently in the grid. */ +function persistGradientsFromDom(grid) { + const customs = Array.from(grid.querySelectorAll('.tft-color-option.tft-custom')) + .map(option => (option.dataset.gradient || '').toLowerCase()) + .filter(Boolean); + saveCustomList(CUSTOM_GRADIENTS_KEY, customs); +} + +/** + * Highlights the solid swatch matching the given hex. + * @param {string|null} hex + * @returns {HTMLElement|null} Matched option element. + */ +export function syncSolidSwatchActive(hex) { + if (!_element) return null; + const target = hex?.toLowerCase(); + let found = null; + _element.querySelectorAll('.tft-color-grid[data-tab-panel="solid"] .tft-color-option').forEach(option => { + const match = !!target && (option.dataset.color || '').toLowerCase() === target; + option.classList.toggle('tca-active', match); + if (match) found = option; + }); + return found; +} + +/** + * Highlights the gradient swatch matching the given CSS gradient. + * @param {string|null} gradient + * @returns {HTMLElement|null} Matched option element. + */ +export function syncGradientSwatchActive(gradient) { + if (!_element) return null; + const targetKey = gradient ? canonicalGradientKey(gradient) : ''; + let found = null; + _element.querySelectorAll('.tft-color-grid[data-tab-panel="gradient"] .tft-color-option').forEach(option => { + const optionKey = canonicalGradientKey(option.dataset.gradient || ''); + const match = !!targetKey && optionKey === targetKey; + option.classList.toggle('tca-active', match); + if (match) found = option; + }); + return found; +} + +/** Recolors a swatch in place (presets too); re-persists when it's a custom one. */ +export function recolorSolidSwatch(option, hex) { + if (!option || !hex) return; + hex = hex.toLowerCase(); + option.dataset.color = hex; + const innerSwatch = option.querySelector('.tft-color-swatch'); + if (innerSwatch) innerSwatch.style.background = hex; + if (isCustomSwatchEl(option)) persistSolidsFromDom(option.parentElement); +} + +/** Recolors a gradient swatch in place (presets too); re-persists when custom. */ +export function recolorGradientSwatch(option, gradient) { + if (!option || !gradient) return; + const normalizedGradient = gradient.toLowerCase(); + option.dataset.gradient = normalizedGradient; + const innerSwatch = option.querySelector('.tft-color-swatch'); + if (innerSwatch) innerSwatch.style.background = normalizedGradient; + if (isCustomSwatchEl(option)) persistGradientsFromDom(option.parentElement); +} + +/** + * Returns the existing solid swatch matching `hex`, or appends a new custom one + * (up to the 21-swatch cap). Returns null only when the grid is full. + * @param {string} hex + * @returns {HTMLElement|null} + */ +export function ensureCustomSwatch(hex) { + if (!_element || !hex) return null; + hex = hex.toLowerCase(); + const grid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]'); + if (!grid) return null; + const existing = Array.from(grid.querySelectorAll('.tft-color-option')) + .find(candidateOption => (candidateOption.dataset.color || '').toLowerCase() === hex); + if (existing) return existing; + if (isGridFull(grid)) return null; + const option = document.createElement('div'); + option.className = 'tft-color-option tft-custom'; + option.dataset.color = hex; + option.innerHTML = `<span class="tft-color-swatch" style="background:${hex}"></span>`; + appendSwatch(grid, option); + persistSolidsFromDom(grid); + return option; +} + +/** + * Returns the existing gradient swatch matching `gradient`, or appends a new + * custom one (up to the cap). Returns null only when the grid is full. + * @param {string} gradient + * @returns {HTMLElement|null} + */ +export function ensureCustomGradientSwatch(gradient) { + if (!_element || !gradient) return null; + const normalizedGradient = gradient.toLowerCase(); + const key = canonicalGradientKey(normalizedGradient); + const grid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]'); + if (!grid) return null; + const existing = Array.from(grid.querySelectorAll('.tft-color-option')) + .find(candidateOption => canonicalGradientKey(candidateOption.dataset.gradient || '') === key); + if (existing) return existing; + if (isGridFull(grid)) return null; + const option = document.createElement('div'); + option.className = 'tft-color-option tft-custom'; + option.dataset.gradient = normalizedGradient; + option.innerHTML = `<span class="tft-color-swatch" style="background:${normalizedGradient}"></span>`; + appendSwatch(grid, option); + persistGradientsFromDom(grid); + return option; +} + +/** + * Appends a new custom solid swatch even when the color already exists — used by + * the "+" button to duplicate the active swatch. Returns null when the grid is full. + * @param {string} hex + * @returns {HTMLElement|null} + */ +export function appendCustomSwatch(hex) { + if (!_element || !hex) return null; + hex = hex.toLowerCase(); + const grid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]'); + if (!grid || isGridFull(grid)) return null; + const option = document.createElement('div'); + option.className = 'tft-color-option tft-custom'; + option.dataset.color = hex; + option.innerHTML = `<span class="tft-color-swatch" style="background:${hex}"></span>`; + appendSwatch(grid, option); + persistSolidsFromDom(grid); + return option; +} + +/** + * Appends a new custom gradient swatch even when it already exists — used by the + * "+" button to duplicate the active gradient. Returns null when the grid is full. + * @param {string} gradient + * @returns {HTMLElement|null} + */ +export function appendCustomGradientSwatch(gradient) { + if (!_element || !gradient) return null; + const normalizedGradient = gradient.toLowerCase(); + const grid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]'); + if (!grid || isGridFull(grid)) return null; + const option = document.createElement('div'); + option.className = 'tft-color-option tft-custom'; + option.dataset.gradient = normalizedGradient; + option.innerHTML = `<span class="tft-color-swatch" style="background:${normalizedGradient}"></span>`; + appendSwatch(grid, option); + persistGradientsFromDom(grid); + return option; +} + +/** Rebuilds custom solid and gradient swatches from localStorage. */ +export function restorePersistedSwatches() { + if (!_element) return; + + const solidGrid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]'); + if (solidGrid) { + const stored = loadCustomList(CUSTOM_SOLIDS_KEY); + const cleaned = []; + const seen = new Set(); + stored.forEach(hex => { + const lowercaseHex = (hex || '').toLowerCase(); + if (!lowercaseHex || PRESET_SOLIDS_LOWERCASE.includes(lowercaseHex) || seen.has(lowercaseHex)) return; + seen.add(lowercaseHex); + cleaned.push(lowercaseHex); + const exists = Array.from(solidGrid.querySelectorAll('.tft-color-option')) + .some(option => (option.dataset.color || '').toLowerCase() === lowercaseHex); + if (exists || isGridFull(solidGrid)) return; + const div = document.createElement('div'); + div.className = 'tft-color-option tft-custom'; + div.dataset.color = lowercaseHex; + div.innerHTML = `<span class="tft-color-swatch" style="background:${lowercaseHex}"></span>`; + appendSwatch(solidGrid, div); + }); + if (cleaned.length !== stored.length) saveCustomList(CUSTOM_SOLIDS_KEY, cleaned); + updateAddButtonVisibility(solidGrid); + } + + const gradientGrid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]'); + if (gradientGrid) { + const stored = loadCustomList(CUSTOM_GRADIENTS_KEY); + const cleaned = []; + const seen = new Set(); + stored.forEach(gradient => { + const lowercaseGradient = (gradient || '').toLowerCase(); + if (!lowercaseGradient) return; + const key = canonicalGradientKey(lowercaseGradient); + if (PRESET_GRADIENT_KEYS.includes(key) || seen.has(key)) return; + seen.add(key); + cleaned.push(lowercaseGradient); + const exists = Array.from(gradientGrid.querySelectorAll('.tft-color-option')) + .some(option => canonicalGradientKey(option.dataset.gradient || '') === key); + if (exists || isGridFull(gradientGrid)) return; + const div = document.createElement('div'); + div.className = 'tft-color-option tft-custom'; + div.dataset.gradient = lowercaseGradient; + div.innerHTML = `<span class="tft-color-swatch" style="background:${lowercaseGradient}"></span>`; + appendSwatch(gradientGrid, div); + }); + if (cleaned.length !== stored.length) saveCustomList(CUSTOM_GRADIENTS_KEY, cleaned); + updateAddButtonVisibility(gradientGrid); + } +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/template.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/template.js new file mode 100644 index 0000000..fd80603 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/template.js @@ -0,0 +1,60 @@ +/** DOM id for the text color/gradient action panel root element. */ +export const COLOR_ACTION_ID = 'text-format-color-action'; + +/** Preset solid fill colors for the swatch grid. */ +export const SOLID_COLORS = [ + '#000000', + '#FF3B30', + '#FF9500', + '#34C759', + '#007AFF', +]; + +/** Preset CSS linear gradients for the swatch grid. */ +export const GRADIENTS = [ + 'linear-gradient(90deg, #FF6B6B, #FFE66D)', + 'linear-gradient(90deg, #4A90E2, #50C9C3)', + 'linear-gradient(90deg, #F12711, #F5AF19)', + 'linear-gradient(90deg, #8E2DE2, #4A00E0)', + 'linear-gradient(90deg, #00B09B, #96C93D)', +]; + +/** HTML scaffold for the color action panel (tabs, swatches, picker). */ +export const COLOR_ACTION_HTML = ` +<div id="${COLOR_ACTION_ID}"> + <div class="tft-color-tabs"> + <button class="tft-color-tab tca-tab-active" data-tab="solid">Solid</button> + <button class="tft-color-tab" data-tab="gradient">Gradient</button> + </div> + <div class="tft-color-grid" data-tab-panel="solid"> + ${SOLID_COLORS.map(color => `<div class="tft-color-option" data-color="${color}"><span class="tft-color-swatch" style="background:${color}"></span></div>`).join('\n ')} + <div class="tft-color-add" role="button" aria-label="Add color" title="Add color"><span class="tft-color-add-box"></span></div> + </div> + <div class="tft-color-grid tca-hidden" data-tab-panel="gradient"> + ${GRADIENTS.map(gradient => `<div class="tft-color-option" data-gradient="${gradient}"><span class="tft-color-swatch" style="background:${gradient}"></span></div>`).join('\n ')} + <div class="tft-color-add" role="button" aria-label="Add gradient" title="Add gradient"><span class="tft-color-add-box"></span></div> + </div> + <div class="tft-gradient-stops tca-hidden" data-tab-panel="gradient"> + <div class="tft-color-option tft-gradient-stop-swatch" data-stop="0"><span class="tft-color-swatch"></span></div> + <div class="tft-color-option tft-gradient-stop-swatch" data-stop="1"><span class="tft-color-swatch"></span></div> + </div> + <div class="tft-picker"> + <div class="tft-picker-square"> + <div class="tft-picker-sq-white"></div> + <div class="tft-picker-sq-black"></div> + <div class="tft-picker-indicator"></div> + </div> + <input type="range" class="tft-picker-hue" min="0" max="360" value="0"> + <input type="range" class="tft-picker-alpha" min="0" max="100" value="100"> + <div class="tft-picker-hex-row"> + <div class="tft-picker-hex-input" role="textbox"></div> + <button class="tft-picker-hex-picker"> + <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"> + <path d="M8.22768 7.04306L4.90249 10.3683C4.84755 10.4232 4.8201 10.5012 4.83022 10.5782L4.90249 11.1212L5.39174 11.1661L5.44643 11.1651C5.50056 11.1581 5.55163 11.1331 5.59096 11.0938L8.93471 7.75009L9.99624 8.81064L6.65151 12.1544C6.33072 12.4751 5.89991 12.6576 5.45132 12.6671L5.25796 12.6602L4.52065 12.5938L4.27553 12.839C3.98275 13.1317 3.50791 13.1315 3.21499 12.839C2.94053 12.5645 2.92334 12.1306 3.16323 11.836L3.21499 11.7784L3.44643 11.547L3.34389 10.7735C3.273 10.2342 3.45731 9.69234 3.84194 9.30771L7.16714 5.98251L8.22768 7.04306Z" fill="#ffffff"/> + <path d="M10.3869 2.83798C11.1679 2.05694 12.4349 2.05694 13.216 2.83798C13.9966 3.61894 13.9966 4.88512 13.216 5.66611L11.6242 7.25693L11.9777 7.61044C12.2706 7.90334 12.2706 8.37907 11.9777 8.67197C11.6848 8.96443 11.2099 8.96461 10.9171 8.67197L7.38198 5.13584C7.08909 4.84294 7.08909 4.36818 7.38198 4.07529C7.67488 3.78249 8.14967 3.78243 8.44253 4.07529L8.79604 4.4288L10.3869 2.83798Z" fill="#ffffff"/> + </svg> + </button> + </div> + </div> +</div> +`.trim(); \ No newline at end of file diff --git a/apps/web/plugins/visual-editor/ui/text-format/dropdown-panel.js b/apps/web/plugins/visual-editor/ui/text-format/dropdown-panel.js new file mode 100644 index 0000000..0ca5ae1 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/dropdown-panel.js @@ -0,0 +1,94 @@ +/** + * Shared lifecycle helpers for the toolbar dropdown action panels + * (size, font, link, color): DOM bootstrapping, positioning relative to the + * toolbar, and outside-interaction dismissal. + */ +import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT } from '../../constants/layout.js'; +import { isolateEditorUiEvents } from '../../utils/dom-utils.js'; +import { getToolbarEl } from './toolbar/toolbar.js'; + +/** + * Injects the panel stylesheet (once) and mounts the panel root element. + * @param {{ styleId: string, styles: string, html: string }} config + * @returns {HTMLElement} The mounted panel root. + */ +export function createPanelElement({ styleId, styles, html }) { + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = styles; + document.head.appendChild(style); + } + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const element = wrapper.firstElementChild; + document.body.appendChild(element); + isolateEditorUiEvents(element); + return element; +} + +/** + * Positions a dropdown panel below (or above) the toolbar, left-aligned to its + * trigger button and clamped to the viewport. + * @param {HTMLElement} element - Panel root. + * @param {{ buttonAction?: string|null, matchToolbarWidth?: boolean }} [options] + */ +export function positionDropdownPanel(element, { buttonAction = null, matchToolbarWidth = false } = {}) { + const toolbarElement = getToolbarEl(); + if (!toolbarElement || !element) return; + + const toolbarBounds = toolbarElement.getBoundingClientRect(); + if (matchToolbarWidth) element.style.width = `${toolbarBounds.width}px`; + + const button = buttonAction ? toolbarElement.querySelector(`[data-action="${buttonAction}"]`) : null; + const anchorBounds = button ? button.getBoundingClientRect() : toolbarBounds; + const panelWidth = matchToolbarWidth ? toolbarBounds.width : element.offsetWidth; + const panelHeight = element.offsetHeight; + + const spaceBelow = window.innerHeight - PARENT_TOOLBAR_HEIGHT - toolbarBounds.bottom; + const top = Math.max(PANEL_MARGIN, spaceBelow >= panelHeight + PANEL_GAP + ? toolbarBounds.bottom + PANEL_GAP + : toolbarBounds.top - panelHeight - PANEL_GAP); + + const left = Math.max(PANEL_MARGIN, Math.min(anchorBounds.left, window.innerWidth - panelWidth - PANEL_MARGIN)); + + element.style.left = `${left}px`; + element.style.top = `${top}px`; +} + +/** + * Builds attach/detach-able listeners that dismiss a panel on outside + * mousedown. + * @param {{ + * getElement: () => HTMLElement|null, + * dismiss: () => void, + * ignoreToolbar?: boolean, + * ignoreMouseDown?: ((event: MouseEvent) => boolean)|null, + * captureMouseDown?: boolean, + * }} config + * @returns {{ attach: () => void, detach: () => void }} + */ +export function createOutsideDismiss({ getElement, dismiss, ignoreToolbar = true, ignoreMouseDown = null, captureMouseDown = false }) { + function onMouseDown(event) { + if (getElement()?.contains(event.target)) return; + if (ignoreToolbar && getToolbarEl()?.contains(event.target)) return; + if (ignoreMouseDown?.(event)) return; + dismiss(); + } + return { + attach() { + document.addEventListener('mousedown', onMouseDown, captureMouseDown); + }, + detach() { + document.removeEventListener('mousedown', onMouseDown, captureMouseDown); + }, + }; +} + +/** + * @returns {Range|null} Clone of the current selection range, if any. + */ +export function captureSelectionRange() { + const selection = window.getSelection(); + return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/font-family-action/font-family-action.js b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/font-family-action.js new file mode 100644 index 0000000..f709432 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/font-family-action.js @@ -0,0 +1,113 @@ +import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js'; +import { getToolbarTarget } from '../toolbar/toolbar.js'; +import { wrapSelectionWithStyle } from '../style-actions.js'; +import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js'; +import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js'; +import { SYSTEM_FONTS, FONT_ACTION_HTML } from './template.js'; +import { FONT_ACTION_STYLES } from './styles.js'; + +let fontActionElement = null; +let savedFontRange = null; + +const outsideDismiss = createOutsideDismiss({ + getElement: () => fontActionElement, + dismiss: () => hideFontAction(), +}); + +function getAvailableFonts() { + const fonts = [...SYSTEM_FONTS]; + const seen = new Set(SYSTEM_FONTS.map(font => font.name.toLowerCase())); + document.fonts?.forEach(face => { + if (face.status !== 'loaded') return; + const family = face.family.replace(/^['"]|['"]$/g, ''); + const key = family.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + const needsQuotes = /\s/.test(family); + const quoted = needsQuotes ? `"${family}"` : family; + fonts.push({ name: family, stack: `${quoted}, sans-serif` }); + }); + return fonts; +} + +function renderFontOptions() { + if (!fontActionElement) return; + fontActionElement.innerHTML = getAvailableFonts() + .map(font => `<div class="tft-font-option" data-font='${font.stack}' style='font-family: ${font.stack}'>${font.name}</div>`) + .join('\n '); +} + +function initFontAction() { + if (fontActionElement) return; + fontActionElement = createPanelElement({ + styleId: 'text-format-font-action-styles', + styles: FONT_ACTION_STYLES, + html: FONT_ACTION_HTML, + }); + + fontActionElement.addEventListener('mousedown', (event) => event.preventDefault()); + + fontActionElement.addEventListener('click', (event) => { + const option = event.target.closest('.tft-font-option'); + if (!option) return; + applyFontAction(option.dataset.font); + hideFontAction(); + }); +} + +function applyFontAction(family) { + if (!savedFontRange || !getToolbarTarget() || !family) return; + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(savedFontRange); + wrapSelectionWithStyle(getToolbarTarget(), 'fontFamily', 'font-family', family); + onSelectionChange(); +} + +export function positionFontAction() { + if (!isPanelVisible(PanelId.FONT) || !fontActionElement) return; + positionDropdownPanel(fontActionElement, { buttonAction: 'font-family' }); +} + +/** Opens the font family panel with the current family highlighted. */ +export function showFontAction() { + if (!fontActionElement) initFontAction(); + renderFontOptions(); + + savedFontRange = captureSelectionRange(); + + const styles = getStylesAtCursor(); + const currentFamily = styles?.fontFamily + ?? (getToolbarTarget() ? getComputedStyle(getToolbarTarget()).fontFamily : ''); + const normalizedCurrent = currentFamily.split(',')[0].replace(/['"]/g, '').trim().toLowerCase(); + fontActionElement.querySelectorAll('.tft-font-option').forEach(option => { + const optionName = option.dataset.font.split(',')[0].replace(/['"]/g, '').trim().toLowerCase(); + option.classList.toggle('tfa-active', optionName === normalizedCurrent); + }); + + fontActionElement.classList.add('tfa-visible'); + setPanelVisible(PanelId.FONT, true); + positionFontAction(); + + outsideDismiss.attach(); +} + +/** Closes the font family panel. */ +export function hideFontAction() { + if (!fontActionElement) return; + fontActionElement.classList.remove('tfa-visible'); + setPanelVisible(PanelId.FONT, false); + savedFontRange = null; + outsideDismiss.detach(); +} + +/** Toggles the font family panel visibility. */ +export function toggleFontAction() { + if (isPanelVisible(PanelId.FONT)) { + hideFontAction(); + } else { + showFontAction(); + } +} + +registerPanel({ id: PanelId.FONT, hide: hideFontAction }); diff --git a/apps/web/plugins/visual-editor/ui/text-format/font-family-action/styles.js b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/styles.js new file mode 100644 index 0000000..0c1d698 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/styles.js @@ -0,0 +1,41 @@ +import { FONT_ACTION_ID } from './template.js'; +import { PANEL_BG, BORDER_COLOR, HOVER_BG, ACTIVE_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js'; + +/** Injected CSS for the font family dropdown panel. */ +export const FONT_ACTION_STYLES = ` +#${FONT_ACTION_ID} { + position: fixed; + display: none; + flex-direction: column; + overflow-y: auto; + max-height: 280px; + background: ${PANEL_BG}; + border: 1px solid ${BORDER_COLOR}; + border-radius: 12px; + padding: 4px; + box-shadow: ${BOX_SHADOW_DROPDOWN}; + user-select: none; + z-index: ${Z_INDEX_EDITOR_PANEL}; +} + +#${FONT_ACTION_ID}.tfa-visible { + display: flex; +} + +.tft-font-option { + padding: 4px 10px; + color: ${COLOR_WHITE}; + font-size: 14px; + border-radius: 8px; + cursor: pointer; + white-space: nowrap; +} + +.tft-font-option:hover { + background: ${HOVER_BG}; +} + +.tft-font-option.tfa-active { + background: ${ACTIVE_BG}; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/font-family-action/template.js b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/template.js new file mode 100644 index 0000000..5c53c2b --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/template.js @@ -0,0 +1,16 @@ +/** DOM id for the font family action panel root element. */ +export const FONT_ACTION_ID = 'text-format-font-action'; + +/** System font presets shown in the font family dropdown. */ +export const SYSTEM_FONTS = [ + { name: 'Arial', stack: 'Arial, sans-serif' }, + { name: 'Helvetica', stack: 'Helvetica, sans-serif' }, + { name: 'Verdana', stack: 'Verdana, sans-serif' }, + { name: 'Trebuchet MS', stack: '"Trebuchet MS", sans-serif' }, + { name: 'Georgia', stack: 'Georgia, serif' }, + { name: 'Times New Roman', stack: '"Times New Roman", serif' }, + { name: 'Courier New', stack: '"Courier New", monospace' }, +]; + +/** HTML scaffold for the font family action panel. */ +export const FONT_ACTION_HTML = `<div id="${FONT_ACTION_ID}"></div>`; \ No newline at end of file diff --git a/apps/web/plugins/visual-editor/ui/text-format/link-action/link-action.js b/apps/web/plugins/visual-editor/ui/text-format/link-action/link-action.js new file mode 100644 index 0000000..0d6958d --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/link-action/link-action.js @@ -0,0 +1,427 @@ +import { getEditing } from '../../../state/editing-state.js'; +import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js'; +import { recordEdit } from '../../../state/history-state.js'; +import { notifyDraftStateChanged } from '../../../api/draft-snapshot.js'; +import { escapeHtml } from '../../../utils/html-utils.js'; +import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js'; +import { captureElementMetadata } from '../../../utils/selection-mode-metadata.js'; +import { LINK_ACTION_HTML } from './template.js'; +import { LINK_ACTION_STYLES } from './styles.js'; + +let linkActionElement = null; +let savedLinkRange = null; +let linkVirtualValue = ''; +let linkVirtualCursor = 0; +let linkAnchor = 0; +let linkDragging = false; + +const outsideDismiss = createOutsideDismiss({ + getElement: () => linkActionElement, + dismiss: () => saveLink(), +}); + +/** @returns {number} Lower selection bound. */ +function selectionStart() { return Math.min(linkAnchor, linkVirtualCursor); } +/** @returns {number} Upper selection bound. */ +function selectionEnd() { return Math.max(linkAnchor, linkVirtualCursor); } +/** @returns {boolean} Whether a (non-collapsed) selection exists. */ +function hasSelection() { return linkAnchor !== linkVirtualCursor; } +/** Collapses the selection onto the caret. */ +function collapseSelection() { linkAnchor = linkVirtualCursor; } + +function renderLinkInput() { + const element = linkActionElement?.querySelector('#tft-link-url'); + if (!element) return; + const caret = '<span class="tft-link-vcursor"></span>'; + if (!linkVirtualValue) { + element.innerHTML = `<span class="tft-link-placeholder">Paste or type a link</span>${caret}`; + return; + } + const start = selectionStart(); + const end = selectionEnd(); + let html = ''; + for (let index = 0; index < linkVirtualValue.length; index++) { + if (index === linkVirtualCursor) html += caret; + const selectionClass = index >= start && index < end ? ' tft-link-csel' : ''; + html += `<span class="tft-link-char${selectionClass}">${escapeHtml(linkVirtualValue[index])}</span>`; + } + if (linkVirtualCursor === linkVirtualValue.length) html += caret; + element.innerHTML = html; +} + +/** Selects the whole link value (double-click / Cmd+A) so the next input or paste replaces it. */ +function selectAllLink() { + linkAnchor = 0; + linkVirtualCursor = linkVirtualValue.length; + renderLinkInput(); +} + +/** + * Maps a viewport x coordinate to a caret index between characters. + * @param {number} pointerX + * @returns {number} + */ +function caretIndexFromPointerX(pointerX) { + const element = linkActionElement?.querySelector('#tft-link-url'); + if (!element) return 0; + const characters = element.querySelectorAll('.tft-link-char'); + for (let index = 0; index < characters.length; index++) { + const characterBounds = characters[index].getBoundingClientRect(); + // Caret goes before this character once the click lands left of its midpoint. + if (pointerX < characterBounds.left + characterBounds.width / 2) return index; + } + return characters.length; +} + +/** + * Begins a click/drag in the field: drops a collapsed caret at the pointer. + * @param {number} pointerX + */ +function linkPointerDown(pointerX) { + renderLinkInput(); + const caretIndex = caretIndexFromPointerX(pointerX); + linkAnchor = caretIndex; + linkVirtualCursor = caretIndex; + renderLinkInput(); +} + +/** + * Extends the selection to the dragged-to position. + * @param {number} pointerX + */ +function linkPointerMove(pointerX) { + linkVirtualCursor = caretIndexFromPointerX(pointerX); + renderLinkInput(); +} + +function onLinkKeydown(event) { + if (!isPanelVisible(PanelId.LINK)) return; + if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); saveLink(); return; } + if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); hideLinkAction(); return; } + if (event.key === 'a' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); event.stopPropagation(); + selectAllLink(); + return; + } + if (hasSelection()) { + const start = selectionStart(); + const end = selectionEnd(); + if (event.key === 'Backspace' || event.key === 'Delete') { + event.preventDefault(); event.stopPropagation(); + linkVirtualValue = linkVirtualValue.slice(0, start) + linkVirtualValue.slice(end); + linkVirtualCursor = start; + collapseSelection(); + renderLinkInput(); + return; + } + if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); event.stopPropagation(); + linkVirtualValue = linkVirtualValue.slice(0, start) + event.key + linkVirtualValue.slice(end); + linkVirtualCursor = start + 1; + collapseSelection(); + renderLinkInput(); + return; + } + // Any other key collapses the selection before normal handling. + collapseSelection(); + } + if (event.key === 'Backspace') { + event.preventDefault(); event.stopPropagation(); + if (linkVirtualCursor > 0) { + linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor - 1) + linkVirtualValue.slice(linkVirtualCursor); + linkVirtualCursor--; + collapseSelection(); + renderLinkInput(); + } + return; + } + if (event.key === 'Delete') { + event.preventDefault(); event.stopPropagation(); + if (linkVirtualCursor < linkVirtualValue.length) { + linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor) + linkVirtualValue.slice(linkVirtualCursor + 1); + collapseSelection(); + renderLinkInput(); + } + return; + } + if (event.key === 'Home' || (event.key === 'ArrowLeft' && (event.metaKey || event.ctrlKey))) { + event.preventDefault(); event.stopPropagation(); + linkVirtualCursor = 0; + collapseSelection(); + renderLinkInput(); + return; + } + if (event.key === 'End' || (event.key === 'ArrowRight' && (event.metaKey || event.ctrlKey))) { + event.preventDefault(); event.stopPropagation(); + linkVirtualCursor = linkVirtualValue.length; + collapseSelection(); + renderLinkInput(); + return; + } + if (event.key === 'ArrowLeft') { + event.preventDefault(); event.stopPropagation(); + linkVirtualCursor = Math.max(0, linkVirtualCursor - 1); + collapseSelection(); + renderLinkInput(); + return; + } + if (event.key === 'ArrowRight') { + event.preventDefault(); event.stopPropagation(); + linkVirtualCursor = Math.min(linkVirtualValue.length, linkVirtualCursor + 1); + collapseSelection(); + renderLinkInput(); + return; + } + if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) { + event.preventDefault(); event.stopPropagation(); + linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor) + event.key + linkVirtualValue.slice(linkVirtualCursor); + linkVirtualCursor++; + collapseSelection(); + renderLinkInput(); + } +} + +function onLinkCopy(event) { + if (!isPanelVisible(PanelId.LINK)) return; + if (!hasSelection()) return; + const text = linkVirtualValue.slice(selectionStart(), selectionEnd()); + if (!text) return; + event.preventDefault(); event.stopPropagation(); + event.clipboardData?.setData('text/plain', text); + if (event.type === 'cut') { + const start = selectionStart(); + linkVirtualValue = linkVirtualValue.slice(0, start) + linkVirtualValue.slice(selectionEnd()); + linkVirtualCursor = start; + collapseSelection(); + renderLinkInput(); + } +} + +function onLinkPaste(event) { + if (!isPanelVisible(PanelId.LINK)) return; + event.preventDefault(); event.stopPropagation(); + const text = (event.clipboardData?.getData('text/plain') || '').replace(/\s/g, ''); + // Replace the current selection (if any), otherwise insert at the caret. + const start = selectionStart(); + const end = selectionEnd(); + linkVirtualValue = linkVirtualValue.slice(0, start) + text + linkVirtualValue.slice(end); + linkVirtualCursor = start + text.length; + collapseSelection(); + renderLinkInput(); +} + +function initLinkAction() { + if (linkActionElement) return; + linkActionElement = createPanelElement({ + styleId: 'text-format-link-action-styles', + styles: LINK_ACTION_STYLES, + html: LINK_ACTION_HTML, + }); + + linkActionElement.addEventListener('mousedown', (event) => { + event.preventDefault(); + // Start a drag-selection inside the virtual link field (it isn't a real + // <input>, so we emulate caret/selection from pointer coordinates). + if (event.target.closest('#tft-link-url')) { + linkActionElement.querySelector('#tft-link-url')?.classList.add('tla-focused'); + linkPointerDown(event.clientX); + linkDragging = true; + } + }); + + document.addEventListener('mousemove', (event) => { + if (linkDragging) linkPointerMove(event.clientX); + }); + document.addEventListener('mouseup', () => { linkDragging = false; }); + + linkActionElement.addEventListener('dblclick', (event) => { + if (event.target.closest('#tft-link-url')) { + event.preventDefault(); + selectAllLink(); + } + }); + + linkActionElement.addEventListener('click', (event) => { + const labelElement = event.target.closest('.tft-link-newwindow'); + if (labelElement) { + const checkbox = labelElement.querySelector('#tft-link-new-window'); + if (checkbox) checkbox.checked = !checkbox.checked; + } + }); +} + +function findAnchorInRange(range) { + if (!range) return null; + for (const container of [range.startContainer, range.endContainer]) { + let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container; + while (node && node !== document.body) { + if (node.tagName === 'A') return node; + node = node.parentNode; + } + } + return null; +} + +// A "source anchor" is an <a> rendered from JSX <a> or <Link> in the user's source +// (carries data-edit-url-attr). Its URL is editable in place by writing back to source. +// Distinct from toolbar-inserted anchors which only live inside the contenteditable. +function findSourceAnchorInRange(range) { + if (!range) return null; + for (const container of [range.startContainer, range.endContainer]) { + let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container; + while (node && node !== document.body) { + if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('data-edit-url-attr')) return node; + node = node.parentNode; + } + } + return null; +} + +function unwrapAnchor(anchor) { + const parent = anchor.parentNode; + while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor); + parent.removeChild(anchor); +} + +function applyLink(range, url, openNewTab) { + const anchor = document.createElement('a'); + anchor.href = url; + anchor.style.textDecoration = 'underline'; + if (openNewTab) { + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + } + try { + range.surroundContents(anchor); + } catch { + anchor.appendChild(range.extractContents()); + range.insertNode(anchor); + } + + const selection = window.getSelection(); + const newRange = document.createRange(); + newRange.selectNodeContents(anchor); + selection.removeAllRanges(); + selection.addRange(newRange); +} + +function saveLink() { + const url = linkVirtualValue.trim(); + const newTab = linkActionElement.querySelector('#tft-link-new-window').checked; + + let range = savedLinkRange; + + // Source-anchor path: rendered <a>/<Link> from JSX. Update href in place and + // persist the attribute edit. We must NOT unwrap — that destroys the contenteditable target. + const sourceAnchor = findSourceAnchorInRange(range); + if (sourceAnchor) { + // Empty URL on a source anchor would break navigation; no "remove link" semantic + // for source-attached anchors — leave as-is. + if (!url) { hideLinkAction(); return; } + + const editId = sourceAnchor.getAttribute('data-edit-id'); + const urlAttribute = sourceAnchor.getAttribute('data-edit-url-attr'); + const oldUrl = sourceAnchor.getAttribute('href'); + + if (oldUrl !== url && editId && urlAttribute) { + const selectionMode = captureElementMetadata(sourceAnchor); + sourceAnchor.setAttribute('href', url); + recordEdit(editId, { beforeContent: oldUrl, afterContent: url }, { attribute: urlAttribute, element: sourceAnchor, selectionMode }); + notifyDraftStateChanged(); + } + hideLinkAction(); + return; + } + + // Inline anchor path: anchors that live only inside contenteditable HTML + // (created/edited by the toolbar itself). Wrap/unwrap the DOM and let the + // outer text edit's innerHTML diff carry the change to source on commit. + const existing = findAnchorInRange(range); + let changed = false; + + if (existing) { + const children = Array.from(existing.childNodes); + const entirelyInside = children.length > 0 + && existing.contains(range.startContainer) + && existing.contains(range.endContainer); + unwrapAnchor(existing); + changed = true; + if (entirelyInside) { + range = document.createRange(); + range.setStartBefore(children[0]); + range.setEndAfter(children[children.length - 1]); + } + } + if (url && range) { + applyLink(range, url, newTab); + changed = true; + } + + if (changed) { + getEditing()?.targetElement?.dispatchEvent(new Event('input', { bubbles: true })); + } + hideLinkAction(); +} + +export function positionLinkAction() { + if (!isPanelVisible(PanelId.LINK) || !linkActionElement) return; + positionDropdownPanel(linkActionElement, { matchToolbarWidth: true }); +} + +/** Opens the link panel prefilled from an existing anchor in the selection. */ +export function showLinkAction() { + if (!linkActionElement) initLinkAction(); + + savedLinkRange = captureSelectionRange(); + + const existing = findAnchorInRange(savedLinkRange); + linkVirtualValue = existing ? (existing.getAttribute('href') || '') : ''; + linkVirtualCursor = linkVirtualValue.length; + linkAnchor = linkVirtualValue.length; + + // Source anchors don't roundtrip target/rel back to JSX yet, so hide the + // "open in new tab" checkbox to avoid a no-op control. + const sourceAnchor = findSourceAnchorInRange(savedLinkRange); + const newTabLabel = linkActionElement.querySelector('.tft-link-newwindow'); + if (newTabLabel) newTabLabel.style.display = sourceAnchor ? 'none' : ''; + linkActionElement.querySelector('#tft-link-new-window').checked = !sourceAnchor && existing ? existing.target === '_blank' : false; + + linkActionElement.classList.add('tla-visible'); + setPanelVisible(PanelId.LINK, true); + linkActionElement.querySelector('#tft-link-url')?.classList.add('tla-focused'); + positionLinkAction(); + renderLinkInput(); + + document.addEventListener('keydown', onLinkKeydown, true); + document.addEventListener('paste', onLinkPaste, true); + document.addEventListener('copy', onLinkCopy, true); + document.addEventListener('cut', onLinkCopy, true); + outsideDismiss.attach(); +} + +/** Closes the link panel and clears saved range. */ +export function hideLinkAction() { + if (!linkActionElement) return; + linkActionElement.classList.remove('tla-visible'); + linkActionElement.querySelector('#tft-link-url')?.classList.remove('tla-focused'); + setPanelVisible(PanelId.LINK, false); + savedLinkRange = null; + linkAnchor = linkVirtualCursor; + linkDragging = false; + document.removeEventListener('keydown', onLinkKeydown, true); + document.removeEventListener('paste', onLinkPaste, true); + document.removeEventListener('copy', onLinkCopy, true); + document.removeEventListener('cut', onLinkCopy, true); + outsideDismiss.detach(); +} + +/** Toggles the link panel visibility. */ +export function toggleLinkAction() { + if (isPanelVisible(PanelId.LINK)) { + hideLinkAction(); + } else { + showLinkAction(); + } +} + +registerPanel({ id: PanelId.LINK, hide: hideLinkAction }); diff --git a/apps/web/plugins/visual-editor/ui/text-format/link-action/styles.js b/apps/web/plugins/visual-editor/ui/text-format/link-action/styles.js new file mode 100644 index 0000000..98cad8e --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/link-action/styles.js @@ -0,0 +1,120 @@ +import { LINK_ACTION_ID } from './template.js'; +import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_FOCUS, BOX_SHADOW_DROPDOWN, COLOR_WHITE, PLACEHOLDER_COLOR, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js'; + +/** Injected CSS for the link insertion panel. */ +export const LINK_ACTION_STYLES = ` +#${LINK_ACTION_ID} { + position: fixed; + display: none; + flex-direction: column; + gap: 8px; + background: ${PANEL_BG}; + padding: 8px; + border-radius: 12px; + box-sizing: border-box; + box-shadow: ${BOX_SHADOW_DROPDOWN}; + user-select: none; + font-family: DM Sans, sans-serif; + z-index: ${Z_INDEX_EDITOR_PANEL}; +} + +#${LINK_ACTION_ID}.tla-visible { + display: flex; +} + +.tft-link-input { + background: transparent; + border: 1px solid ${BORDER_COLOR}; + border-radius: 8px; + color: ${COLOR_WHITE}; + font-family: DM Sans, sans-serif; + font-size: 12px; + line-height: 20px; + padding: 6px 8px; + width: 100%; + box-sizing: border-box; + height: 32px; + cursor: text; + position: relative; + white-space: nowrap; + overflow: hidden; + display: flex; + align-items: center; +} + +.tft-link-input.tla-focused { + border-color: ${BORDER_COLOR_FOCUS}; +} + +.tft-link-placeholder { + position: absolute; + top: 50%; + transform: translateY(-50%); + left: 8px; + color: ${PLACEHOLDER_COLOR}; + pointer-events: none; +} + +.tft-link-char { + white-space: pre; +} + +.tft-link-csel { + background: #357DF966; + border-radius: 1px; +} + +.tft-link-vcursor { + display: inline-block; + width: 1px; + height: 1em; + background: ${COLOR_WHITE}; + margin-right: -1px; + vertical-align: text-bottom; + animation: tft-cursor-blink 1s step-end infinite; +} + +@keyframes tft-cursor-blink { + 50% { opacity: 0; } +} + +.tft-link-newwindow { + display: flex; + align-items: center; + gap: 8px; + padding: 0 4px; + cursor: pointer; +} +.tft-link-checkbox { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border: 1.5px solid ${COLOR_WHITE}4d; + border-radius: 3px; + background: ${COLOR_WHITE}08; + cursor: pointer; + flex-shrink: 0; + position: relative; +} +.tft-link-checkbox:checked { + background: ${COLOR_WHITE}; + border-color: ${COLOR_WHITE}; +} +.tft-link-checkbox:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 2px; + width: 4px; + height: 7px; + border: 2px solid ${PANEL_BG}; + border-top: none; + border-left: none; + transform: rotate(45deg); +} +.tft-link-newwindow-label { + font-size: 12px; + color: ${COLOR_WHITE}; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/link-action/template.js b/apps/web/plugins/visual-editor/ui/text-format/link-action/template.js new file mode 100644 index 0000000..ab457cb --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/link-action/template.js @@ -0,0 +1,13 @@ +/** DOM id for the link action panel root element. */ +export const LINK_ACTION_ID = 'text-format-link-action'; + +/** HTML scaffold for the link panel (URL input and new-tab checkbox). */ +export const LINK_ACTION_HTML = ` +<div id="${LINK_ACTION_ID}"> + <div id="tft-link-url" class="tft-link-input" role="textbox" aria-label="URL"></div> + <label class="tft-link-newwindow"> + <input class="tft-link-checkbox" type="checkbox" id="tft-link-new-window" /> + <span class="tft-link-newwindow-label">New tab</span> + </label> +</div> +`.trim(); diff --git a/apps/web/plugins/visual-editor/ui/text-format/size-action/size-action.js b/apps/web/plugins/visual-editor/ui/text-format/size-action/size-action.js new file mode 100644 index 0000000..a32932a --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/size-action/size-action.js @@ -0,0 +1,102 @@ +import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js'; +import { getToolbarTarget, stopFontSizeEdit } from '../toolbar/toolbar.js'; +import { wrapSelectionWithStyle } from '../style-actions.js'; +import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js'; +import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js'; +import { SIZE_ACTION_HTML } from './template.js'; +import { SIZE_ACTION_STYLES } from './styles.js'; + +let sizeActionElement = null; +let savedSizeRange = null; + +const outsideDismiss = createOutsideDismiss({ + getElement: () => sizeActionElement, + dismiss: () => { + stopFontSizeEdit(); + hideSizeAction(); + }, +}); + +function initSizeAction() { + if (sizeActionElement) return; + sizeActionElement = createPanelElement({ + styleId: 'text-format-size-action-styles', + styles: SIZE_ACTION_STYLES, + html: SIZE_ACTION_HTML, + }); + + sizeActionElement.addEventListener('mousedown', (event) => event.preventDefault()); + + sizeActionElement.addEventListener('click', (event) => { + const option = event.target.closest('.tft-size-option'); + if (!option) return; + applySizeAction(parseInt(option.dataset.size, 10)); + stopFontSizeEdit(); + hideSizeAction(); + }); +} + +export function positionSizeAction() { + if (!isPanelVisible(PanelId.SIZE) || !sizeActionElement) return; + positionDropdownPanel(sizeActionElement, { buttonAction: 'font-size' }); +} + +/** Saves the current selection range for a subsequent size apply. */ +export function saveSizeRange() { + const range = captureSelectionRange(); + if (range) savedSizeRange = range; +} + +/** + * Applies font size in pixels to the saved selection. + * @param {number} pixels + */ +export function applySizeAction(pixels) { + if (!savedSizeRange || !getToolbarTarget() || !(pixels > 0)) return; + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(savedSizeRange); + wrapSelectionWithStyle(getToolbarTarget(), 'fontSize', 'font-size', typeof pixels === 'number' ? `${pixels}px` : pixels); + onSelectionChange(); +} + +/** Opens the font size panel with the current size highlighted. */ +export function showSizeAction() { + if (!sizeActionElement) initSizeAction(); + + savedSizeRange = captureSelectionRange(); + + const styles = getStylesAtCursor(); + const currentSize = Math.round(parseFloat( + styles?.fontSize ?? getComputedStyle(getToolbarTarget()).fontSize + )); + sizeActionElement.querySelectorAll('.tft-size-option').forEach(option => { + option.classList.toggle('tsa-active', Number(option.dataset.size) === currentSize); + }); + + sizeActionElement.classList.add('tsa-visible'); + setPanelVisible(PanelId.SIZE, true); + positionSizeAction(); + + outsideDismiss.attach(); +} + +/** Closes the font size panel. */ +export function hideSizeAction() { + if (!sizeActionElement) return; + sizeActionElement.classList.remove('tsa-visible'); + setPanelVisible(PanelId.SIZE, false); + savedSizeRange = null; + outsideDismiss.detach(); +} + +/** Toggles the font size panel visibility. */ +export function toggleSizeAction() { + if (isPanelVisible(PanelId.SIZE)) { + hideSizeAction(); + } else { + showSizeAction(); + } +} + +registerPanel({ id: PanelId.SIZE, hide: hideSizeAction }); diff --git a/apps/web/plugins/visual-editor/ui/text-format/size-action/styles.js b/apps/web/plugins/visual-editor/ui/text-format/size-action/styles.js new file mode 100644 index 0000000..94695ae --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/size-action/styles.js @@ -0,0 +1,42 @@ +import { SIZE_ACTION_ID } from './template.js'; +import { PANEL_BG, BORDER_COLOR, HOVER_BG, ACTIVE_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js'; + +/** Injected CSS for the font size dropdown panel. */ +export const SIZE_ACTION_STYLES = ` +#${SIZE_ACTION_ID} { + position: fixed; + display: none; + flex-direction: column; + overflow-y: auto; + max-height: 240px; + background: ${PANEL_BG}; + border: 1px solid ${BORDER_COLOR}; + border-radius: 12px; + padding: 4px; + box-shadow: ${BOX_SHADOW_DROPDOWN}; + font-family: DM Sans, sans-serif; + user-select: none; + z-index: ${Z_INDEX_EDITOR_PANEL}; +} + +#${SIZE_ACTION_ID}.tsa-visible { + display: flex; +} + +.tft-size-option { + padding: 6px 12px; + color: ${COLOR_WHITE}; + font-size: 12px; + border-radius: 8px; + cursor: pointer; + white-space: nowrap; +} + +.tft-size-option:hover { + background: ${HOVER_BG}; +} + +.tft-size-option.tsa-active { + background: ${ACTIVE_BG}; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/size-action/template.js b/apps/web/plugins/visual-editor/ui/text-format/size-action/template.js new file mode 100644 index 0000000..db28702 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/size-action/template.js @@ -0,0 +1,12 @@ +/** DOM id for the font size action panel root element. */ +export const SIZE_ACTION_ID = 'text-format-size-action'; + +/** Preset font sizes (px) listed in the size dropdown. */ +export const TEXT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 80, 88, 96, 104, 120, 144]; + +/** HTML scaffold for the font size dropdown options. */ +export const SIZE_ACTION_HTML = ` +<div id="${SIZE_ACTION_ID}"> + ${TEXT_SIZES.map(size => `<div class="tft-size-option" data-size="${size}">${size}</div>`).join('')} +</div> +`.trim(); diff --git a/apps/web/plugins/visual-editor/ui/text-format/style-actions.js b/apps/web/plugins/visual-editor/ui/text-format/style-actions.js new file mode 100644 index 0000000..e44803d --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/style-actions.js @@ -0,0 +1,502 @@ +/** + * DOM range helpers for applying bold, italic, underline, and alignment inside + * the active contenteditable selection. + */ +import { + getTextNodesInRange, + isNodeBold, + isNodeItalic, + isNodeUnderlined, +} from '../../utils/inline-style-state.js'; + +function hasAncestorTag(textNode, tagNames, stopAt) { + let element = textNode.parentElement; + while (element && element !== stopAt) { + if (tagNames.includes(element.tagName.toLowerCase())) return true; + element = element.parentElement; + } + return false; +} + +function isFullyWrapped(range, tagNames, editTarget) { + if (range.collapsed) return false; + const nodes = getTextNodesInRange(range); + return nodes.length > 0 && nodes.every(node => hasAncestorTag(node, tagNames, editTarget)); +} + +/** + * Wraps a range in an element, with optional inline styles. + * @param {Range} range + * @param {string} tagName + * @param {object} [styleProperties] + * @returns {HTMLElement} + */ +export function wrapRange(range, tagName, styleProperties) { + const wrapper = document.createElement(tagName); + if (styleProperties) Object.assign(wrapper.style, styleProperties); + try { + range.surroundContents(wrapper); + } catch { + wrapper.appendChild(range.extractContents()); + range.insertNode(wrapper); + } + if (!styleProperties) { + wrapper.querySelectorAll(tagName).forEach(element => element.replaceWith(...element.childNodes)); + wrapper.normalize(); + } + return wrapper; +} + +function restoreSelectionOver(firstNode, lastNode) { + if (!firstNode || !lastNode) return; + try { + const newRange = document.createRange(); + if (firstNode.nodeType === Node.TEXT_NODE) { + newRange.setStart(firstNode, 0); + } else { + newRange.setStartBefore(firstNode); + } + if (lastNode.nodeType === Node.TEXT_NODE) { + newRange.setEnd(lastNode, lastNode.length); + } else { + newRange.setEndAfter(lastNode); + } + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(newRange); + } catch { /* ignore invalid range errors */ } +} + +function ancestorDepthWithin(element, editTarget) { + let depth = 0; + let current = element; + while (current && current !== editTarget) { + depth += 1; + current = current.parentElement; + } + return depth; +} + +/** + * Removes a single inline wrapper element over the portion covered by `range`, + * keeping the parts outside the range wrapped and preserving every descendant + * (e.g. a nested `<strong>`) of the in-range portion. Splitting the element when + * the selection only partially covers it. + * @param {HTMLElement} element + * @param {Range} range + */ +function unwrapElementOverRange(element, range) { + const elementContents = document.createRange(); + elementContents.selectNodeContents(element); + + const selectionStartsInside = range.compareBoundaryPoints(Range.START_TO_START, elementContents) > 0; + const selectionEndsInside = range.compareBoundaryPoints(Range.END_TO_END, elementContents) < 0; + + if (selectionEndsInside) { + const afterRange = document.createRange(); + afterRange.setStart(range.endContainer, range.endOffset); + afterRange.setEnd(elementContents.endContainer, elementContents.endOffset); + const afterContent = afterRange.extractContents(); + if (afterContent.textContent !== '') { + const afterClone = element.cloneNode(false); + afterClone.appendChild(afterContent); + element.after(afterClone); + } + } + + if (selectionStartsInside) { + const beforeRange = document.createRange(); + beforeRange.setStart(elementContents.startContainer, elementContents.startOffset); + beforeRange.setEnd(range.startContainer, range.startOffset); + const beforeContent = beforeRange.extractContents(); + if (beforeContent.textContent !== '') { + const beforeClone = element.cloneNode(false); + beforeClone.appendChild(beforeContent); + element.before(beforeClone); + } + } + + element.replaceWith(...element.childNodes); +} + +function unwrapTagsInRange(range, tagNames, editTarget) { + const tagSet = tagNames.map(tag => tag.toLowerCase()); + const textNodes = getTextNodesInRange(range); + const firstTextNode = textNodes[0] ?? null; + const lastTextNode = textNodes[textNodes.length - 1] ?? null; + + const targetElements = []; + for (const textNode of textNodes) { + let element = textNode.parentElement; + while (element && element !== editTarget) { + if (tagSet.includes(element.tagName?.toLowerCase()) && !targetElements.includes(element)) { + targetElements.push(element); + } + element = element.parentElement; + } + } + + // Unwrap the deepest wrappers first so re-parenting never shifts the + // boundaries of a shallower wrapper still waiting to be processed. + targetElements.sort((a, b) => ancestorDepthWithin(b, editTarget) - ancestorDepthWithin(a, editTarget)); + for (const element of targetElements) { + unwrapElementOverRange(element, range); + } + + restoreSelectionOver(firstTextNode, lastTextNode); + editTarget.normalize(); +} + +/** + * Returns the user's active text selection inside the edit target. + * When the caret is collapsed (no highlighted text), the entire element's + * contents are selected so that style actions apply to all text. + * @param {HTMLElement} editTarget + * @returns {Range|null} Selection range inside the edit target. + */ +export function getActiveRange(editTarget) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + + if (selection.isCollapsed) { + if (!editTarget.contains(selection.anchorNode)) return null; + const range = document.createRange(); + range.selectNodeContents(editTarget); + selection.removeAllRanges(); + selection.addRange(range); + return range; + } + + const range = selection.getRangeAt(0); + if (!editTarget.contains(range.commonAncestorContainer)) return null; + return range; +} + +/** Tailwind utility classes cleared when toggling bold off on an edit target. */ +const TAILWIND_BOLD_CLASSES = ['font-bold', 'font-semibold', 'font-extrabold', 'font-black']; + +/** Tailwind utility classes cleared when toggling italic off on an edit target. */ +const TAILWIND_ITALIC_CLASSES = ['italic']; + +function isFullyBold(range, editTarget) { + if (range.collapsed) return false; + const nodes = getTextNodesInRange(range); + return nodes.length > 0 && nodes.every((node) => isNodeBold(node, editTarget)); +} + +function isFullyItalic(range, editTarget) { + if (range.collapsed) return false; + const nodes = getTextNodesInRange(range); + return nodes.length > 0 && nodes.every((node) => isNodeItalic(node, editTarget)); +} + +/** + * Toggles bold on the current selection within the edit target. + * @param {HTMLElement} editTarget + */ +export function applyBold(editTarget) { + const range = getActiveRange(editTarget); + if (!range) return; + if (isFullyBold(range, editTarget)) { + unwrapTagsInRange(range, ['strong', 'b'], editTarget); + TAILWIND_BOLD_CLASSES.forEach(className => editTarget.classList.remove(className)); + if (editTarget.style.fontWeight) editTarget.style.removeProperty('font-weight'); + const selection = window.getSelection(); + if (selection?.rangeCount > 0 && !selection.isCollapsed) { + const selectionRange = selection.getRangeAt(0); + if (isFullyBold(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontWeight: 'normal' }); + } + } else { + wrapRange(range, 'strong'); + } + editTarget.dispatchEvent(new Event('input', { bubbles: true })); +} + +/** + * Toggles italic on the current selection within the edit target. + * @param {HTMLElement} editTarget + */ +export function applyItalic(editTarget) { + const range = getActiveRange(editTarget); + if (!range) return; + if (isFullyItalic(range, editTarget)) { + unwrapTagsInRange(range, ['em', 'i'], editTarget); + TAILWIND_ITALIC_CLASSES.forEach(className => editTarget.classList.remove(className)); + if (editTarget.style.fontStyle) editTarget.style.removeProperty('font-style'); + const selection = window.getSelection(); + if (selection?.rangeCount > 0 && !selection.isCollapsed) { + const selectionRange = selection.getRangeAt(0); + if (isFullyItalic(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontStyle: 'normal' }); + } + } else { + wrapRange(range, 'em'); + } + editTarget.dispatchEvent(new Event('input', { bubbles: true })); +} + +/** Tailwind utility classes cleared when toggling underline off on an edit target. */ +const TAILWIND_UNDERLINE_CLASSES = ['underline']; + +function isRangeFullyUnderlined(range, editTarget) { + if (range.collapsed) return false; + const nodes = getTextNodesInRange(range); + return nodes.length > 0 && nodes.every((node) => isNodeUnderlined(node, editTarget)); +} + +function removeExplicitUnderlineInRange(range, editTarget) { + getTextNodesInRange(range).forEach(node => { + let element = node.parentElement; + while (element && element !== editTarget) { + if (element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline')) { + element.style.removeProperty('text-decoration'); + element.style.removeProperty('text-decoration-line'); + } + element = element.parentElement; + } + }); +} + +function getContextualDecorationStyle(range) { + // When text sits between CSS-class underline siblings (after a split), inherit + // their full decoration style so re-applied underline visually matches. + const node = range.startContainer; + if (node.nodeType !== Node.TEXT_NODE) return null; + + for (const sibling of [node.previousSibling, node.nextSibling]) { + if (!sibling || sibling.nodeType !== Node.ELEMENT_NODE) continue; + const computedStyle = getComputedStyle(sibling); + const textDecorationLine = computedStyle.textDecorationLine || computedStyle.textDecoration || ''; + if (!textDecorationLine.includes('underline')) continue; + + const style = {}; + if (computedStyle.textDecorationColor) { + style.textDecorationColor = computedStyle.textDecorationColor; + } + if (computedStyle.textDecorationThickness && computedStyle.textDecorationThickness !== 'auto') { + style.textDecorationThickness = computedStyle.textDecorationThickness; + } + if (computedStyle.textDecorationStyle && computedStyle.textDecorationStyle !== 'solid') { + style.textDecorationStyle = computedStyle.textDecorationStyle; + } + return Object.keys(style).length > 0 ? style : null; + } + return null; +} + +function getEffectiveTextColor(range, editTarget) { + const nodes = getTextNodesInRange(range); + if (nodes.length === 0 && range.startContainer.nodeType === Node.TEXT_NODE) { + nodes.push(range.startContainer); + } + if (nodes.length === 0) return null; + + const containerColor = getComputedStyle(editTarget).color; + for (const textNode of nodes) { + let element = textNode.parentElement; + while (element && element !== editTarget) { + const inlineColor = element.style?.color; + if (inlineColor && inlineColor !== 'transparent') return inlineColor; + element = element.parentElement; + } + const parent = textNode.parentElement; + if (!parent) continue; + const computed = getComputedStyle(parent).color; + if (computed && computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)') { + return computed; + } + } + return null; +} + +function buildUnderlineWrapStyle(range, editTarget) { + const style = getContextualDecorationStyle(range) ?? {}; + const textColor = getEffectiveTextColor(range, editTarget); + if (textColor) { + style.textDecorationColor = textColor; + } + return Object.keys(style).length > 0 ? style : null; +} + +/** Keeps `<u>` decoration color in sync when nested text color changes. */ +export function syncUnderlineColorFromElement(element, editTarget) { + if (!element || !editTarget) return; + const inlineColor = element.style?.color; + const containerColor = getComputedStyle(editTarget).color; + const decorationColor = inlineColor + || (() => { + const computed = getComputedStyle(element).color; + return computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)' ? computed : null; + })(); + if (!decorationColor) return; + + let node = element.parentElement; + while (node && node !== editTarget) { + if (node.tagName?.toLowerCase() === 'u') { + node.style.textDecorationColor = decorationColor; + } + node = node.parentElement; + } +} + +function findCssUnderlineAncestor(node, editTarget) { + // Find the closest ancestor that sources underline via a CSS class, not a <u> tag + // or inline style — those are handled by other steps. + let element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; + while (element && element !== editTarget) { + if (element.tagName?.toLowerCase() === 'u') { element = element.parentElement; continue; } + const hasInline = element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline'); + if (hasInline) { element = element.parentElement; continue; } + const textDecorationLine = getComputedStyle(element).textDecorationLine || getComputedStyle(element).textDecoration || ''; + if (textDecorationLine.includes('underline')) return element; + element = element.parentElement; + } + return null; +} + +function splitElementAroundRange(range, ancestor, editTarget) { + const fragment = range.extractContents(); + const selectionFirst = fragment.firstChild; + const selectionLast = fragment.lastChild; + + const parent = ancestor.parentNode; + const nextSibling = ancestor.nextSibling; + + const afterRange = document.createRange(); + afterRange.setStart(range.startContainer, range.startOffset); + if (ancestor.lastChild) afterRange.setEndAfter(ancestor.lastChild); + else afterRange.collapse(true); + const afterContent = ancestor.lastChild ? afterRange.extractContents() : null; + + parent.insertBefore(fragment, nextSibling); + + if (afterContent?.textContent) { + const afterClone = ancestor.cloneNode(false); + afterClone.appendChild(afterContent); + parent.insertBefore(afterClone, nextSibling); + } + + if (!ancestor.textContent) ancestor.remove(); + + restoreSelectionOver(selectionFirst, selectionLast); + editTarget.normalize(); +} + +/** + * Toggles underline on the current selection within the edit target. + * @param {HTMLElement} editTarget + */ +export function applyUnderline(editTarget) { + const range = getActiveRange(editTarget); + if (!range) return; + + if (isRangeFullyUnderlined(range, editTarget)) { + if (getTextNodesInRange(range).some(node => hasAncestorTag(node, ['u'], editTarget))) { + unwrapTagsInRange(range, ['u'], editTarget); + } + let selection = window.getSelection(); + if (selection?.rangeCount > 0) removeExplicitUnderlineInRange(selection.getRangeAt(0), editTarget); + // Strip Tailwind underline classes from the editTarget itself + TAILWIND_UNDERLINE_CLASSES.forEach(className => editTarget.classList.remove(className)); + selection = window.getSelection(); + if (selection?.rangeCount > 0 && !selection.isCollapsed) { + const selectionRange = selection.getRangeAt(0); + if (isRangeFullyUnderlined(selectionRange, editTarget)) { + const textNodes = getTextNodesInRange(selectionRange); + const cssAncestor = textNodes.length > 0 ? findCssUnderlineAncestor(textNodes[0], editTarget) : null; + if (cssAncestor) splitElementAroundRange(selectionRange, cssAncestor, editTarget); + else wrapRange(selectionRange, 'span', { textDecoration: 'none' }); + } + } + } else { + const decorationStyle = buildUnderlineWrapStyle(range, editTarget); + wrapRange(range, 'u', decorationStyle ?? undefined); + } + + editTarget.dispatchEvent(new Event('input', { bubbles: true })); +} + +/** + * Finds the nearest ancestor `<span>` that already carries `styleProperty` and whose + * text content exactly matches the selection. Reusing it (instead of nesting a + * new inner span) keeps in-between elements like `<u>` at the same font-size as + * the text, so e.g. underline thickness stays proportional. + * @param {Range} range + * @param {HTMLElement} editTarget + * @param {string} styleProperty + * @returns {HTMLElement|null} + */ +function findStyledAncestorWrappingRange(range, editTarget, styleProperty) { + let element = range.commonAncestorContainer; + if (element.nodeType === Node.TEXT_NODE) element = element.parentElement; + const selectionText = range.toString(); + if (!selectionText) return null; + let match = null; + while (element && element !== editTarget) { + if (element.nodeType === Node.ELEMENT_NODE && element.tagName === 'SPAN' && element.style[styleProperty]) { + const elementRange = document.createRange(); + elementRange.selectNodeContents(element); + if (elementRange.toString() === selectionText) match = element; + } + element = element.parentElement; + } + return match; +} + +/** + * Wraps the active selection in a span carrying one inline style prop, + * stripping the same prop from descendants (and a redundant single-child + * parent span) so values never nest. + * @param {HTMLElement} editTarget + * @param {string} styleProperty - camelCase property (e.g. 'fontSize'). + * @param {string} cssProperty - kebab-case property (e.g. 'font-size'). + * @param {string} value + */ +export function wrapSelectionWithStyle(editTarget, styleProperty, cssProperty, value) { + const range = getActiveRange(editTarget); + if (!range) return; + const reuse = findStyledAncestorWrappingRange(range, editTarget, styleProperty); + if (reuse) { + reuse.style[styleProperty] = value; + if (styleProperty === 'fontSize') reuse.style.lineHeight = 'normal'; + reuse.querySelectorAll('*').forEach(element => { + if (element.style[styleProperty]) { + element.style.removeProperty(cssProperty); + if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes); + } + }); + reuse.normalize(); + editTarget.dispatchEvent(new Event('input', { bubbles: true })); + return; + } + + const wrapperStyle = { [styleProperty]: value }; + if (styleProperty === 'fontSize') wrapperStyle.lineHeight = 'normal'; + const wrapper = wrapRange(range, 'span', wrapperStyle); + if (wrapper) { + wrapper.querySelectorAll('*').forEach(element => { + if (element.style[styleProperty]) { + element.style.removeProperty(cssProperty); + if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes); + } + }); + const parentElement = wrapper.parentElement; + if (parentElement && parentElement !== editTarget && parentElement.tagName === 'SPAN' && parentElement.style[styleProperty] && parentElement.childNodes.length === 1) { + parentElement.style.removeProperty(cssProperty); + if (!parentElement.style.cssText.trim()) parentElement.replaceWith(...parentElement.childNodes); + } + wrapper.normalize(); + } + editTarget.dispatchEvent(new Event('input', { bubbles: true })); +} + +/** + * Sets text-align on the edit target element. + * @param {HTMLElement} editTarget + * @param {'left'|'center'|'right'} alignment + */ +export function applyAlign(editTarget, alignment) { + const current = getComputedStyle(editTarget).textAlign; + if (current === alignment || (alignment === 'left' && (current === 'start' || current === 'left'))) return; + editTarget.style.textAlign = alignment; + editTarget.dispatchEvent(new Event('input', { bubbles: true })); +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/toolbar/positioning.js b/apps/web/plugins/visual-editor/ui/text-format/toolbar/positioning.js new file mode 100644 index 0000000..c29e7bb --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/toolbar/positioning.js @@ -0,0 +1,108 @@ +import { PARENT_TOOLBAR_HEIGHT } from '../../../constants/layout.js'; +import { isInFixedContext } from '../../../utils/dom-utils.js'; + +let _toolbarElement = null; +let _toolbarTarget = null; +let dragOffset = { x: 0, y: 0 }; +let isDragging = false; +let dragStart = { x: 0, y: 0 }; +let toolbarSide = 'below'; + +/** @param {HTMLElement|null} element */ +export function setPositioningEl(element) { _toolbarElement = element; } +/** @param {HTMLElement|null} element */ +export function setPositioningTarget(element) { _toolbarTarget = element; } +/** Resets user drag offset to zero. */ +export function resetDragOffset() { dragOffset = { x: 0, y: 0 }; } + +/** + * Chooses above vs below placement based on viewport space. + * @returns {'above'|'below'} + */ +export function lockToolbarSide() { + const targetBounds = _toolbarTarget.getBoundingClientRect(); + const toolbarHeight = _toolbarElement.offsetHeight; + const GAP = 10; + const fitsBelow = window.innerHeight - PARENT_TOOLBAR_HEIGHT - targetBounds.bottom >= 88; + const fitsAbove = targetBounds.top - toolbarHeight - GAP >= 0; + toolbarSide = (!fitsBelow && fitsAbove) ? 'above' : 'below'; + return toolbarSide; +} + +/** + * @returns {boolean} True when the toolbar is anchored to a viewport-fixed + * element (fixed/sticky ancestor) and must therefore use `position: fixed`. + * Document-flow elements use `position: absolute` so the toolbar scrolls with + * the page natively (no per-scroll repositioning, no jitter). + */ +export function isToolbarFixedToViewport() { + return !!_toolbarTarget && isInFixedContext(_toolbarTarget); +} + +/** Applies locked side and drag offset to position the toolbar. */ +export function positionToolbar() { + if (!_toolbarTarget || !_toolbarElement) return; + const targetBounds = _toolbarTarget.getBoundingClientRect(); + const toolbarWidth = _toolbarElement.offsetWidth; + const toolbarHeight = _toolbarElement.offsetHeight; + const GAP = 10; + const MARGIN = 8; + + // Anchor in document coordinates (position:absolute) so the toolbar scrolls + // together with the element natively. Elements inside fixed/sticky ancestors + // stay in the viewport while the page scrolls, so they keep position:fixed. + const fixed = isInFixedContext(_toolbarTarget); + _toolbarElement.style.position = fixed ? 'fixed' : 'absolute'; + const scrollX = fixed ? 0 : window.scrollX; + const scrollY = fixed ? 0 : window.scrollY; + + // Use the side locked when the toolbar was shown — don't re-evaluate on scroll. + const baseTop = (toolbarSide === 'above' + ? targetBounds.top - toolbarHeight - GAP + : targetBounds.bottom + GAP) + scrollY; + + const baseLeft = Math.min(targetBounds.left - 4, window.innerWidth - toolbarWidth - MARGIN) + scrollX; + + let left = baseLeft + dragOffset.x; + let top = baseTop + dragOffset.y; + + if (isDragging) { + left = Math.max(MARGIN + scrollX, Math.min(left, scrollX + window.innerWidth - toolbarWidth - MARGIN)); + top = Math.max(MARGIN + scrollY, Math.min(top, scrollY + window.innerHeight - PARENT_TOOLBAR_HEIGHT - toolbarHeight - MARGIN)); + } + + _toolbarElement.style.left = `${left}px`; + _toolbarElement.style.top = `${top}px`; +} + +function onDragMove(event) { + if (!isDragging) return; + dragOffset.x = event.clientX - dragStart.x; + dragOffset.y = event.clientY - dragStart.y; + positionToolbar(); +} + +function onDragEnd() { + isDragging = false; + _toolbarElement?.classList.remove('tft-dragging'); + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('mouseup', onDragEnd); +} + +/** + * Begins toolbar drag tracking. + * @param {MouseEvent} event + */ +export function startDrag(event) { + isDragging = true; + dragStart = { x: event.clientX - dragOffset.x, y: event.clientY - dragOffset.y }; + _toolbarElement.classList.add('tft-dragging'); + document.addEventListener('mousemove', onDragMove); + document.addEventListener('mouseup', onDragEnd); +} + +/** Repositions the toolbar on scroll and closes the more-formatting panel. */ +export function onToolbarScroll() { + _toolbarElement.querySelector('.tft-more-panel')?.classList.remove('tft-open'); + positionToolbar(); +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/toolbar/styles.js b/apps/web/plugins/visual-editor/ui/text-format/toolbar/styles.js new file mode 100644 index 0000000..05f980f --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/toolbar/styles.js @@ -0,0 +1,211 @@ +import { TOOLBAR_ID } from './template.js'; +import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_TRANSLUCENT, HOVER_BG, ACTIVE_BG, BOX_SHADOW_TOOLBAR, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js'; + +/** Injected CSS for the floating text-format toolbar. */ +export const TOOLBAR_STYLES = ` +#${TOOLBAR_ID} { + position: fixed; + display: none; + align-items: center; + padding: 4px; + background: ${PANEL_BG}; + border: 1px solid ${BORDER_COLOR}; + border-radius: 12px; + box-shadow: ${BOX_SHADOW_TOOLBAR}; + user-select: none; + color: ${COLOR_WHITE}; + font-family: DM Sans, sans-serif; + white-space: nowrap; + cursor: grab; + z-index: ${Z_INDEX_EDITOR_PANEL}; + /* Never exceed the viewport — the inner row scrolls horizontally instead. */ + max-width: calc(100vw - 16px); +} +#${TOOLBAR_ID}.tft-visible { display: flex; } +#${TOOLBAR_ID}.tft-dragging { cursor: grabbing; } + +/* Horizontally scrollable control row. The more-panel is a sibling (not inside + this container) so it is never clipped by the scroll overflow. */ +.tft-scroll { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + max-width: 100%; + overflow-x: auto; + white-space: nowrap; + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-overflow-scrolling: touch; +} +.tft-scroll::-webkit-scrollbar { display: none; } + +.tft-text-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 6px 12px; + background: transparent; + border: none; + border-radius: 8px; + color: ${COLOR_WHITE}; + font-family: DM Sans, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 20px; + cursor: pointer; + flex-shrink: 0; +} +.tft-text-btn:hover { background: ${HOVER_BG}; } +.tft-text-btn svg { flex-shrink: 0; } + +.tft-swatch { + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + background: transparent; + border: none; + border-radius: 8px; + cursor: pointer; + flex-shrink: 0; +} +.tft-swatch:hover { background: ${HOVER_BG}; } + +.tft-swatch-preview { + width: 20px; + height: 20px; + border-radius: 6px; + background: ${COLOR_WHITE}; + border: 1px solid ${BORDER_COLOR_TRANSLUCENT}; + pointer-events: none; +} + +.tft-divider { + width: 1px; + height: 24px; + background: ${COLOR_WHITE}26; + flex-shrink: 0; +} + +.tft-select { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 8px; + background: transparent; + border: none; + border-radius: 8px; + color: ${COLOR_WHITE}; + font-size: 14px; + font-weight: 400; + line-height: 20px; + min-width: 32px; + max-width: 120px; + cursor: pointer; + white-space: nowrap; +} +.tft-select:hover { background: ${HOVER_BG}; } +.tft-select--size { width: 64px; } + +/* Fills the button so long font names ellipsis-truncate instead of widening it. */ +.tft-label { + flex: 1 0 0; + min-width: 0; + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tft-font-size { + color: ${COLOR_WHITE}; + font-size: 14px; + font-weight: 400; + line-height: 20px; + font-family: DM Sans, sans-serif; + cursor: text; +} +.tft-font-size--editing::after { + content: ''; + display: inline-block; + width: 1px; + height: 0.9em; + background: ${COLOR_WHITE}; + margin-left: 1px; + vertical-align: text-bottom; + animation: tft-cursor-blink 1s step-end infinite; +} + +@keyframes tft-cursor-blink { + 50% { opacity: 0; } +} + +.tft-btn-group { + display: flex; + gap: 2px; +} + +.tft-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: transparent; + border: none; + border-radius: 8px; + color: #9ca3af; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background 0.15s, color 0.15s; + flex-shrink: 0; +} + +.tft-btn:hover { + background: ${ACTIVE_BG}; + color: ${COLOR_WHITE}; +} + +.tft-btn.tft-active { + background: ${ACTIVE_BG}; + color: ${COLOR_WHITE}; +} + +.tft-btn--italic { + font-style: italic; + font-weight: 400; + font-size: 15px; +} + +.tft-btn--more { display: none; } + +@media (pointer: coarse) { + .tft-btn-group .tft-btn[data-action="bold"], + .tft-btn-group .tft-btn[data-action="italic"], + .tft-btn-group .tft-btn[data-action="underline"], + .tft-btn-group .tft-btn[data-action="align"] { display: none; } + .tft-btn--more { display: flex; } +} + +.tft-more-panel { + display: none; + position: absolute; + top: calc(100% + 8px); + right: 0; + align-items: center; + gap: 2px; + padding: 4px; + background: ${PANEL_BG}; + border: 1px solid ${BORDER_COLOR}; + border-radius: 12px; + box-shadow: ${BOX_SHADOW_TOOLBAR}; +} + +.tft-more-panel.tft-open { + display: flex; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/toolbar/template.js b/apps/web/plugins/visual-editor/ui/text-format/toolbar/template.js new file mode 100644 index 0000000..9207342 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/toolbar/template.js @@ -0,0 +1,52 @@ +import { ICON_CHEVRON_DOWN, ICON_BOLD, ICON_ITALIC, ICON_UNDERLINE, ICON_ALIGN_LEFT, ICON_MORE, ICON_LINK, ICON_SPARKLES } from "../../../constants/icons.js"; + +/** DOM id for the floating text-format toolbar root element. */ +export const TOOLBAR_ID = 'text-format-toolbar'; + +/** HTML scaffold for the inline text-format toolbar buttons. */ +export const TOOLBAR_HTML = ` +<div id="${TOOLBAR_ID}"> + <div class="tft-scroll"> + <button class="tft-text-btn" data-action="edit-with-ai" title="Edit with AI"> + ${ICON_SPARKLES} + <span class="tft-text-btn-label">Edit with AI</span> + </button> + <span class="tft-divider"></span> + <button class="tft-swatch" data-action="color" title="Text color"> + <span class="tft-swatch-preview"></span> + </button> + <span class="tft-divider"></span> + <button class="tft-select" data-action="font-family" title="Font family"> + <span class="tft-label tft-font-family">Inter</span> + ${ICON_CHEVRON_DOWN} + </button> + <span class="tft-divider"></span> + <button class="tft-select tft-select--size" data-action="font-size" title="Font size"> + <span class="tft-label tft-font-size">16</span> + ${ICON_CHEVRON_DOWN} + </button> + <span class="tft-divider"></span> + <div class="tft-btn-group" role="group" aria-label="Text formatting options"> + <button class="tft-btn" data-action="bold" title="Bold">${ICON_BOLD}</button> + <button class="tft-btn tft-btn--italic" data-action="italic" title="Italic">${ICON_ITALIC}</button> + <button class="tft-btn" data-action="underline" title="Underline">${ICON_UNDERLINE}</button> + <button class="tft-btn" data-action="align" title="Text alignment">${ICON_ALIGN_LEFT}</button> + <button class="tft-btn tft-btn--more" data-action="more" title="More formatting">${ICON_MORE}</button> + <button class="tft-btn" data-action="link" title="Link">${ICON_LINK}</button> + </div> + </div> + <div class="tft-more-panel"> + <button class="tft-btn" data-action="bold" title="Bold">${ICON_BOLD}</button> + <button class="tft-btn tft-btn--italic" data-action="italic" title="Italic">${ICON_ITALIC}</button> + <button class="tft-btn" data-action="underline" title="Underline">${ICON_UNDERLINE}</button> + <button class="tft-btn" data-action="align" title="Text alignment">${ICON_ALIGN_LEFT}</button> + </div> +</div> +`.trim(); + +/** SVG icons for left, center, and right text alignment toolbar states. */ +export const ALIGN_ICONS = { + left: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10.5771 11.2539C10.9551 11.2925 11.25 11.6118 11.25 12C11.25 12.3882 10.9551 12.7075 10.5771 12.7461L10.5 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H10.5L10.5771 11.2539Z" fill="white"/><path d="M7.5 7.25C7.91421 7.25 8.25 7.58579 8.25 8C8.25 8.41421 7.91421 8.75 7.5 8.75H3.5C3.08579 8.75 2.75 8.41421 2.75 8C2.75 7.58579 3.08579 7.25 3.5 7.25H7.5Z" fill="white"/><path d="M13.5 3.25C13.9142 3.25007 14.25 3.58583 14.25 4C14.25 4.41417 13.9142 4.74993 13.5 4.75H3.5C3.08579 4.75 2.75 4.41421 2.75 4C2.75 3.58579 3.08579 3.25 3.5 3.25H13.5Z" fill="white"/></svg>`, + center: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5771 11.2539C11.9551 11.2925 12.25 11.6118 12.25 12C12.25 12.3882 11.9551 12.7075 11.5771 12.7461L11.5 12.75H4.5C4.08579 12.75 3.75 12.4142 3.75 12C3.75 11.5858 4.08579 11.25 4.5 11.25H11.5L11.5771 11.2539Z" fill="white"/><path d="M10.0771 7.25391C10.4551 7.29253 10.75 7.61183 10.75 8C10.75 8.38817 10.4551 8.70747 10.0771 8.74609L10 8.75H6C5.58579 8.75 5.25 8.41421 5.25 8C5.25 7.58579 5.58579 7.25 6 7.25H10L10.0771 7.25391Z" fill="white"/><path d="M13 3.25C13.4142 3.25 13.75 3.58579 13.75 4C13.75 4.41421 13.4142 4.75 13 4.75H3C2.58579 4.75 2.25 4.41421 2.25 4C2.25 3.58579 2.58579 3.25 3 3.25H13Z" fill="white"/></svg>`, + right: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12.5 11.25C12.9142 11.25 13.25 11.5858 13.25 12C13.25 12.4142 12.9142 12.75 12.5 12.75H5.5L5.42285 12.7461C5.04488 12.7075 4.75 12.3882 4.75 12C4.75 11.6118 5.04488 11.2925 5.42285 11.2539L5.5 11.25H12.5Z" fill="white"/><path d="M12.5 7.25C12.9142 7.25 13.25 7.58579 13.25 8C13.25 8.41421 12.9142 8.75 12.5 8.75H8.5C8.08579 8.75 7.75 8.41421 7.75 8C7.75 7.58579 8.08579 7.25 8.5 7.25H12.5Z" fill="white"/><path d="M12.5 3.25C12.9142 3.25 13.25 3.58579 13.25 4C13.25 4.41421 12.9142 4.75 12.5 4.75H2.5C2.08579 4.75 1.75 4.41421 1.75 4C1.75 3.58579 2.08579 3.25 2.5 3.25H12.5Z" fill="white"/></svg>`, +}; diff --git a/apps/web/plugins/visual-editor/ui/text-format/toolbar/toolbar.js b/apps/web/plugins/visual-editor/ui/text-format/toolbar/toolbar.js new file mode 100644 index 0000000..5f7c000 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/toolbar/toolbar.js @@ -0,0 +1,357 @@ +import { isolateEditorUiEvents } from '../../../utils/dom-utils.js'; +import { hideLinkAction, toggleLinkAction, positionLinkAction } from '../link-action/link-action.js'; +import { hideFontAction, toggleFontAction, positionFontAction } from '../font-family-action/font-family-action.js'; +import { isPanelVisible, PanelId } from '../../../state/panel-state.js'; +import { hideSizeAction, showSizeAction, toggleSizeAction, saveSizeRange, applySizeAction, positionSizeAction } from '../size-action/size-action.js'; +import { hideColorAction, toggleColorAction, positionColorAction } from '../color-action/color-action.js'; +import { applyBold, applyItalic, applyUnderline, applyAlign } from '../style-actions.js'; +import { onSelectionChange } from '../../inline-edit/cursor-styles.js'; +import { placeCursorAtEnd } from '../../inline-edit/edit-action.js'; +import { TOOLBAR_HTML, ALIGN_ICONS } from './template.js'; +import { TOOLBAR_STYLES } from './styles.js'; +import { + setPositioningEl, setPositioningTarget, resetDragOffset, + lockToolbarSide, positionToolbar, startDrag, isToolbarFixedToViewport, +} from './positioning.js'; +import { openOrCreateAnnotation, getAnnotationPanelEl, hideAnnotationPanel } from '../../annotation-panel/panel.js'; +import { getEditorTranslations } from '../../../state/annotation-state.js'; +import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT, ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT } from '../../../constants/layout.js'; + +let toolbarElement = null; +let toolbarTarget = null; +let toolbarResizeObserver = null; +let toolbarMutationObserver = null; +let windowResizeListener = null; +let repositionAnimationFrameId = null; + +let fontSizeEditing = false; +let fontSizeOriginal = ''; + +function onFontSizeKeydown(event) { + const span = toolbarElement?.querySelector('.tft-font-size'); + if (!span) return; + if (/^\d$/.test(event.key)) { + event.preventDefault(); event.stopPropagation(); + if (span.textContent.length < 3) span.textContent += event.key; + } else if (event.key === 'Backspace') { + event.preventDefault(); event.stopPropagation(); + span.textContent = span.textContent.slice(0, -1); + } else if (event.key === 'Enter') { + event.preventDefault(); event.stopPropagation(); + applySizeAction(parseInt(span.textContent, 10)); + stopFontSizeEdit(); + hideSizeAction(); + } else if (event.key === 'Escape') { + event.preventDefault(); event.stopPropagation(); + span.textContent = fontSizeOriginal; + stopFontSizeEdit(); + hideSizeAction(); + } +} + +function startFontSizeEdit() { + if (fontSizeEditing) return; + const span = toolbarElement?.querySelector('.tft-font-size'); + if (!span) return; + fontSizeEditing = true; + fontSizeOriginal = span.textContent; + span.textContent = ''; + span.classList.add('tft-font-size--editing'); + saveSizeRange(); + document.addEventListener('keydown', onFontSizeKeydown, true); +} + +/** Ends inline font-size keyboard editing on the toolbar label. */ +export function stopFontSizeEdit() { + if (!fontSizeEditing) return; + fontSizeEditing = false; + const span = toolbarElement?.querySelector('.tft-font-size'); + if (span) { + span.classList.remove('tft-font-size--editing'); + if (!span.textContent) span.textContent = fontSizeOriginal; + } + document.removeEventListener('keydown', onFontSizeKeydown, true); +} + +/** @returns {HTMLElement|null} */ +export function getToolbarEl() { return toolbarElement; } +/** @returns {HTMLElement|null} The element currently being formatted. */ +export function getToolbarTarget() { return toolbarTarget; } + +function restoreSelectionAndCloseLinkPanel() { + if (!isPanelVisible(PanelId.LINK)) return; + hideLinkAction(); +} + +function repositionAttachedPanels() { + positionColorAction(); + positionFontAction(); + positionSizeAction(); + positionLinkAction(); + repositionAnnotationToToolbar(); +} + +function repositionAnnotationToToolbar() { + const panel = getAnnotationPanelEl(); + if (!panel?.classList.contains('active') || !toolbarElement) return; + if (panel.style.position === 'absolute') return; + const rect = toolbarElement.getBoundingClientRect(); + + let left = rect.left; + let top = rect.bottom + PANEL_GAP; + + if (left + ANNOTATION_PANEL_WIDTH > window.innerWidth - PANEL_MARGIN) { + left = window.innerWidth - ANNOTATION_PANEL_WIDTH - PANEL_MARGIN; + } + if (left < PANEL_MARGIN) left = PANEL_MARGIN; + if (top + ANNOTATION_PANEL_ESTIMATED_HEIGHT > window.innerHeight - PARENT_TOOLBAR_HEIGHT - PANEL_MARGIN) { + top = rect.top - ANNOTATION_PANEL_ESTIMATED_HEIGHT - PANEL_GAP; + } + if (top < PANEL_MARGIN) top = PANEL_MARGIN; + + panel.style.left = `${left}px`; + panel.style.top = `${top}px`; +} + +function repositionEditOverlays() { + if (!toolbarTarget || !toolbarElement?.classList.contains('tft-visible')) return; + positionToolbar(); + repositionAttachedPanels(); +} + +/** Repositions toolbar and open sub-panels after the edit target changes size. */ +export function scheduleRepositionEditOverlays() { + if (repositionAnimationFrameId) cancelAnimationFrame(repositionAnimationFrameId); + repositionAnimationFrameId = requestAnimationFrame(() => { + repositionAnimationFrameId = null; + repositionEditOverlays(); + }); +} + +function observeToolbarTarget() { + disconnectToolbarTargetObserver(); + if (!toolbarTarget) return; + + if (typeof ResizeObserver !== 'undefined') { + toolbarResizeObserver = new ResizeObserver(scheduleRepositionEditOverlays); + toolbarResizeObserver.observe(toolbarTarget); + } + + if (typeof MutationObserver !== 'undefined') { + toolbarMutationObserver = new MutationObserver(scheduleRepositionEditOverlays); + toolbarMutationObserver.observe(toolbarTarget, { + childList: true, + subtree: true, + characterData: true, + }); + } + + windowResizeListener = () => scheduleRepositionEditOverlays(); + window.addEventListener('resize', windowResizeListener, { passive: true }); +} + +function disconnectToolbarTargetObserver() { + toolbarResizeObserver?.disconnect(); + toolbarResizeObserver = null; + toolbarMutationObserver?.disconnect(); + toolbarMutationObserver = null; + if (windowResizeListener) { + window.removeEventListener('resize', windowResizeListener); + windowResizeListener = null; + } + if (repositionAnimationFrameId) { + cancelAnimationFrame(repositionAnimationFrameId); + repositionAnimationFrameId = null; + } +} + +function onToolbarScroll() { + toolbarElement?.querySelector('.tft-more-panel')?.classList.remove('tft-open'); + + if (isToolbarFixedToViewport()) { + // The element lives in a fixed/sticky ancestor, so it stays in the + // viewport while the page scrolls; keep the viewport-anchored toolbar + // and its panels aligned to it. + repositionEditOverlays(); + return; + } + + // Document-flow element: the absolutely-positioned toolbar scrolls with the + // page natively (no reposition needed, so no bounce). Only realign open + // sub-panels, which are viewport-fixed and anchored to the toolbar. + repositionAttachedPanels(); +} + +function initToolbar() { + if (toolbarElement) return; + + const style = document.createElement('style'); + style.id = 'text-format-toolbar-styles'; + style.textContent = TOOLBAR_STYLES; + document.head.appendChild(style); + + const wrapper = document.createElement('div'); + wrapper.innerHTML = TOOLBAR_HTML; + toolbarElement = wrapper.firstElementChild; + document.body.appendChild(toolbarElement); + isolateEditorUiEvents(toolbarElement); + + const editWithAiLabel = getEditorTranslations().toolbarEditWithAi; + if (editWithAiLabel) { + const labelSpan = toolbarElement.querySelector('[data-action="edit-with-ai"] .tft-text-btn-label'); + if (labelSpan) labelSpan.textContent = editWithAiLabel; + } + + setPositioningEl(toolbarElement); + + toolbarElement.addEventListener('mousedown', (event) => { + event.preventDefault(); + if (event.target.closest('[data-action]')) return; + hideFontAction(); + hideLinkAction(); + hideSizeAction(); + hideColorAction(true); + startDrag(event); + }); + + toolbarElement.addEventListener('click', (event) => { + const button = event.target.closest('[data-action]'); + if (!button || !toolbarTarget) return; + + const action = button.dataset.action; + const opensDropdownPanel = action === 'color' || action === 'font-family' || action === 'font-size' || action === 'link'; + + if (action !== 'font-family') hideFontAction(); + if (action !== 'font-size') { hideSizeAction(); stopFontSizeEdit(); } + if (action !== 'color') hideColorAction(); + if (action !== 'link') restoreSelectionAndCloseLinkPanel(); + if (action !== 'edit-with-ai') { + const annotationWasOpen = getAnnotationPanelEl()?.classList.contains('active'); + // Skip the focus/caret restore when opening another dropdown panel — + // it fires a selectionchange event that would immediately close the + // panel this same click is about to open. + hideAnnotationPanel({ restoreFocus: !opensDropdownPanel }); + if (annotationWasOpen && toolbarTarget && !opensDropdownPanel) { + toolbarTarget.focus(); + placeCursorAtEnd(toolbarTarget); + } + } + + switch (action) { + case 'color': toggleColorAction(); break; + case 'font-family': toggleFontAction(); break; + case 'more': + toolbarElement.querySelector('.tft-more-panel')?.classList.toggle('tft-open'); + break; + case 'font-size': + if (event.target.closest('.tft-font-size')) { + showSizeAction(); + startFontSizeEdit(); + } else { + stopFontSizeEdit(); + toggleSizeAction(); + } + break; + case 'bold': applyBold(toolbarTarget); onSelectionChange(true); break; + case 'italic': applyItalic(toolbarTarget); onSelectionChange(true); break; + case 'underline': applyUnderline(toolbarTarget); onSelectionChange(true); break; + case 'align': { + const current = getComputedStyle(toolbarTarget).textAlign; + const next = current === 'left' || current === 'start' ? 'center' + : current === 'center' ? 'right' + : 'left'; + applyAlign(toolbarTarget, next); + onSelectionChange(true); + break; + } + case 'link': toggleLinkAction(); break; + case 'edit-with-ai': { + const target = toolbarTarget; + if (!target) break; + + if (getAnnotationPanelEl()?.classList.contains('active')) { + hideAnnotationPanel(); + break; + } + + const toolbarRect = toolbarElement.getBoundingClientRect(); + openOrCreateAnnotation(target, toolbarRect.left, toolbarRect.bottom, toolbarRect.top); + break; + } + } + }); +} + +/** + * Shows and positions the toolbar for the given edit target. + * @param {HTMLElement} targetElement + */ +export function showTextFormatToolbar(targetElement) { + if (!toolbarElement) initToolbar(); + else if (!toolbarElement.isConnected) document.body.appendChild(toolbarElement); + toolbarTarget = targetElement; + setPositioningTarget(targetElement); + resetDragOffset(); + toolbarElement.classList.add('tft-visible'); + lockToolbarSide(); + positionToolbar(); + observeToolbarTarget(); + document.addEventListener('scroll', onToolbarScroll, true); +} + +/** Hides the toolbar and all sub-panels. */ +export function hideTextFormatToolbar() { + if (!toolbarElement) return; + toolbarElement.classList.remove('tft-visible'); + toolbarElement.querySelector('.tft-more-panel')?.classList.remove('tft-open'); + hideFontAction(); + hideLinkAction(); + hideSizeAction(); + hideColorAction(true); + stopFontSizeEdit(); + toolbarTarget = null; + setPositioningTarget(null); + disconnectToolbarTargetObserver(); + document.removeEventListener('scroll', onToolbarScroll, true); +} + +/** + * Syncs toolbar controls to styles reported at the caret. + * @param {object} styles - From getStylesAtCursor(). + */ +export function updateTextFormatToolbar(styles) { + if (!toolbarElement?.classList.contains('tft-visible')) return; + + const swatch = toolbarElement.querySelector('.tft-swatch-preview'); + if (swatch) swatch.style.background = styles.color; + + const fontFamilyLabel = toolbarElement.querySelector('.tft-font-family'); + if (fontFamilyLabel) { + fontFamilyLabel.textContent = styles.fontFamily + .split(',')[0] + .replace(/['"]/g, '') + .trim(); + } + + const fontSizeLabel = toolbarElement.querySelector('.tft-font-size'); + if (fontSizeLabel && !fontSizeEditing) { + fontSizeLabel.textContent = String(Math.round(parseFloat(styles.fontSize))); + } + + const isBold = !!styles.isBold; + const isItalic = !!styles.isItalic; + const isUnderline = !!styles.isUnderline; + const align = styles.textAlign === 'center' ? 'center' + : styles.textAlign === 'right' ? 'right' + : 'left'; + + toolbarElement.querySelectorAll('[data-action="bold"]').forEach(button => + button.classList.toggle('tft-active', isBold)); + toolbarElement.querySelectorAll('[data-action="italic"]').forEach(button => + button.classList.toggle('tft-active', isItalic)); + toolbarElement.querySelectorAll('[data-action="underline"]').forEach(button => + button.classList.toggle('tft-active', isUnderline)); + toolbarElement.querySelectorAll('[data-action="align"]').forEach(button => { + button.innerHTML = ALIGN_ICONS[align]; + }); +} diff --git a/apps/web/plugins/visual-editor/utils/dom-utils.js b/apps/web/plugins/visual-editor/utils/dom-utils.js new file mode 100644 index 0000000..90f8bfd --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/dom-utils.js @@ -0,0 +1,69 @@ +import { FIXED_CONTEXT_SELECTOR, ELEMENT_TYPE_MAP } from "../constants/selectors.js"; + +/** + * Logical type of an element for the hover tooltip: `text`, `button`, `image`, + * or `block` otherwise. Edit targets (`data-edit-id` / `data-edit-assisted-id`) + * that are not button/image tags are `text`. + * @param {HTMLElement} element + * @returns {string} + */ +export function getElementType(element) { + const tag = element?.tagName?.toLowerCase(); + const hasEditId = element?.hasAttribute?.('data-edit-id') || element?.hasAttribute?.('data-edit-assisted-id'); + + for (const [type, tags] of Object.entries(ELEMENT_TYPE_MAP)) { + if (tags.some(candidate => candidate.toLowerCase() === tag)) { + if (!hasEditId && type === 'text') return 'block'; + return type; + } + } + + if (hasEditId) return 'text'; + + return 'block'; +} + + +/** + * The app's mount point, which roots every structural walk so the editor's own + * body-level surfaces (panels, markers, outlines) are never traversed. + * @returns {HTMLElement} + */ +export function getAppRoot() { + return document.getElementById('root') || document.body; +} + + +/** + * True when the element sits inside a fixed/sticky ancestor, meaning overlays + * must use viewport (fixed) positioning instead of document coordinates. + * @param {HTMLElement} element + * @returns {boolean} + */ +export function isInFixedContext(element) { + return !!element.closest(FIXED_CONTEXT_SELECTOR); +} + +/** + * Host-page modal dialogs that are currently open and visible. Mounted-but-closed + * dialogs are skipped so a dismissed modal never keeps blocking the page. + * @returns {HTMLElement[]} + */ +export function getOpenModals() { + return Array.from(document.querySelectorAll('[role="dialog"]')).filter( + (modal) => modal.getAttribute('data-state') !== 'closed' && modal.checkVisibility?.() !== false, + ); +} + +const HOST_ISOLATED_EVENTS = ['pointerdown', 'pointerup', 'keydown', 'keyup']; + +/** + * Prevents host-page libraries (Radix DismissableLayer, FocusScope, etc.) from + * seeing pointer and keyboard events that originate inside the editor's floating UI. + * @param {HTMLElement} element + */ +export function isolateEditorUiEvents(element) { + for (const eventName of HOST_ISOLATED_EVENTS) { + element.addEventListener(eventName, (event) => event.stopPropagation()); + } +} diff --git a/apps/web/plugins/visual-editor/utils/html-utils.js b/apps/web/plugins/visual-editor/utils/html-utils.js new file mode 100644 index 0000000..99c70c3 --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/html-utils.js @@ -0,0 +1,334 @@ +import { IMPORTANT_STYLES } from '../constants/selectors.js'; + +const TEXT_CONTEXT_MAX_LENGTH = 500; +const OUTER_HTML_MAX_LENGTH = 5000; + +/** + * Decodes HTML entity-encoded characters back to their literal form. + * @param {string} string + * @returns {string} + */ +export function decodeHtmlEntities(string) { + return string + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +/** + * Converts a kebab-case CSS property name to camelCase. + * @param {string} property + * @returns {string} + */ +export function cssPropToCamel(property) { + return property.replace(/-([a-z])/g, (_, character) => character.toUpperCase()); +} + +const HTML_TO_JSX_ATTRIBUTE_RENAME_MAP = { class: 'className', for: 'htmlFor' }; + +/** + * Converts an HTML attribute name to its JSX/React equivalent (renames `class`/`for`, + * camelCases hyphenated names except `data-*`/`aria-*`, e.g. `stroke-width` -> `strokeWidth`). + * @param {string} name + * @returns {string} + */ +export function toJsxAttributeName(name) { + if (Object.prototype.hasOwnProperty.call(HTML_TO_JSX_ATTRIBUTE_RENAME_MAP, name)) return HTML_TO_JSX_ATTRIBUTE_RENAME_MAP[name]; + if (name.startsWith('data-') || name.startsWith('aria-')) return name; + return name.replace(/-([a-z0-9])/g, (_, character) => character.toUpperCase()); +} + +/** + * Escapes HTML special characters in a plain string (used to build safe HTML markup). + * @param {string} string + * @returns {string} + */ +export function escapeHtml(string) { + return string.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); +} + +/** + * Strips inline HTML tags from text, converting <br> to newlines. + * @param {string} text + * @returns {string} + */ +export function stripInlineTags(text) { + if (typeof text !== "string") return ""; + return text + .replace(/<br\s*\/?>/gi, "\n") + .replace(/<\/?(strong|em|b|i|u|a|span)\b[^>]*>/gi, ""); +} + +/** + * Returns the next node in document order within `root`, or null at the end. + * @param {Node} node + * @param {Node} root + * @returns {Node|null} + */ +function nextNodeInFlow(node, root) { + if (node.firstChild) return node.firstChild; + let current = node; + while (current && current !== root) { + if (current.nextSibling) return current.nextSibling; + current = current.parentNode; + } + return null; +} + +/** + * Whether a text node sits at the end of a line: nothing follows it, or the next + * node in flow is a <br>. Only such trailing spaces get collapsed by the browser + * on re-parse and need preserving — spaces between inline siblings (e.g. coloured + * <span> words) must stay normal breaking spaces or the line can't wrap. + * @param {Node} node + * @param {Node} root + * @returns {boolean} + */ +function isAtLineEnd(node, root) { + const next = nextNodeInFlow(node, root); + if (!next) return true; + let candidate = next; + while (candidate && candidate.nodeType === Node.ELEMENT_NODE) { + if (candidate.tagName.toLowerCase() === 'br') return true; + candidate = candidate.firstChild; + } + return false; +} + +/** + * Replaces line-end trailing keyboard spaces in text nodes with U+00A0 so innerHTML + * round-trips preserve them (browsers drop trailing U+0020 on re-parse). Spaces + * between inline siblings are left intact so the content can still wrap. + * @param {Node} root + */ +function preserveTrailingSpacesInTextNodes(root) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const textNodes = []; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + textNodes.push(node); + } + for (const node of textNodes) { + const text = node.nodeValue ?? ''; + if (!/ $/.test(text) || !isAtLineEnd(node, root)) continue; + node.nodeValue = text.replace(/ +$/g, (spaces) => '\u00a0'.repeat(spaces.length)); + } +} + +/** + * Reads contenteditable markup from the live DOM after keyboard input, preserving + * trailing spaces that `innerHTML` alone would drop on commit. + * @param {HTMLElement} element + * @returns {string} + */ +export function serializeContentEditableHtml(element) { + if (!element) return ''; + const clone = element.cloneNode(true); + preserveTrailingSpacesInTextNodes(clone); + return normalizeContentEditableHtml(clone.innerHTML); +} + +/** + * Normalises contenteditable innerHTML into a canonical form by collapsing + * Chrome's <div>-based line wrapping back into <br> separators. + * Intentionally keeps ` ` entities so keyboard-typed trailing spaces persist. + * @param {string} html + * @returns {string} + */ +export function normalizeContentEditableHtml(html) { + if (typeof html !== "string") return ""; + return html + .replace(/<div><br\s*\/?><\/div>/gi, "<br>") + .replace(/<br\s*\/?>\s*(?=<div>)/gi, "") + .replace(/<div>/gi, "<br>") + .replace(/<\/div>/gi, "") + .replace(/^<br>/, ""); +} + +/** + * Guards against "Failed to execute 'removeChild'" when innerHTML is set + * while a browser async task still holds a reference to a child node. + * @param {HTMLElement} element + */ +export function patchRemoveChild(element) { + if (element.__removeChildPatched) return; + const original = element.removeChild; + element.removeChild = function (child) { + if (child.parentNode !== this) return child; + return original.call(this, child); + }; + element.__removeChildPatched = true; +} + +let reactDomMutationsPatched = false; + +/** + * Inline edits rewrite a target element's subtree via `innerHTML`, which orphans + * the DOM nodes React still references in its fiber tree (including *nested* + * nodes like <em>/<strong> inside an edited <h1>). On its next render React then + * calls `removeChild`/`insertBefore` against nodes that are no longer where it + * expects, throwing "The node to be removed is not a child of this node". + * + * Patching the per-element `removeChild` only covers the edit target, not its + * descendants, so we guard the prototypes once instead: mismatched removals + * become no-ops and mismatched insertions fall back to an append, letting React + * reconcile to the correct state on the next render. Idempotent. + */ +export function patchReactDomMutations() { + if (reactDomMutationsPatched) return; + reactDomMutationsPatched = true; + + const originalRemoveChild = Node.prototype.removeChild; + Node.prototype.removeChild = function (child) { + if (child && child.parentNode !== this) return child; + return originalRemoveChild.call(this, child); + }; + + const originalInsertBefore = Node.prototype.insertBefore; + Node.prototype.insertBefore = function (newNode, referenceNode) { + if (referenceNode && referenceNode.parentNode !== this) { + return originalInsertBefore.call(this, newNode, null); + } + return originalInsertBefore.call(this, newNode, referenceNode); + }; +} + +/** + * Escapes raw HTML characters while preserving allowed inline tags + * (strong, em, a, span, br, etc.) so user content is safe to inject into the DOM. + * @param {string} text + * @returns {string} + */ +export function sanitizeText(text) { + if (typeof text !== "string") return ""; + const normalized = normalizeContentEditableHtml(text); + const preserved = []; + let result = normalized.replace(/<(\/?)(?:strong|em|b|i|u|a|span|br)\b[^>]*\/?>/gi, (match) => { + preserved.push(match); + return `\x00ITAG${preserved.length - 1}\x00`; + }); + result = result + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/{/g, "{") + .replace(/}/g, "}"); + result = result.replace(/\x00ITAG(\d+)\x00/g, (_, index) => preserved[parseInt(index)]); + return result; +} + +/** + * Builds a CSS selector path to uniquely identify the element. + * Uses tag + classes + nth-of-type where IDs are unavailable. + * @param {HTMLElement} element + * @returns {string} + */ +export function buildCssSelector(element) { + const path = []; + let current = element; + let depth = 0; + const maxDepth = 20; + + while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) { + let selector = current.nodeName.toLowerCase(); + + if (current.id) { + selector += `#${current.id}`; + path.unshift(selector); + break; + } + + if (current.className && typeof current.className === 'string') { + const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0); + if (classes.length > 0) { + selector += `.${classes.join('.')}`; + } + } + + if (current.parentElement) { + const siblings = Array.from(current.parentElement.children); + const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName); + if (sameTypeSiblings.length > 1) { + const index = sameTypeSiblings.indexOf(current) + 1; + selector += `:nth-of-type(${index})`; + } + } + + path.unshift(selector); + current = current.parentElement; + depth++; + } + + return path.join(' > '); +} + +function getComputedStylesForElement(element) { + const computedStyles = window.getComputedStyle(element); + + return Object.fromEntries(IMPORTANT_STYLES.map((style) => { + const styleValue = computedStyles.getPropertyValue(style)?.trim(); + + return styleValue && styleValue !== 'none' && styleValue !== 'normal' + ? [style, styleValue] + : null; + }).filter(Boolean)); +} + +/** + * Extracts a rich DOM context payload for an element: selector, attributes, + * computed styles, and truncated text content. + * @param {HTMLElement} element + * @returns {object|null} + */ +export function extractDOMContext(element) { + if (!element) return null; + + const textContent = element.textContent?.trim(); + + return { + outerHTML: element.outerHTML.slice(0, OUTER_HTML_MAX_LENGTH), + selector: buildCssSelector(element), + attributes: (element.attributes && element.attributes.length > 0) + ? Object.fromEntries(Array.from(element.attributes).map((attr) => [attr.name, attr.value])) + : {}, + computedStyles: getComputedStylesForElement(element), + textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH) + ? textContent + : null, + }; +} + +/** + * Walks the React fiber tree from a DOM node to find the source file path. + * @param {HTMLElement} node + * @returns {string|null} + */ +export function getFilePathFromNode(node) { + const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber')); + if (!fiberKey) return null; + + let currentFiber = node[fiberKey]; + while (currentFiber) { + const source = currentFiber._debugSource + || currentFiber.memoizedProps?.__source + || currentFiber.pendingProps?.__source; + + if (source?.fileName) return source.fileName; + currentFiber = currentFiber.return; + } + + return null; +} + +/** + * Strips the path prefix up to and including `public_html/`. + * @param {string} filePath + * @returns {string} + */ +export function stripFilePath(filePath) { + if (!filePath) return filePath; + const publicHtmlIndex = filePath.indexOf('public_html/'); + if (publicHtmlIndex !== -1) return filePath.substring(publicHtmlIndex + 'public_html/'.length); + return filePath; +} diff --git a/apps/web/plugins/visual-editor/utils/inline-style-state.js b/apps/web/plugins/visual-editor/utils/inline-style-state.js new file mode 100644 index 0000000..22d0bfc --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/inline-style-state.js @@ -0,0 +1,104 @@ +/** Minimum computed font-weight treated as bold (matches toolbar + toggle-off). */ +const BOLD_WEIGHT_THRESHOLD = 600; + +/** + * @param {Range} range + * @returns {Text[]} + */ +export function getTextNodesInRange(range) { + const root = range.commonAncestorContainer.nodeType === Node.TEXT_NODE + ? range.commonAncestorContainer.parentElement + : range.commonAncestorContainer; + const nodes = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + while (walker.nextNode()) { + if (range.intersectsNode(walker.currentNode)) nodes.push(walker.currentNode); + } + return nodes; +} + +function elementFromNode(node) { + if (!node) return null; + return node.nodeType === Node.TEXT_NODE ? node.parentElement : node; +} + +function walkAncestors(element, container, predicate) { + let current = element; + while (current && current !== container.parentElement) { + if (predicate(current)) return true; + current = current.parentElement; + } + return false; +} + +/** @returns {boolean} True when `node` sits inside bold markup or computed weight. */ +export function isNodeBold(node, container) { + if (parseInt(getComputedStyle(container).fontWeight) >= BOLD_WEIGHT_THRESHOLD) return true; + const element = elementFromNode(node); + if (!element) return false; + return walkAncestors(element, container, (current) => { + // `<strong>`/`<b>` apply `font-weight: bolder`, which resolves to only 400 + // when the parent is a light weight (e.g. Tailwind `font-light`, 300), so a + // computed-weight check alone misses them. Treat the tags as bold directly. + const tag = current.tagName?.toLowerCase(); + if (tag === 'strong' || tag === 'b') return true; + return parseInt(getComputedStyle(current).fontWeight) >= BOLD_WEIGHT_THRESHOLD; + }); +} + +/** @returns {boolean} True when `node` sits inside italic markup or computed style. */ +export function isNodeItalic(node, container) { + if (getComputedStyle(container).fontStyle === 'italic') return true; + const element = elementFromNode(node); + if (!element) return false; + return walkAncestors(element, container, (current) => getComputedStyle(current).fontStyle === 'italic'); +} + +/** @returns {boolean} True when `node` sits inside underlined markup or decoration. */ +export function isNodeUnderlined(node, container) { + const containerTextDecorationLine = getComputedStyle(container).textDecorationLine || getComputedStyle(container).textDecoration || ''; + if (containerTextDecorationLine.includes('underline')) return true; + const element = elementFromNode(node); + if (!element) return false; + return walkAncestors(element, container, (current) => { + const textDecorationLine = getComputedStyle(current).textDecorationLine || getComputedStyle(current).textDecoration || ''; + return textDecorationLine.includes('underline'); + }); +} + +/** + * Resolves coexisting bold / italic / underline at a single DOM position. + * Each flag is independent — e.g. `<em><u><strong>t</strong></u></em>` is all three. + * @param {Node} node + * @param {HTMLElement} container + * @returns {{ isBold: boolean, isItalic: boolean, isUnderline: boolean }} + */ +export function resolveInlineStylesAtNode(node, container) { + return { + isBold: isNodeBold(node, container), + isItalic: isNodeItalic(node, container), + isUnderline: isNodeUnderlined(node, container), + }; +} + +/** + * Resolves toolbar toggle state for the current selection. + * Collapsed → styles at caret. Range → active only when every covered text node has the style. + * @param {Range} range + * @param {HTMLElement} container + * @returns {{ isBold: boolean, isItalic: boolean, isUnderline: boolean }} + */ +export function resolveInlineStylesAtRange(range, container) { + if (range.collapsed) { + return resolveInlineStylesAtNode(range.startContainer, container); + } + const nodes = getTextNodesInRange(range); + if (nodes.length === 0) { + return resolveInlineStylesAtNode(range.startContainer, container); + } + return { + isBold: nodes.every((node) => isNodeBold(node, container)), + isItalic: nodes.every((node) => isNodeItalic(node, container)), + isUnderline: nodes.every((node) => isNodeUnderlined(node, container)), + }; +} diff --git a/apps/web/plugins/visual-editor/utils/parent-frame.js b/apps/web/plugins/visual-editor/utils/parent-frame.js new file mode 100644 index 0000000..b014ff0 --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/parent-frame.js @@ -0,0 +1,39 @@ +/** Origins allowed to receive messages from this iframe. */ +export const ALLOWED_PARENT_ORIGINS = [ + 'https://horizons.hostinger.com', + 'https://horizons.hostinger.dev', + 'https://horizons-frontend-local.hostinger.dev', + 'http://localhost:4000', +]; + +/** + * Resolves the parent frame origin from `ancestorOrigins` or `document.referrer`. + * @returns {string | null} + */ +export function getParentOrigin() { + if (window.location.ancestorOrigins && window.location.ancestorOrigins.length > 0) { + return window.location.ancestorOrigins[0]; + } + + if (document.referrer) { + try { + return new URL(document.referrer).origin; + } catch { + console.warn('[plugin] Invalid referrer URL:', document.referrer); + } + } + + return null; +} + +/** + * Sends a typed message to the parent frame when the origin is allowlisted. + * @param {string} type - A {@link ParentMessage} value. + * @param {object} [payload] + */ +export function postToParent(type, payload = {}) { + const parentOrigin = getParentOrigin(); + if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) { + window.parent.postMessage({ type, payload }, parentOrigin); + } +} diff --git a/apps/web/plugins/visual-editor/utils/selection-mode-metadata.js b/apps/web/plugins/visual-editor/utils/selection-mode-metadata.js new file mode 100644 index 0000000..0de70f9 --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/selection-mode-metadata.js @@ -0,0 +1,416 @@ +/** Shared DOM-context capture for the unified visual-edit contract. */ + +const IMPORTANT_STYLES = [ + 'display', + 'position', + 'flex-direction', + 'justify-content', + 'align-items', + 'width', + 'height', + 'padding', + 'margin', + 'border', + 'background-color', + 'color', + 'font-size', + 'font-weight', + 'font-family', + 'border-radius', + 'box-shadow', + 'gap', + 'grid-template-columns', +]; + +const TEXT_CONTEXT_MAX_LENGTH = 500; + +/** Backend DTO caps `selectionMode.selector` at this length; must match `src/constants/message.ts`. */ +const SELECTOR_MAX_LENGTH = 1_000; + +/** Keeps generated context well below the backend's 50,000-character `outerHTML` cap. */ +const DOM_CONTEXT_MAX_LENGTH = 5_000; +const DOM_CONTEXT_MAX_DEPTH = 3; +const DOM_CONTEXT_MAX_CHILD_ELEMENTS = 5; +const DOM_CONTEXT_EDGE_CHILD_ELEMENTS = 2; +const DOM_CONTEXT_MAX_TEXT_LENGTH = 300; +const DOM_CONTEXT_TEXT_EDGE_LENGTH = 140; +const DOM_CONTEXT_MAX_ATTRIBUTE_LENGTH = 300; +const DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH = 140; + +const OMITTED_ELEMENT_NAMES = new Set([ + 'base', + 'head', + 'link', + 'meta', + 'noscript', + 'script', + 'style', + 'template', +]); + +function contextAttributeEntries(element) { + if (!element.attributes) { + return []; + } + + return Array.from(element.attributes).flatMap((attribute) => { + const name = attribute.name.toLowerCase(); + const value = attribute.value; + if ( + name.startsWith('data-edit-') + || name === 'data-selection-mode-enabled' + || name.startsWith('on') + || name === 'srcdoc' + || value.trimStart().toLowerCase().startsWith('data:') + ) { + return []; + } + + if (value.length <= DOM_CONTEXT_MAX_ATTRIBUTE_LENGTH) { + return [[attribute.name, value]]; + } + + const omittedLength = value.length - (DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH * 2); + return [[ + attribute.name, + `${value.slice(0, DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH)}[${omittedLength} chars omitted]${value.slice(-DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH)}`, + ]]; + }); +} + +function cloneElementShell(element) { + const clone = element.cloneNode(false); + Array.from(clone.attributes ?? []).forEach(attribute => clone.removeAttribute(attribute.name)); + contextAttributeEntries(element).forEach(([name, value]) => clone.setAttribute(name, value)); + return clone; +} + +function appendTextContext(parent, value) { + if (!value) { + return; + } + if (value.length <= DOM_CONTEXT_MAX_TEXT_LENGTH) { + parent.appendChild(parent.ownerDocument.createTextNode(value)); + return; + } + + const omittedLength = value.length - (DOM_CONTEXT_TEXT_EDGE_LENGTH * 2); + parent.appendChild(parent.ownerDocument.createTextNode(value.slice(0, DOM_CONTEXT_TEXT_EDGE_LENGTH))); + parent.appendChild(parent.ownerDocument.createComment(`${omittedLength} text chars omitted`)); + parent.appendChild(parent.ownerDocument.createTextNode(value.slice(-DOM_CONTEXT_TEXT_EDGE_LENGTH))); +} + +function contextElementChildren(element) { + return Array.from(element.children) + .filter(child => !OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase())); +} + +function collectVisibleText(element) { + if (element.tagName.toLowerCase() === 'svg') { + return Array.from(element.children) + .filter(child => ['title', 'desc'].includes(child.tagName.toLowerCase())) + .map(child => child.textContent ?? '') + .join(' ') + .replace(/\s+/g, ' ') + .trim(); + } + + return Array.from(element.childNodes).map((child) => { + if (child.nodeType === Node.TEXT_NODE) { + return child.textContent ?? ''; + } + if (child.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + if (OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase())) { + return ''; + } + return collectVisibleText(child); + }) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function appendSvgContext(source, clone) { + Array.from(source.children) + .filter(child => ['title', 'desc'].includes(child.tagName.toLowerCase())) + .forEach((child) => { + const semanticClone = cloneElementShell(child); + appendTextContext(semanticClone, collectVisibleText(child)); + clone.appendChild(semanticClone); + }); + clone.appendChild(clone.ownerDocument.createComment('SVG content omitted')); +} + +function cloneElementContext(element, depth) { + const tagName = element.tagName.toLowerCase(); + if (OMITTED_ELEMENT_NAMES.has(tagName)) { + return null; + } + + const clone = cloneElementShell(element); + if (tagName === 'svg') { + appendSvgContext(element, clone); + return clone; + } + if (tagName === 'canvas') { + appendTextContext(clone, collectVisibleText(element).trim()); + clone.appendChild(clone.ownerDocument.createComment('canvas bitmap not captured')); + return clone; + } + + const elementChildren = contextElementChildren(element); + if (depth >= DOM_CONTEXT_MAX_DEPTH && elementChildren.length > 0) { + appendTextContext(clone, collectVisibleText(element).trim()); + clone.appendChild(clone.ownerDocument.createComment('descendants omitted')); + return clone; + } + + const keptChildren = elementChildren.length > DOM_CONTEXT_MAX_CHILD_ELEMENTS + ? new Set([ + ...elementChildren.slice(0, DOM_CONTEXT_EDGE_CHILD_ELEMENTS), + ...elementChildren.slice(-DOM_CONTEXT_EDGE_CHILD_ELEMENTS), + ]) + : new Set(elementChildren); + const omittedChildrenCount = elementChildren.length - keptChildren.size; + let omissionMarkerAdded = false; + + Array.from(element.childNodes).forEach((child) => { + if (child.nodeType === Node.TEXT_NODE) { + appendTextContext(clone, child.textContent ?? ''); + return; + } + if (child.nodeType !== Node.ELEMENT_NODE || OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase())) { + return; + } + if (!keptChildren.has(child)) { + if (!omissionMarkerAdded) { + clone.appendChild(clone.ownerDocument.createComment(`${omittedChildrenCount} elements omitted`)); + omissionMarkerAdded = true; + } + return; + } + + const childClone = cloneElementContext(child, depth + 1); + if (childClone) { + clone.appendChild(childClone); + } + }); + + return clone; +} + +/** + * Builds compact, valid HTML context while retaining the selected element's + * identifying structure and visible text. + * @param {HTMLElement} element + * @returns {string} + */ +export function serializeElementContext(element) { + const clone = cloneElementContext(element, 0); + if (!clone) { + return '<!-- context omitted -->'; + } + + const serialized = clone.outerHTML; + if (serialized.length <= DOM_CONTEXT_MAX_LENGTH) { + return serialized; + } + + const fallback = cloneElementShell(element); + fallback.appendChild(fallback.ownerDocument.createComment('context omitted: size limit')); + while (fallback.outerHTML.length > DOM_CONTEXT_MAX_LENGTH && fallback.attributes.length > 0) { + fallback.removeAttribute(fallback.attributes.item(fallback.attributes.length - 1).name); + } + return fallback.outerHTML; +} + +/** + * Builds a CSS selector path to uniquely identify the element. + * Uses tag + classes + nth-of-type where IDs are unavailable. Ancestors with many + * utility classes (e.g. Tailwind) can make the full path exceed the backend's length + * cap, so distant ancestors are dropped first since the leaf end is more identifying. + * @param {HTMLElement} element + * @returns {string} CSS selector path, truncated to fit {@link SELECTOR_MAX_LENGTH} + */ +export function buildCssSelector(element) { + const path = []; + let current = element; + let depth = 0; + const maxDepth = 20; // Prevent infinite loops + + while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) { + let selector = current.nodeName.toLowerCase(); + + if (current.id) { + selector += `#${current.id}`; + path.unshift(selector); + break; + } + + if (current.className && typeof current.className === 'string') { + const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0); + if (classes.length > 0) { + selector += `.${classes.join('.')}`; + } + } + + if (current.parentElement) { + const siblings = Array.from(current.parentElement.children); + const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName); + if (sameTypeSiblings.length > 1) { + const index = sameTypeSiblings.indexOf(current) + 1; + selector += `:nth-of-type(${index})`; + } + } + + path.unshift(selector); + current = current.parentElement; + depth++; + } + + while (path.length > 1 && path.join(' > ').length > SELECTOR_MAX_LENGTH) { + path.shift(); + } + + return path.join(' > ').slice(0, SELECTOR_MAX_LENGTH); +} + +function getComputedStylesSnapshot(element) { + const computedStyles = window.getComputedStyle(element); + + return Object.fromEntries(IMPORTANT_STYLES.map((style) => { + const styleValue = computedStyles.getPropertyValue(style)?.trim(); + + return styleValue && styleValue !== 'none' && styleValue !== 'normal' + ? [style, styleValue] + : null; + }) + .filter(Boolean)); +} + +/** + * Strips everything up to and including `public_html/` so the path matches + * the workspace-relative form the backend/AI expect. + * @param {string|null} filePath + * @returns {string|null} + */ +function stripPublicHtmlPrefix(filePath) { + if (!filePath) { + return filePath; + } + + const publicHtmlIndex = filePath.indexOf('public_html/'); + if (publicHtmlIndex !== -1) { + return filePath.substring(publicHtmlIndex + 'public_html/'.length); + } + + return filePath; +} + +/** + * Extracts source location from a `filePath:line:column` edit id (the + * format the visual-editor transform writes into `data-edit-id` / + * `data-edit-assisted-id` — see `transform/inject-attributes.js` and the + * matching parser in `server/source-writer.js`). + * @param {string} editId + * @returns {{filePath: string, line: number, column: number}|null} + */ +export function sourceLocationFromEditId(editId) { + const segments = editId.split(':'); + if (segments.length < 3) { + return null; + } + const line = Number(segments.at(-2)); + const column = Number(segments.at(-1)); + const filePath = segments.slice(0, -2).join(':'); + if (!filePath || !Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) { + return null; + } + return { filePath, line, column }; +} + +/** + * Walks the React Fiber tree to find the source file path of an element with + * no visual-editor edit-id attribute (plain selection-mode clicks). + * @param {HTMLElement} element + * @returns {string|null} + */ +function filePathFromReactFiber(element) { + const fiberKey = Object.keys(element).find(key => key.startsWith('__reactFiber')); + if (!fiberKey) { + return null; + } + + let currentFiber = element[fiberKey]; + while (currentFiber) { + const source = currentFiber._debugSource + || currentFiber.memoizedProps?.__source + || currentFiber.pendingProps?.__source; + + if (source?.fileName) { + return source.fileName; + } + + currentFiber = currentFiber.return; + } + + return null; +} + +/** + * Resolves the source file path for `element`. Prefers the visual editor's + * own `data-edit-id` / `data-edit-assisted-id` attribute — already + * `filePath:line:col`, no extra work — falling back to walking the React + * Fiber tree for elements the editor hasn't tagged (plain selection-mode + * clicks). + * @param {HTMLElement} element + * @returns {string} File path, or "" if it could not be resolved. + */ +export function resolveElementFilePath(element) { + const editId = element.getAttribute?.('data-edit-id') || element.getAttribute?.('data-edit-assisted-id'); + if (editId) { + const sourceLocation = sourceLocationFromEditId(editId); + if (sourceLocation) { + return stripPublicHtmlPrefix(sourceLocation.filePath) ?? ''; + } + } + + return stripPublicHtmlPrefix(filePathFromReactFiber(element)) ?? ''; +} + +/** + * Captures the full `selectionMode` context for `element`: source file + * path and optional source position, outer HTML, a CSS selector path, its + * attributes, a snapshot of the important computed styles, and trimmed text + * content (when short enough to be useful). Shared by selection mode and the + * visual editor so both emit the exact same shape the backend validates and + * the AI consumes. + * @param {HTMLElement} element + * @returns {{filePath: string, sourceLine?: number, sourceColumn?: number, outerHTML: string, selector: string, attributes: Record<string, string>, computedStyles: Record<string, string>, textContent: string|null}|null} + */ +export function captureElementMetadata(element) { + if (!element) { + return null; + } + + const textContent = element.textContent?.trim(); + const editId = element.getAttribute?.('data-edit-id') || element.getAttribute?.('data-edit-assisted-id'); + const sourceLocation = editId ? sourceLocationFromEditId(editId) : null; + + return { + filePath: resolveElementFilePath(element), + ...(sourceLocation && { + sourceLine: sourceLocation.line, + sourceColumn: sourceLocation.column, + }), + outerHTML: serializeElementContext(element), + selector: buildCssSelector(element), + attributes: Object.fromEntries(contextAttributeEntries(element)), + computedStyles: getComputedStylesSnapshot(element), + textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH) + ? textContent + : null, + }; +} diff --git a/apps/web/plugins/visual-editor/utils/session-history-command.js b/apps/web/plugins/visual-editor/utils/session-history-command.js new file mode 100644 index 0000000..73ecfe2 --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/session-history-command.js @@ -0,0 +1,33 @@ +import { ParentMessage } from '../constants/messages.js'; +import { undo, redo } from '../state/history-state.js'; +import { notifyDraftStateChanged } from '../api/draft-snapshot.js'; + +/** + * Applies one undo/redo step and notifies the parent frame when an action ran. + * @param {'undo'|'redo'} direction + * @returns {object|null} + */ +function applySessionHistoryStep(direction) { + const action = direction === 'undo' ? undo() : redo(); + if (!action) return null; + + notifyDraftStateChanged( + direction === 'undo' ? ParentMessage.EDIT_UNDO_APPLIED : ParentMessage.EDIT_REDO_APPLIED, + { + editId: action.editId, + value: direction === 'undo' ? action.instruction.beforeContent : action.instruction.afterContent, + }, + ); + + return action; +} + +/** @returns {object|null} */ +export function applySessionUndo() { + return applySessionHistoryStep('undo'); +} + +/** @returns {object|null} */ +export function applySessionRedo() { + return applySessionHistoryStep('redo'); +} diff --git a/apps/web/plugins/visual-editor/vite-plugin-edit-mode.js b/apps/web/plugins/visual-editor/vite-plugin-edit-mode.js new file mode 100644 index 0000000..f1ff662 --- /dev/null +++ b/apps/web/plugins/visual-editor/vite-plugin-edit-mode.js @@ -0,0 +1,55 @@ +import { context } from 'esbuild'; +import { resolve } from 'path'; +import { fileURLToPath } from 'url'; +import { EDIT_MODE_STYLES } from './ui/inline-edit/styles.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = resolve(__filename, '..'); + +/** + * Bundles the multi-file contenteditable edit-mode runtime with esbuild and + * injects it (plus the edit-mode styles) into the dev preview HTML. + * + * @returns {import('vite').Plugin} + */ +export default function inlineEditDevPlugin() { + let buildContext = null; + + return { + name: 'vite:inline-edit-dev', + apply: 'serve', + + async buildStart() { + buildContext = await context({ + entryPoints: [resolve(__dirname, 'edit-mode-script.js')], + bundle: true, + format: 'esm', + sourcemap: 'inline', + write: false, + platform: 'browser', + target: 'es2020', + }); + }, + + async transformIndexHtml() { + const { outputFiles } = await buildContext.rebuild(); + return [ + { + tag: 'script', + attrs: { type: 'module' }, + children: outputFiles[0].text, + injectTo: 'body', + }, + { + tag: 'style', + children: EDIT_MODE_STYLES, + injectTo: 'head', + }, + ]; + }, + + closeBundle() { + buildContext?.dispose(); + }, + }; +} diff --git a/apps/web/plugins/visual-editor/vite-plugin-react-inline-editor.js b/apps/web/plugins/visual-editor/vite-plugin-react-inline-editor.js new file mode 100644 index 0000000..8857a4c --- /dev/null +++ b/apps/web/plugins/visual-editor/vite-plugin-react-inline-editor.js @@ -0,0 +1,210 @@ +import nodePath from 'path'; +import { parse } from '@babel/parser'; +import traverseBabel from '@babel/traverse'; +import * as t from '@babel/types'; +import fs from 'fs'; +import { + parseFileToAST, + generateSourceWithMap, + VITE_PROJECT_ROOT +} from '../utils/ast-utils.js'; +import { isSameOriginRequest } from '../utils/same-origin.js'; +import { groupEditsByFile, applyElementEdit, renderEditedSource } from './server/source-writer.js'; +import { + EDITABLE_HTML_TAGS, + INLINE_EDIT_HTML_TAGS, + checkTagNameEditable, + validateImageSrc, + hasMeaningfulTextContent, + shouldDisableDueToChildren, + evalChildComposition, + subtreeRequiresAssistance, + hasEditableAncestor, +} from './transform/editability.js'; +import { + detectUrlAttribute, + attachEditAttributes, + attachAssistedEditId, +} from './transform/inject-attributes.js'; + +export default function inlineEditPlugin() { + return { + name: 'vite-inline-edit-plugin', + enforce: 'pre', + + transform(code, id) { + if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) { + return null; + } + + const relativeFilePath = nodePath.relative(VITE_PROJECT_ROOT, id); + const webRelativeFilePath = relativeFilePath.split(nodePath.sep).join('/'); + + try { + const babelAst = parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + errorRecovery: true + }); + + let attributesAdded = 0; + + traverseBabel.default(babelAst, { + JSXOpeningElement(path) { + const openingNode = path.node; + const elementNode = path.parentPath.node; + + if (!openingNode.loc) return; + + const originalLength = openingNode.attributes.length; + openingNode.attributes = openingNode.attributes.filter(attribute => + !(t.isJSXAttribute(attribute) && attribute.name?.name?.startsWith('data-edit-')) + ); + if (openingNode.attributes.length < originalLength) { + attributesAdded++; + } + + const line = openingNode.loc.start.line; + const column = openingNode.loc.start.column + 1; + const editId = `${webRelativeFilePath}:${line}:${column}`; + + // Condition 1: an annotated ancestor already owns this subtree. Applies to + // assisted ids too, so a branch never ends up with nested edit targets. + if (hasEditableAncestor(path)) return; + + // Condition 2: tags outside the editable list cannot be edited in place, + // but still qualify for an assisted id when they hold text of their own. + if (!checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)) { + if (checkTagNameEditable(openingNode, INLINE_EDIT_HTML_TAGS)) return; + + if (t.isJSXElement(elementNode) && hasMeaningfulTextContent(elementNode)) { + attachAssistedEditId(openingNode, editId); + attributesAdded++; + } + return; + } + + // Condition 3: image src must be a non-empty static string + const imageValidation = validateImageSrc(openingNode); + if (!imageValidation.isValid) { + attachAssistedEditId(openingNode, editId); + attributesAdded++; + return; + } + + // Condition 4: mark as assisted-edit if spread props or dynamic children + if (shouldDisableDueToChildren(elementNode, openingNode)) { + attachAssistedEditId(openingNode, editId); + attributesAdded++; + return; + } + + // Condition 5: skip or mark assisted-edit based on child composition + const childResult = evalChildComposition(elementNode, openingNode); + if (childResult === 'skip') return; + if (childResult === 'disabled') { + attachAssistedEditId(openingNode, editId); + attributesAdded++; + return; + } + + // Condition 6: recursive subtree check — dynamic expressions, + // spread props, or conditionals anywhere below this element make + // a direct edit unsafe (it would overwrite the JSX with static text). + if (subtreeRequiresAssistance(elementNode)) { + attachAssistedEditId(openingNode, editId); + attributesAdded++; + return; + } + + const urlAttributeName = detectUrlAttribute(openingNode); + attachEditAttributes(openingNode, editId, urlAttributeName); + attributesAdded++; + } + }); + + if (attributesAdded > 0) { + const output = generateSourceWithMap(babelAst, webRelativeFilePath, code); + return { code: output.code, map: output.map }; + } + + return null; + } catch (error) { + console.error(`[vite][visual-editor] Error transforming ${id}:`, error); + return null; + } + }, + + // Computes the source a batch of draft edits would produce and returns it. + // Nothing is written here — the preview posts the result up to the editor, + // which persists it through the authenticated backend. + configureServer(server) { + server.middlewares.use('/api/draft-save', async (request, response, next) => { + if (request.method !== 'POST') return next(); + + // Defence in depth only: this endpoint is public and Sec-Fetch-Site is + // client-supplied, so it stops browser-driven cross-origin calls and + // nothing more. Authorisation happens on the backend persist route. + if (!isSameOriginRequest(request)) { + response.writeHead(403, { 'Content-Type': 'application/json' }); + return response.end(JSON.stringify({ error: 'Forbidden' })); + } + + let body = ''; + request.on('data', chunk => { body += chunk.toString(); }); + + request.on('end', async () => { + let parsedBody; + try { + parsedBody = JSON.parse(body); + } catch { + response.writeHead(400, { 'Content-Type': 'application/json' }); + return response.end(JSON.stringify({ error: 'Invalid JSON request body' })); + } + + if (!parsedBody || typeof parsedBody !== 'object') { + response.writeHead(400, { 'Content-Type': 'application/json' }); + return response.end(JSON.stringify({ error: 'Invalid JSON request body' })); + } + + try { + const { draft } = parsedBody; + + if (!draft || typeof draft !== 'object') { + response.writeHead(400, { 'Content-Type': 'application/json' }); + return response.end(JSON.stringify({ error: 'Missing or invalid draft object' })); + } + + const { editsByFile, results } = groupEditsByFile(draft); + + for (const [absoluteFilePath, fileEdits] of editsByFile) { + const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8'); + const babelAst = parseFileToAST(absoluteFilePath); + let fileModified = false; + + for (const { draftKey, edit, parsedId } of fileEdits) { + const { modified, error } = applyElementEdit(babelAst, edit, parsedId); + if (!modified) { + results.push({ draftKey, success: false, error }); + continue; + } + fileModified = true; + } + + if (fileModified) { + results.push(renderEditedSource(babelAst, absoluteFilePath, originalContent)); + } + } + + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ success: true, results })); + } catch (error) { + console.error('[vite][visual-editor] Error in draft-save:', error); + response.writeHead(500, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: 'Internal server error during draft save.' })); + } + }); + }); + } + }; +} diff --git a/apps/web/plugins/vite-plugin-iframe-route-restoration.js b/apps/web/plugins/vite-plugin-iframe-route-restoration.js new file mode 100644 index 0000000..9fbdc4d --- /dev/null +++ b/apps/web/plugins/vite-plugin-iframe-route-restoration.js @@ -0,0 +1,134 @@ +export default function iframeRouteRestorationPlugin() { + return { + name: 'vite:iframe-route-restoration', + apply: 'serve', + transformIndexHtml() { + const script = ` + const ALLOWED_PARENT_ORIGINS = [ + "https://horizons.hostinger.com", + "https://horizons.hostinger.dev", + "https://horizons-frontend-local.hostinger.dev", + ]; + + // Check to see if the page is in an iframe + if (window.self !== window.top) { + const STORAGE_KEY = 'horizons-iframe-saved-route'; + + const getCurrentRoute = () => location.pathname + location.search + location.hash; + + const save = () => { + try { + const currentRoute = getCurrentRoute(); + sessionStorage.setItem(STORAGE_KEY, currentRoute); + window.parent.postMessage({message: 'route-changed', route: currentRoute}, '*'); + } catch {} + }; + + const replaceHistoryState = (url) => { + try { + history.replaceState(null, '', url); + window.dispatchEvent(new PopStateEvent('popstate', { state: history.state })); + return true; + } catch {} + return false; + }; + + const restore = () => { + try { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (!saved) return; + + if (!saved.startsWith('/')) { + sessionStorage.removeItem(STORAGE_KEY); + return; + } + + const current = getCurrentRoute(); + if (current !== saved) { + if (!replaceHistoryState(saved)) { + replaceHistoryState('/'); + } + + requestAnimationFrame(() => setTimeout(() => { + try { + const text = (document.body?.innerText || '').trim(); + + // If the restored route results in too little content, assume it is invalid and navigate home + if (text.length < 50) { + replaceHistoryState('/'); + } + } catch {} + }, 1000)); + } + } catch {} + }; + + const originalPushState = history.pushState; + history.pushState = function(...args) { + originalPushState.apply(this, args); + save(); + }; + + const originalReplaceState = history.replaceState; + history.replaceState = function(...args) { + originalReplaceState.apply(this, args); + save(); + }; + + const getParentOrigin = () => { + if ( + window.location.ancestorOrigins && + window.location.ancestorOrigins.length > 0 + ) { + return window.location.ancestorOrigins[0]; + } + + if (document.referrer) { + try { + return new URL(document.referrer).origin; + } catch (e) { + console.warn("Invalid referrer URL:", document.referrer); + } + } + + return null; + }; + + window.addEventListener('popstate', save); + window.addEventListener('hashchange', save); + window.addEventListener("message", function (event) { + const parentOrigin = getParentOrigin(); + if (!parentOrigin || !ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) return; + + if (event.data?.type === "redirect-home") { + const saved = sessionStorage.getItem(STORAGE_KEY); + if (saved && saved !== '/') { + replaceHistoryState('/'); + } + } + + if (event.data?.type === "navigate-to-route" && typeof event.data.path === "string") { + const target = event.data.path.startsWith('/') ? event.data.path : '/' + event.data.path; + const current = getCurrentRoute(); + if (current !== target) { + replaceHistoryState(target); + } + } + }); + + + restore(); + } + `; + + return [ + { + tag: 'script', + attrs: { type: 'module' }, + children: script, + injectTo: 'head' + } + ]; + } + }; +} diff --git a/apps/web/plugins/vite-plugin-pocketbase-auth.js b/apps/web/plugins/vite-plugin-pocketbase-auth.js new file mode 100644 index 0000000..6cd17ea --- /dev/null +++ b/apps/web/plugins/vite-plugin-pocketbase-auth.js @@ -0,0 +1,52 @@ +export default function pocketbaseAuthPlugin() { + return { + name: 'vite:pocketbase-auth', + apply: 'serve', + transformIndexHtml() { + const script = ` + const ALLOWED_PARENT_ORIGINS = [ + "https://horizons.hostinger.com", + "https://horizons.hostinger.dev", + "https://horizons-frontend-local.hostinger.dev", + ]; + const getParentOrigin = () => { + if ( + window.location.ancestorOrigins && + window.location.ancestorOrigins.length > 0 + ) { + return window.location.ancestorOrigins[0]; + } + + if (document.referrer) { + try { + return new URL(document.referrer).origin; + } catch (e) { + console.warn("Invalid referrer URL:", document.referrer); + } + } + + return null; + }; + + + + window.addEventListener("message", function (event) { + const parentOrigin = getParentOrigin(); + + if (event.data?.type === "database_preview_auth" && parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) { + localStorage.setItem("__pb_superuser_auth__", event.data.value); + } + }); + `; + + return [ + { + tag: 'script', + attrs: { type: 'module' }, + children: script, + injectTo: 'head' + } + ]; + } + }; +} diff --git a/apps/web/plugins/vite-plugin-site-pages.js b/apps/web/plugins/vite-plugin-site-pages.js new file mode 100644 index 0000000..56ad334 --- /dev/null +++ b/apps/web/plugins/vite-plugin-site-pages.js @@ -0,0 +1,91 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse } from '@babel/parser'; +import { VITE_PROJECT_ROOT } from './utils/ast-utils.js'; +import { isSameOriginRequest } from './utils/same-origin.js'; +import { + buildPageList, + scanFile, + upsertFile, +} from './site-pages/site-pages-server.js'; + +const __dirname = resolve(fileURLToPath(import.meta.url), '..'); +const SITE_PAGES_ENDPOINT = '/__horizons/site-pages'; +const SITE_PAGES_SCRIPT = readFileSync(resolve(__dirname, 'site-pages/site-pages-script.js'), 'utf-8'); + +/** + * Scan routes from JSX, expose them at /__horizons/site-pages, + * and post them to the Horizons parent frame from the preview iframe. + * + * @returns {import('vite').Plugin} + */ +export default function sitePagesPlugin() { + let index = { routerEntries: [], titlesByPageFile: {}, ecommercePageFiles: {} }; + + return { + name: 'vite:site-pages', + apply: 'serve', + enforce: 'pre', + + transform(code, id) { + if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) { + return null; + } + + try { + const ast = parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + errorRecovery: true, + }); + + index = upsertFile(index, id, scanFile(ast)); + } catch (error) { + console.error(`[site-pages] Error scanning ${id}:`, error); + } + + return null; + }, + + configureServer(server) { + server.middlewares.use(SITE_PAGES_ENDPOINT, async (request, response, next) => { + if (request.method !== 'GET') { + next(); + return; + } + + if (!isSameOriginRequest(request)) { + response.statusCode = 403; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ error: 'Forbidden' })); + return; + } + + try { + const pages = await buildPageList( + index, + (specifier, importer) => server.pluginContainer.resolveId(specifier, importer), + VITE_PROJECT_ROOT, + ); + + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify(pages)); + } catch (error) { + response.statusCode = 500; + response.setHeader('Content-Type', 'application/json'); + response.end(JSON.stringify({ error: error.message })); + } + }); + }, + + transformIndexHtml() { + return [{ + tag: 'script', + attrs: { type: 'module' }, + children: SITE_PAGES_SCRIPT, + injectTo: 'head', + }]; + }, + }; +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..5b756c1 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; \ No newline at end of file diff --git a/apps/web/public/.htaccess b/apps/web/public/.htaccess new file mode 100644 index 0000000..acf86bc --- /dev/null +++ b/apps/web/public/.htaccess @@ -0,0 +1,19 @@ +<IfModule mod_rewrite.c> + RewriteEngine On + RewriteBase / + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.html [L] +</IfModule> + +<IfModule mod_headers.c> + Header set X-Powered-By "Hostinger Horizons" + + # Cache everything on CDN by default + Header set Cache-Control "public, s-maxage=604800, max-age=0" + + # Cache in browser all assets + <If "%{REQUEST_URI} =~ m#^/assets/.*$#"> + Header set Cache-Control "public, max-age=604800" + </If> +</IfModule> diff --git a/apps/web/public/llms-full.txt b/apps/web/public/llms-full.txt new file mode 100644 index 0000000..2ef356f --- /dev/null +++ b/apps/web/public/llms-full.txt @@ -0,0 +1,110 @@ +# Korrith - llms-full.txt +# Comprehensive LLM crawler configuration for AI model training + +# Full site access for all LLM crawlers +User-agent: * +Allow: / + +# Specific LLM crawler permissions +User-agent: CCBot +Allow: / +Crawl-delay: 0 + +User-agent: anthropic-ai +Allow: / +Crawl-delay: 0 + +User-agent: Claude-Web +Allow: / +Crawl-delay: 0 + +User-agent: GPTBot +Allow: / +Crawl-delay: 0 + +User-agent: ChatGPT-User +Allow: / +Crawl-delay: 0 + +User-agent: OpenAI-SearchBot +Allow: / +Crawl-delay: 0 + +User-agent: Googlebot-Extended +Allow: / +Crawl-delay: 0 + +User-agent: Gemini +Allow: / +Crawl-delay: 0 + +User-agent: Copilot +Allow: / +Crawl-delay: 0 + +User-agent: Copilotbot +Allow: / +Crawl-delay: 0 + +User-agent: Meta-ExternalFetcher +Allow: / +Crawl-delay: 0 + +User-agent: Llama-Web +Allow: / +Crawl-delay: 0 + +User-agent: Perplexity +Allow: / +Crawl-delay: 0 + +User-agent: PerplexityBot +Allow: / +Crawl-delay: 0 + +User-agent: Bytedance +Allow: / +Crawl-delay: 0 + +User-agent: Bytespider +Allow: / +Crawl-delay: 0 + +User-agent: Applebot +Allow: / +Crawl-delay: 0 + +User-agent: Applebot-Extended +Allow: / +Crawl-delay: 0 + +User-agent: Diffbot +Allow: / +Crawl-delay: 0 + +User-agent: Scrapy +Allow: / +Crawl-delay: 0 + +# Allow access to all content +User-agent: * +Allow: / + +# Disallow only truly private areas +Disallow: /admin/ +Disallow: /private/ +Disallow: /internal/ +Disallow: /.env +Disallow: /.git/ + +# Sitemap reference for comprehensive indexing +Sitemap: https://korrith.com/sitemap.xml + +# Content access policy +# All public pages, resources, and documentation are available for AI training +# This includes: +# - All platform documentation (/platform/*) +# - All solution pages (/solutions/*) +# - All resource pages (/resources/*, /blog/*, /research/*) +# - All company information (/about/, /partners/, /trust/, /contact/) +# - All public documentation (/docs/*, /faq/) \ No newline at end of file diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx new file mode 100644 index 0000000..691a4b3 --- /dev/null +++ b/apps/web/src/App.jsx @@ -0,0 +1,113 @@ +import React, { useEffect } from 'react'; +import { Route, Routes, BrowserRouter as Router, useLocation } from 'react-router-dom'; +import ScrollToTop from '@/components/ScrollToTop.jsx'; +import { useGoogleAnalytics } from '@/hooks/useGoogleAnalytics.js'; + +// Pages +import HomePage from '@/pages/HomePage.jsx'; +import ArgusGridPage from '@/pages/ArgusGridPage.jsx'; +import ArgusDetailPage from '@/pages/ArgusDetailPage.jsx'; +import ExternalPosturePage from '@/pages/ExternalPosturePage.jsx'; +import InternalPosturePage from '@/pages/InternalPosturePage.jsx'; +import ActiveValidationPage from '@/pages/ActiveValidationPage.jsx'; +import ReportsAndEvidencePage from '@/pages/ReportsAndEvidencePage.jsx'; +import QuantumReadinessPage from '@/pages/QuantumReadinessPage.jsx'; +import RoadmapPage from '@/pages/RoadmapPage.jsx'; +import AgenciesPage from '@/pages/AgenciesPage.jsx'; +import MSSPsPage from '@/pages/MSSPsPage.jsx'; +import BusinessesPage from '@/pages/BusinessesPage.jsx'; +import WebsiteSecurityAuditPage from '@/pages/WebsiteSecurityAuditPage.jsx'; +import ManagedSecureHostingPage from '@/pages/ManagedSecureHostingPage.jsx'; +import PostQuantumReadinessPage from '@/pages/PostQuantumReadinessPage.jsx'; +import WhyKorrithPage from '@/pages/WhyKorrithPage.jsx'; +import BlogPage from '@/pages/BlogPage.jsx'; +import SampleReportPage from '@/pages/SampleReportPage.jsx'; +import DocsPage from '@/pages/DocsPage.jsx'; +import DocumentationPage from '@/pages/DocumentationPage.jsx'; +import ResearchPage from '@/pages/ResearchPage.jsx'; +import FAQPage from '@/pages/FAQPage.jsx'; +import AboutPage from '@/pages/AboutPage.jsx'; +import PartnersPage from '@/pages/PartnersPage.jsx'; +import TrustPage from '@/pages/TrustPage.jsx'; +import ContactPage from '@/pages/ContactPage.jsx'; +import AssessmentPage from '@/pages/AssessmentPage.jsx'; +import PrivacyPage from '@/pages/PrivacyPage.jsx'; +import TermsPage from '@/pages/TermsPage.jsx'; +import LoginPage from '@/pages/LoginPage.jsx'; + +// Analytics Tracker Component +function AnalyticsTracker() { + const location = useLocation(); + const { initializeGA, trackPageView } = useGoogleAnalytics(); + + useEffect(() => { + initializeGA(); + }, [initializeGA]); + + useEffect(() => { + console.log(`Route changed to: ${location.pathname}`); + + // Small delay to ensure gtag is initialized and document title is updated + const timeoutId = setTimeout(() => { + trackPageView(location.pathname + location.search, document.title); + }, 150); + + return () => clearTimeout(timeoutId); + }, [location, trackPageView]); + + return null; +} + +function App() { + return ( + <Router> + <AnalyticsTracker /> + <ScrollToTop /> + <Routes> + <Route path="/" element={<HomePage />} /> + + {/* Auth Routes */} + <Route path="/login" element={<LoginPage />} /> + + {/* Platform Routes */} + <Route path="/platform/argus-grid" element={<ArgusGridPage />} /> + <Route path="/platform/argus" element={<ArgusDetailPage />} /> + <Route path="/platform/external-posture" element={<ExternalPosturePage />} /> + <Route path="/platform/internal-posture" element={<InternalPosturePage />} /> + <Route path="/platform/active-validation" element={<ActiveValidationPage />} /> + <Route path="/platform/reports-and-evidence" element={<ReportsAndEvidencePage />} /> + <Route path="/platform/quantum-readiness" element={<QuantumReadinessPage />} /> + <Route path="/platform/roadmap" element={<RoadmapPage />} /> + + {/* Solutions Routes */} + <Route path="/solutions/agencies" element={<AgenciesPage />} /> + <Route path="/solutions/mssps" element={<MSSPsPage />} /> + <Route path="/solutions/businesses" element={<BusinessesPage />} /> + <Route path="/solutions/website-security-audits" element={<WebsiteSecurityAuditPage />} /> + <Route path="/solutions/managed-secure-hosting" element={<ManagedSecureHostingPage />} /> + <Route path="/solutions/post-quantum-readiness" element={<PostQuantumReadinessPage />} /> + + {/* Other Routes */} + <Route path="/why-korrith" element={<WhyKorrithPage />} /> + <Route path="/blog" element={<BlogPage />} /> + <Route path="/resources/sample-report" element={<SampleReportPage />} /> + <Route path="/docs" element={<DocsPage />} /> + <Route path="/resources/documentation" element={<DocumentationPage />} /> + <Route path="/research" element={<ResearchPage />} /> + <Route path="/faq" element={<FAQPage />} /> + <Route path="/about" element={<AboutPage />} /> + <Route path="/partners" element={<PartnersPage />} /> + <Route path="/trust" element={<TrustPage />} /> + <Route path="/contact" element={<ContactPage />} /> + <Route path="/assessment" element={<AssessmentPage />} /> + <Route path="/privacy" element={<PrivacyPage />} /> + <Route path="/terms" element={<TermsPage />} /> + + {/* Fallback Route */} + <Route path="*" element={<HomePage />} /> + </Routes> + </Router> + ); +} + +export default App; \ No newline at end of file diff --git a/apps/web/src/components/Footer.jsx b/apps/web/src/components/Footer.jsx new file mode 100644 index 0000000..eb407ad --- /dev/null +++ b/apps/web/src/components/Footer.jsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Github, Twitter, Linkedin } from 'lucide-react'; + +export default function Footer() { + const currentYear = new Date().getFullYear(); + + return ( + <footer className="bg-card border-t border-border pt-16 pb-8 overflow-hidden"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + + {/* Top Section: CTA & Newsletter */} + <div className="flex flex-col lg:flex-row justify-between items-start gap-12 mb-16 pb-16 border-b border-border/50"> + <div className="max-w-md"> + <h2 className="text-2xl font-bold text-card-foreground mb-4">Secure infrastructure. Quantum future.</h2> + <p className="text-muted-foreground mb-6">Experience the next generation of security posture management and quantum readiness.</p> + <Button asChild size="lg" className="bg-primary text-primary-foreground hover:bg-primary/90"> + <Link to="/assessment">Get a free assessment</Link> + </Button> + </div> + <div className="max-w-md w-full lg:w-auto"> + <h3 className="text-lg font-semibold text-card-foreground mb-4">Stay updated</h3> + <form className="flex gap-2" onSubmit={(e) => e.preventDefault()}> + <Input + type="email" + placeholder="Enter your email" + className="bg-background border-border text-foreground placeholder:text-muted-foreground" + aria-label="Email address" + /> + <Button type="submit" variant="secondary" className="bg-secondary text-secondary-foreground hover:bg-secondary/80"> + Subscribe + </Button> + </form> + </div> + </div> + + {/* Middle Section: Navigation Columns */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-16"> + <div className="flex flex-col space-y-4"> + <span className="font-semibold text-card-foreground">Platform</span> + <Link to="/platform/argus-grid" className="text-sm text-muted-foreground hover:text-primary transition-colors">The Argus Grid</Link> + <Link to="/platform/argus" className="text-sm text-muted-foreground hover:text-primary transition-colors">Argus</Link> + <Link to="/platform/external-posture" className="text-sm text-muted-foreground hover:text-primary transition-colors">External Posture</Link> + <Link to="/platform/reports-and-evidence" className="text-sm text-muted-foreground hover:text-primary transition-colors">Reports and Evidence</Link> + <Link to="/platform/quantum-readiness" className="text-sm text-muted-foreground hover:text-primary transition-colors">Quantum Readiness</Link> + <Link to="/platform/roadmap" className="text-sm text-muted-foreground hover:text-primary transition-colors">Roadmap</Link> + </div> + <div className="flex flex-col space-y-4"> + <span className="font-semibold text-card-foreground">Solutions</span> + <Link to="/solutions/agencies" className="text-sm text-muted-foreground hover:text-primary transition-colors">For Agencies</Link> + <Link to="/solutions/mssps" className="text-sm text-muted-foreground hover:text-primary transition-colors">For MSSPs</Link> + <Link to="/solutions/businesses" className="text-sm text-muted-foreground hover:text-primary transition-colors">For Businesses</Link> + <Link to="/solutions/website-security-audits" className="text-sm text-muted-foreground hover:text-primary transition-colors">Website Security Audits</Link> + <Link to="/solutions/managed-secure-hosting" className="text-sm text-muted-foreground hover:text-primary transition-colors">Managed Secure Hosting</Link> + <Link to="/solutions/post-quantum-readiness" className="text-sm text-muted-foreground hover:text-primary transition-colors">Post-Quantum Readiness</Link> + </div> + <div className="flex flex-col space-y-4"> + <span className="font-semibold text-card-foreground">Resources</span> + <Link to="/blog" className="text-sm text-muted-foreground hover:text-primary transition-colors">Blog</Link> + <Link to="/resources/sample-report" className="text-sm text-muted-foreground hover:text-primary transition-colors">Sample Report</Link> + <Link to="/resources/documentation" className="text-sm text-muted-foreground hover:text-primary transition-colors">Documentation</Link> + <Link to="/research" className="text-sm text-muted-foreground hover:text-primary transition-colors">Research and Whitepapers</Link> + <Link to="/faq" className="text-sm text-muted-foreground hover:text-primary transition-colors">FAQ</Link> + </div> + <div className="flex flex-col space-y-4"> + <span className="font-semibold text-card-foreground">Company</span> + <Link to="/about" className="text-sm text-muted-foreground hover:text-primary transition-colors">About</Link> + <Link to="/partners" className="text-sm text-muted-foreground hover:text-primary transition-colors">Partners</Link> + <Link to="/trust" className="text-sm text-muted-foreground hover:text-primary transition-colors">Trust at Korrith</Link> + <Link to="/contact" className="text-sm text-muted-foreground hover:text-primary transition-colors">Contact</Link> + </div> + </div> + + {/* Bottom Section: Legal & Social */} + <div className="flex flex-col md:flex-row justify-between items-center gap-6 pt-8 border-t border-border/50 text-sm text-muted-foreground relative z-10"> + <div className="flex items-center gap-4"> + <Link + to="/" + className="relative flex items-center shrink-0 hover:opacity-80 transition-opacity" + aria-label="Korrith Home" + > + <img + src="https://horizons-cdn.hostinger.com/f7b2aa49-1077-4692-8b37-dae2a255116c/83c1e4e45b9402680ee713828eebbe4e.png" + alt="Korrith" + className="h-24 w-24 object-contain -my-9" + /> + </Link> + <div className="flex items-center gap-2 z-10"> + <p>© {currentYear > 2026 ? currentYear : 2026} Korrith. All rights reserved.</p> + </div> + </div> + <div className="flex items-center gap-6 z-10"> + <Link to="/privacy" className="hover:text-card-foreground transition-colors">Privacy Policy</Link> + <Link to="/terms" className="hover:text-card-foreground transition-colors">Terms of Service</Link> + <div className="flex items-center gap-4 ml-4"> + <a href="#" aria-label="Twitter" className="hover:text-card-foreground transition-colors"><Twitter className="h-4 w-4" /></a> + <a href="#" aria-label="LinkedIn" className="hover:text-card-foreground transition-colors"><Linkedin className="h-4 w-4" /></a> + <a href="#" aria-label="GitHub" className="hover:text-card-foreground transition-colors"><Github className="h-4 w-4" /></a> + </div> + </div> + </div> + + </div> + </footer> + ); +} \ No newline at end of file diff --git a/apps/web/src/components/Header.jsx b/apps/web/src/components/Header.jsx new file mode 100644 index 0000000..df0921a --- /dev/null +++ b/apps/web/src/components/Header.jsx @@ -0,0 +1,201 @@ +import React, { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { Menu, ChevronDown } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Sheet, SheetContent, SheetTrigger, SheetTitle } from '@/components/ui/sheet'; + +const navigationData = [ + { + title: 'Platform', + links: [ + { name: 'The Argus Grid', path: '/platform/argus-grid' }, + { name: 'Argus', path: '/platform/argus' }, + { name: 'External Posture', path: '/platform/external-posture' }, + { name: 'Reports and Evidence', path: '/platform/reports-and-evidence' }, + { name: 'Quantum Readiness', path: '/platform/quantum-readiness' }, + { name: 'Roadmap', path: '/platform/roadmap' }, + ] + }, + { + title: 'Solutions', + links: [ + { name: 'For Agencies', path: '/solutions/agencies' }, + { name: 'For MSSPs', path: '/solutions/mssps' }, + { name: 'For Businesses', path: '/solutions/businesses' }, + { name: 'Website Security Audits', path: '/solutions/website-security-audits' }, // Fixed path + { name: 'Managed Secure Hosting', path: '/solutions/managed-secure-hosting' }, + { name: 'Post-Quantum Readiness', path: '/solutions/post-quantum-readiness' }, + ] + }, + { + title: 'Why Korrith', + path: '/why-korrith', + links: [] + }, + { + title: 'Resources', + links: [ + { name: 'Blog', path: '/blog' }, + { name: 'Sample Report', path: '/resources/sample-report' }, // Fixed path + { name: 'Documentation', path: '/resources/documentation' }, // Fixed path + { name: 'Research and Whitepapers', path: '/research' }, + { name: 'FAQ', path: '/faq' }, + ] + }, + { + title: 'Company', + links: [ + { name: 'About', path: '/about' }, + { name: 'Partners', path: '/partners' }, + { name: 'Trust at Korrith', path: '/trust' }, + { name: 'Contact', path: '/contact' }, + ] + } +]; + +export default function Header() { + const [isOpen, setIsOpen] = useState(false); + const location = useLocation(); + + const isActive = (path) => location.pathname === path; + + return ( + <header className="sticky top-0 z-50 w-full bg-background/80 backdrop-blur-lg border-b border-border shadow-sm"> + <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> + <div className="flex items-center justify-between h-20"> + + {/* Logo Container */} + <Link to="/" className="flex items-center shrink-0 transition-opacity hover:opacity-90 group h-full"> + <div className="relative h-full flex items-center w-36 md:w-48"> + <img + src="https://horizons-cdn.hostinger.com/f7b2aa49-1077-4692-8b37-dae2a255116c/5bf3c331da81252a27cf615c41a0b6b3.png" + alt="Korrith" + className="absolute max-w-none h-24 md:h-[108px] w-auto left-0 group-hover:scale-105 transition-transform duration-300 object-contain" + /> + </div> + </Link> + + {/* Desktop Nav */} + <nav className="hidden lg:flex items-center space-x-1"> + {navigationData.map((item) => ( + <div key={item.title} className="relative group px-3 py-2"> + {item.links.length > 0 ? ( + <> + <button className={`flex items-center gap-1 text-sm font-medium transition-colors ${ + item.links.some(l => isActive(l.path)) ? 'text-primary' : 'text-muted-foreground hover:text-foreground' + }`}> + {item.title} + <ChevronDown className="h-4 w-4 opacity-50 group-hover:rotate-180 transition-transform duration-200" /> + </button> + <div className="absolute top-full left-0 pt-4 hidden group-hover:block w-64"> + <div className="bg-card border border-border rounded-lg shadow-xl overflow-hidden p-2 flex flex-col gap-1"> + {item.links.map((link) => ( + <Link + key={link.path} + to={link.path} + className={`px-3 py-2 text-sm rounded-md transition-colors ${ + isActive(link.path) + ? 'bg-primary/10 text-primary' + : 'text-muted-foreground hover:bg-white/5 hover:text-foreground' + }`} + > + {link.name} + </Link> + ))} + </div> + </div> + </> + ) : ( + <Link + to={item.path} + className={`text-sm font-medium transition-colors ${ + isActive(item.path) + ? 'text-primary' + : 'text-muted-foreground hover:text-foreground' + }`} + > + {item.title} + </Link> + )} + </div> + ))} + </nav> + + {/* CTA & Mobile Toggle */} + <div className="flex items-center gap-4"> + <Link to="/login" className="hidden lg:block text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"> + Log in + </Link> + + <Button asChild className="hidden md:inline-flex bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_0_20px_-5px_hsl(var(--primary)/0.4)]"> + <Link to="/assessment">Get early access</Link> + </Button> + + <Sheet open={isOpen} onOpenChange={setIsOpen}> + <SheetTrigger asChild className="lg:hidden"> + <Button variant="ghost" size="icon" className="text-muted-foreground hover:text-foreground"> + <Menu className="h-6 w-6" /> + <span className="sr-only">Toggle navigation menu</span> + </Button> + </SheetTrigger> + <SheetContent side="right" className="w-[300px] bg-card border-l border-border p-6 overflow-y-auto"> + <SheetTitle className="text-left mb-6 font-bold text-xl text-foreground"> + Menu + </SheetTitle> + <nav className="flex flex-col space-y-6"> + {navigationData.map((item) => ( + <div key={item.title} className="flex flex-col space-y-3"> + {item.links.length > 0 ? ( + <> + <div className="text-sm font-semibold text-foreground uppercase tracking-wider">{item.title}</div> + <div className="flex flex-col space-y-2 pl-3 border-l border-border"> + {item.links.map((link) => ( + <Link + key={link.path} + to={link.path} + onClick={() => setIsOpen(false)} + className={`text-sm transition-colors ${ + isActive(link.path) ? 'text-primary' : 'text-muted-foreground hover:text-foreground' + }`} + > + {link.name} + </Link> + ))} + </div> + </> + ) : ( + <Link + to={item.path} + onClick={() => setIsOpen(false)} + className={`text-base font-medium transition-colors ${ + isActive(item.path) ? 'text-primary' : 'text-foreground hover:text-primary' + }`} + > + {item.title} + </Link> + )} + </div> + ))} + + <div className="pt-6 border-t border-border mt-6 flex flex-col gap-4"> + <Link + to="/login" + onClick={() => setIsOpen(false)} + className="text-base font-medium text-foreground hover:text-primary transition-colors text-center py-2" + > + Log in + </Link> + <Button asChild className="w-full bg-primary text-primary-foreground hover:bg-primary/90"> + <Link to="/assessment" onClick={() => setIsOpen(false)}>Get early access</Link> + </Button> + </div> + </nav> + </SheetContent> + </Sheet> + </div> + + </div> + </div> + </header> + ); +} \ No newline at end of file diff --git a/apps/web/src/components/LayoutWrapper.jsx b/apps/web/src/components/LayoutWrapper.jsx new file mode 100644 index 0000000..86c5deb --- /dev/null +++ b/apps/web/src/components/LayoutWrapper.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import Header from '@/components/Header.jsx'; +import Footer from '@/components/Footer.jsx'; + +export default function LayoutWrapper({ children }) { + return ( + <div className="flex flex-col min-h-[100dvh] bg-background text-foreground"> + <Header /> + <main className="flex-grow flex flex-col"> + {children} + </main> + <Footer /> + </div> + ); +} \ No newline at end of file diff --git a/apps/web/src/components/ScrollToTop.jsx b/apps/web/src/components/ScrollToTop.jsx new file mode 100644 index 0000000..4675f63 --- /dev/null +++ b/apps/web/src/components/ScrollToTop.jsx @@ -0,0 +1,28 @@ +import { useLocation } from 'react-router-dom'; +import { useLayoutEffect } from 'react'; + +const ScrollToTop = () => { + const { pathname } = useLocation(); + + useLayoutEffect(() => { + window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); + + // Backup GA4 tracking mechanism + try { + if (typeof window !== 'undefined' && typeof window.gtag === 'function') { + console.log(`[Backup] Sending page_view event to GA4: ${pathname}`); + window.gtag('event', 'page_view', { + page_path: pathname, + page_title: document.title + }); + console.log('[Backup] GA4 page_view event sent successfully'); + } + } catch (error) { + console.error('[Backup] Error sending page_view event to GA4:', error); + } + }, [pathname]); + + return null; +} + +export default ScrollToTop; \ No newline at end of file diff --git a/apps/web/src/components/ui/accordion.jsx b/apps/web/src/components/ui/accordion.jsx new file mode 100644 index 0000000..e798edd --- /dev/null +++ b/apps/web/src/components/ui/accordion.jsx @@ -0,0 +1,41 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef(({ className, ...props }, ref) => ( + <AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} /> +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Header className="flex"> + <AccordionPrimitive.Trigger + ref={ref} + className={cn( + "flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180", + className + )} + {...props}> + {children} + <ChevronDown + className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" /> + </AccordionPrimitive.Trigger> + </AccordionPrimitive.Header> +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef(({ className, children, ...props }, ref) => ( + <AccordionPrimitive.Content + ref={ref} + className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down" + {...props}> + <div className={cn("pb-4 pt-0", className)}>{children}</div> + </AccordionPrimitive.Content> +)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } \ No newline at end of file diff --git a/apps/web/src/components/ui/alert-dialog.jsx b/apps/web/src/components/ui/alert-dialog.jsx new file mode 100644 index 0000000..ce2bda4 --- /dev/null +++ b/apps/web/src/components/ui/alert-dialog.jsx @@ -0,0 +1,97 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} /> +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => ( + <AlertDialogPortal> + <AlertDialogOverlay /> + <AlertDialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props} /> + </AlertDialogPortal> +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}) => ( + <div + className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} + {...props} /> +) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}) => ( + <div + className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} + {...props} /> +) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} /> +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} /> +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} /> +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => ( + <AlertDialogPrimitive.Cancel + ref={ref} + className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)} + {...props} /> +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/alert.jsx b/apps/web/src/components/ui/alert.jsx new file mode 100644 index 0000000..c68ea5a --- /dev/null +++ b/apps/web/src/components/ui/alert.jsx @@ -0,0 +1,47 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef(({ className, variant, ...props }, ref) => ( + <div + ref={ref} + role="alert" + className={cn(alertVariants({ variant }), className)} + {...props} /> +)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef(({ className, ...props }, ref) => ( + <h5 + ref={ref} + className={cn("mb-1 font-medium leading-none tracking-tight", className)} + {...props} /> +)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("text-sm [&_p]:leading-relaxed", className)} + {...props} /> +)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } \ No newline at end of file diff --git a/apps/web/src/components/ui/aspect-ratio.jsx b/apps/web/src/components/ui/aspect-ratio.jsx new file mode 100644 index 0000000..750d38c --- /dev/null +++ b/apps/web/src/components/ui/aspect-ratio.jsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } \ No newline at end of file diff --git a/apps/web/src/components/ui/avatar.jsx b/apps/web/src/components/ui/avatar.jsx new file mode 100644 index 0000000..d480902 --- /dev/null +++ b/apps/web/src/components/ui/avatar.jsx @@ -0,0 +1,33 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef(({ className, ...props }, ref) => ( + <AvatarPrimitive.Root + ref={ref} + className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)} + {...props} /> +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef(({ className, ...props }, ref) => ( + <AvatarPrimitive.Image + ref={ref} + className={cn("aspect-square h-full w-full", className)} + {...props} /> +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => ( + <AvatarPrimitive.Fallback + ref={ref} + className={cn( + "flex h-full w-full items-center justify-center rounded-full bg-muted", + className + )} + {...props} /> +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } \ No newline at end of file diff --git a/apps/web/src/components/ui/badge.jsx b/apps/web/src/components/ui/badge.jsx new file mode 100644 index 0000000..86c7abc --- /dev/null +++ b/apps/web/src/components/ui/badge.jsx @@ -0,0 +1,34 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + ...props +}) { + return (<div className={cn(badgeVariants({ variant }), className)} {...props} />); +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/apps/web/src/components/ui/breadcrumb.jsx b/apps/web/src/components/ui/breadcrumb.jsx new file mode 100644 index 0000000..42d8181 --- /dev/null +++ b/apps/web/src/components/ui/breadcrumb.jsx @@ -0,0 +1,92 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef( + ({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} /> +) +Breadcrumb.displayName = "Breadcrumb" + +const BreadcrumbList = React.forwardRef(({ className, ...props }, ref) => ( + <ol + ref={ref} + className={cn( + "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5", + className + )} + {...props} /> +)) +BreadcrumbList.displayName = "BreadcrumbList" + +const BreadcrumbItem = React.forwardRef(({ className, ...props }, ref) => ( + <li + ref={ref} + className={cn("inline-flex items-center gap-1.5", className)} + {...props} /> +)) +BreadcrumbItem.displayName = "BreadcrumbItem" + +const BreadcrumbLink = React.forwardRef(({ asChild, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a" + + return ( + <Comp + ref={ref} + className={cn("transition-colors hover:text-foreground", className)} + {...props} /> + ); +}) +BreadcrumbLink.displayName = "BreadcrumbLink" + +const BreadcrumbPage = React.forwardRef(({ className, ...props }, ref) => ( + <span + ref={ref} + role="link" + aria-disabled="true" + aria-current="page" + className={cn("font-normal text-foreground", className)} + {...props} /> +)) +BreadcrumbPage.displayName = "BreadcrumbPage" + +const BreadcrumbSeparator = ({ + children, + className, + ...props +}) => ( + <li + role="presentation" + aria-hidden="true" + className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)} + {...props}> + {children ?? <ChevronRight />} + </li> +) +BreadcrumbSeparator.displayName = "BreadcrumbSeparator" + +const BreadcrumbEllipsis = ({ + className, + ...props +}) => ( + <span + role="presentation" + aria-hidden="true" + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props}> + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More</span> + </span> +) +BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" + +export { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/button-group.jsx b/apps/web/src/components/ui/button-group.jsx new file mode 100644 index 0000000..f1a2dcf --- /dev/null +++ b/apps/web/src/components/ui/button-group.jsx @@ -0,0 +1,78 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" + +const buttonGroupVariants = cva( + "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}) { + return ( + <div + role="group" + data-slot="button-group" + data-orientation={orientation} + className={cn(buttonGroupVariants({ orientation }), className)} + {...props} /> + ); +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}) { + const Comp = asChild ? Slot : "div" + + return ( + <Comp + className={cn( + "bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none", + className + )} + {...props} /> + ); +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}) { + return ( + <Separator + data-slot="button-group-separator" + orientation={orientation} + className={cn( + "bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto", + className + )} + {...props} /> + ); +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/button.jsx b/apps/web/src/components/ui/button.jsx new file mode 100644 index 0000000..d224e47 --- /dev/null +++ b/apps/web/src/components/ui/button.jsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + <Comp + className={cn(buttonVariants({ variant, size, className }))} + ref={ref} + {...props} /> + ); +}) +Button.displayName = "Button" + +export { Button, buttonVariants } \ No newline at end of file diff --git a/apps/web/src/components/ui/calendar.jsx b/apps/web/src/components/ui/calendar.jsx new file mode 100644 index 0000000..aac2524 --- /dev/null +++ b/apps/web/src/components/ui/calendar.jsx @@ -0,0 +1,175 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayPicker, getDefaultClassNames } from "react-day-picker"; + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + <DayPicker + showOutsideDays={showOutsideDays} + className={cn( + "bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent", + String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root + ), + dropdown: cn("bg-popover absolute inset-0 opacity-0", defaultClassNames.dropdown), + caption_label: cn("select-none font-medium", captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", defaultClassNames.caption_label), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn("w-[--cell-size] select-none", defaultClassNames.week_number_header), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day + ), + range_start: cn("bg-accent rounded-l-md", defaultClassNames.range_start), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return (<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return (<ChevronLeftIcon className={cn("size-4", className)} {...props} />); + } + + if (orientation === "right") { + return (<ChevronRightIcon className={cn("size-4", className)} {...props} />); + } + + return (<ChevronDownIcon className={cn("size-4", className)} {...props} />); + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + <td {...props}> + <div + className="flex size-[--cell-size] items-center justify-center text-center"> + {children} + </div> + </td> + ); + }, + ...components, + }} + {...props} /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( + <Button + ref={ref} + variant="ghost" + size="icon" + data-day={day.date.toLocaleDateString()} + data-selected-single={ + modifiers.selected && + !modifiers.range_start && + !modifiers.range_end && + !modifiers.range_middle + } + data-range-start={modifiers.range_start} + data-range-end={modifiers.range_end} + data-range-middle={modifiers.range_middle} + className={cn( + "data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70", + defaultClassNames.day, + className + )} + {...props} /> + ); +} + +export { Calendar, CalendarDayButton } \ No newline at end of file diff --git a/apps/web/src/components/ui/card.jsx b/apps/web/src/components/ui/card.jsx new file mode 100644 index 0000000..6d071f7 --- /dev/null +++ b/apps/web/src/components/ui/card.jsx @@ -0,0 +1,50 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} + {...props} /> +)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex flex-col space-y-1.5 p-6", className)} + {...props} /> +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("font-semibold leading-none tracking-tight", className)} + {...props} /> +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} /> +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} /> +)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( + <div + ref={ref} + className={cn("flex items-center p-6 pt-0", className)} + {...props} /> +)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } \ No newline at end of file diff --git a/apps/web/src/components/ui/carousel.jsx b/apps/web/src/components/ui/carousel.jsx new file mode 100644 index 0000000..161a8f4 --- /dev/null +++ b/apps/web/src/components/ui/carousel.jsx @@ -0,0 +1,194 @@ +"use client"; +import * as React from "react" +import useEmblaCarousel from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a <Carousel />") + } + + return context +} + +const Carousel = React.forwardRef(( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref +) => { + const [carouselRef, api] = useEmblaCarousel({ + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, plugins) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api) => { + if (!api) { + return + } + + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback((event) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, [scrollPrev, scrollNext]) + + React.useEffect(() => { + if (!api || !setApi) { + return + } + + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) { + return + } + + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + }; + }, [api, onSelect]) + + return ( + <CarouselContext.Provider + value={{ + carouselRef, + api: api, + opts, + orientation: + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), + scrollPrev, + scrollNext, + canScrollPrev, + canScrollNext, + }}> + <div + ref={ref} + onKeyDownCapture={handleKeyDown} + className={cn("relative", className)} + role="region" + aria-roledescription="carousel" + {...props}> + {children} + </div> + </CarouselContext.Provider> + ); +}) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( + <div ref={carouselRef} className="overflow-hidden"> + <div + ref={ref} + className={cn( + "flex", + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", + className + )} + {...props} /> + </div> + ); +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( + <div + ref={ref} + role="group" + aria-roledescription="slide" + className={cn( + "min-w-0 shrink-0 grow-0 basis-full", + orientation === "horizontal" ? "pl-4" : "pt-4", + className + )} + {...props} /> + ); +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal" + ? "-left-12 top-1/2 -translate-y-1/2" + : "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)} + disabled={!canScrollPrev} + onClick={scrollPrev} + {...props}> + <ArrowLeft className="h-4 w-4" /> + <span className="sr-only">Previous slide</span> + </Button> + ); +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + <Button + ref={ref} + variant={variant} + size={size} + className={cn("absolute h-8 w-8 rounded-full", orientation === "horizontal" + ? "-right-12 top-1/2 -translate-y-1/2" + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)} + disabled={!canScrollNext} + onClick={scrollNext} + {...props}> + <ArrowRight className="h-4 w-4" /> + <span className="sr-only">Next slide</span> + </Button> + ); +}) +CarouselNext.displayName = "CarouselNext" + +export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext }; \ No newline at end of file diff --git a/apps/web/src/components/ui/chart.jsx b/apps/web/src/components/ui/chart.jsx new file mode 100644 index 0000000..5e022b6 --- /dev/null +++ b/apps/web/src/components/ui/chart.jsx @@ -0,0 +1,312 @@ +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { + light: "", + dark: ".dark" +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a <ChartContainer />") + } + + return context +} + +const ChartContainer = React.forwardRef(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + <ChartContext.Provider value={{ config }}> + <div + data-chart={chartId} + ref={ref} + className={cn( + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none", + className + )} + {...props}> + <ChartStyle id={chartId} config={config} /> + <RechartsPrimitive.ResponsiveContainer> + {children} + </RechartsPrimitive.ResponsiveContainer> + </div> + </ChartContext.Provider> + ); +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ + id, + config +}) => { + const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color) + + if (!colorConfig.length) { + return null + } + + return ( + <style + dangerouslySetInnerHTML={{ + __html: Object.entries(THEMES) + .map(([theme, prefix]) => ` +${prefix} [data-chart=${id}] { +${colorConfig +.map(([key, itemConfig]) => { +const color = + itemConfig.theme?.[theme] || + itemConfig.color +return color ? ` --color-${key}: ${color};` : null +}) +.join("\n")} +} +`) + .join("\n"), + }} /> + ); +} + +const ChartTooltip = RechartsPrimitive.Tooltip + +const ChartTooltipContent = React.forwardRef(( + { + active, + payload, + className, + indicator = "dot", + hideLabel = false, + hideIndicator = false, + label, + labelFormatter, + labelClassName, + formatter, + color, + nameKey, + labelKey, + }, + ref +) => { + const { config } = useChart() + + const tooltipLabel = React.useMemo(() => { + if (hideLabel || !payload?.length) { + return null + } + + const [item] = payload + const key = `${labelKey || item?.dataKey || item?.name || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const value = + !labelKey && typeof label === "string" + ? config[label]?.label || label + : itemConfig?.label + + if (labelFormatter) { + return ( + <div className={cn("font-medium", labelClassName)}> + {labelFormatter(value, payload)} + </div> + ); + } + + if (!value) { + return null + } + + return <div className={cn("font-medium", labelClassName)}>{value}</div>; + }, [ + label, + labelFormatter, + payload, + hideLabel, + labelClassName, + config, + labelKey, + ]) + + if (!active || !payload?.length) { + return null + } + + const nestLabel = payload.length === 1 && indicator !== "dot" + + return ( + <div + ref={ref} + className={cn( + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl", + className + )}> + {!nestLabel ? tooltipLabel : null} + <div className="grid gap-1.5"> + {payload + .filter((item) => item.type !== "none") + .map((item, index) => { + const key = `${nameKey || item.name || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + const indicatorColor = color || item.payload.fill || item.color + + return ( + <div + key={item.dataKey} + className={cn( + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground", + indicator === "dot" && "items-center" + )}> + {formatter && item?.value !== undefined && item.name ? ( + formatter(item.value, item.name, item, index, item.payload) + ) : ( + <> + {itemConfig?.icon ? ( + <itemConfig.icon /> + ) : ( + !hideIndicator && ( + <div + className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", { + "h-2.5 w-2.5": indicator === "dot", + "w-1": indicator === "line", + "w-0 border-[1.5px] border-dashed bg-transparent": + indicator === "dashed", + "my-0.5": nestLabel && indicator === "dashed", + })} + style={ + { + "--color-bg": indicatorColor, + "--color-border": indicatorColor + } + } /> + ) + )} + <div + className={cn( + "flex flex-1 justify-between leading-none", + nestLabel ? "items-end" : "items-center" + )}> + <div className="grid gap-1.5"> + {nestLabel ? tooltipLabel : null} + <span className="text-muted-foreground"> + {itemConfig?.label || item.name} + </span> + </div> + {item.value && ( + <span className="font-mono font-medium tabular-nums text-foreground"> + {item.value.toLocaleString()} + </span> + )} + </div> + </> + )} + </div> + ); + })} + </div> + </div> + ); +}) +ChartTooltipContent.displayName = "ChartTooltip" + +const ChartLegend = RechartsPrimitive.Legend + +const ChartLegendContent = React.forwardRef(( + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, + ref +) => { + const { config } = useChart() + + if (!payload?.length) { + return null + } + + return ( + <div + ref={ref} + className={cn( + "flex items-center justify-center gap-4", + verticalAlign === "top" ? "pb-3" : "pt-3", + className + )}> + {payload + .filter((item) => item.type !== "none") + .map((item) => { + const key = `${nameKey || item.dataKey || "value"}` + const itemConfig = getPayloadConfigFromPayload(config, item, key) + + return ( + <div + key={item.value} + className={cn( + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground" + )}> + {itemConfig?.icon && !hideIcon ? ( + <itemConfig.icon /> + ) : ( + <div + className="h-2 w-2 shrink-0 rounded-[2px]" + style={{ + backgroundColor: item.color, + }} /> + )} + {itemConfig?.label} + </div> + ); + })} + </div> + ); +}) +ChartLegendContent.displayName = "ChartLegend" + +// Helper to extract item config from a payload. +function getPayloadConfigFromPayload( + config, + payload, + key +) { + if (typeof payload !== "object" || payload === null) { + return undefined + } + + const payloadPayload = + "payload" in payload && + typeof payload.payload === "object" && + payload.payload !== null + ? payload.payload + : undefined + + let configLabelKey = key + + if ( + key in payload && + typeof payload[key] === "string" + ) { + configLabelKey = payload[key] + } else if ( + payloadPayload && + key in payloadPayload && + typeof payloadPayload[key] === "string" + ) { + configLabelKey = payloadPayload[key] + } + + return configLabelKey in config + ? config[configLabelKey] + : config[key]; +} + +export { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + ChartStyle, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/checkbox.jsx b/apps/web/src/components/ui/checkbox.jsx new file mode 100644 index 0000000..7c3f517 --- /dev/null +++ b/apps/web/src/components/ui/checkbox.jsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef(({ className, ...props }, ref) => ( + <CheckboxPrimitive.Root + ref={ref} + className={cn( + "grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground", + className + )} + {...props}> + <CheckboxPrimitive.Indicator className={cn("grid place-content-center text-current")}> + <Check className="h-4 w-4" /> + </CheckboxPrimitive.Indicator> + </CheckboxPrimitive.Root> +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } \ No newline at end of file diff --git a/apps/web/src/components/ui/collapsible.jsx b/apps/web/src/components/ui/collapsible.jsx new file mode 100644 index 0000000..47d724b --- /dev/null +++ b/apps/web/src/components/ui/collapsible.jsx @@ -0,0 +1,11 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } \ No newline at end of file diff --git a/apps/web/src/components/ui/command.jsx b/apps/web/src/components/ui/command.jsx new file mode 100644 index 0000000..ecebc23 --- /dev/null +++ b/apps/web/src/components/ui/command.jsx @@ -0,0 +1,127 @@ +"use client"; +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; + +const Command = React.forwardRef(({ className, ...props }, ref) => ( + <CommandPrimitive + ref={ref} + className={cn( + "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", + className, + )} + {...props} + /> +)); +Command.displayName = CommandPrimitive.displayName; + +const CommandDialog = ({ children, ...props }) => { + return ( + <Dialog {...props}> + <DialogContent className="overflow-hidden p-0"> + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[data-cmdk-input-wrapper]_svg]:h-5 [&_[data-cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> + {children} + </Command> + </DialogContent> + </Dialog> + ); +}; + +const CommandInput = React.forwardRef(({ className, ...props }, ref) => ( + <div className="flex items-center border-b px-3" data-cmdk-input-wrapper=""> + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> + <CommandPrimitive.Input + ref={ref} + className={cn( + "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", + className, + )} + {...props} + /> + </div> +)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef(({ className, ...props }, ref) => ( + <CommandPrimitive.List + ref={ref} + className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} + {...props} + /> +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef((props, ref) => ( + <CommandPrimitive.Empty + ref={ref} + className="py-6 text-center text-sm" + {...props} + /> +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef(({ className, ...props }, ref) => ( + <CommandPrimitive.Group + ref={ref} + className={cn( + "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", + className, + )} + {...props} + /> +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef(({ className, ...props }, ref) => ( + <CommandPrimitive.Separator + ref={ref} + className={cn("-mx-1 h-px bg-border", className)} + {...props} + /> +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef(({ className, ...props }, ref) => ( + <CommandPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + className, + )} + {...props} + /> +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ className, ...props }) => { + return ( + <span + className={cn( + "ml-auto text-xs tracking-widest text-muted-foreground", + className, + )} + {...props} + /> + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; \ No newline at end of file diff --git a/apps/web/src/components/ui/context-menu.jsx b/apps/web/src/components/ui/context-menu.jsx new file mode 100644 index 0000000..f0db587 --- /dev/null +++ b/apps/web/src/components/ui/context-menu.jsx @@ -0,0 +1,156 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ContextMenu = ContextMenuPrimitive.Root + +const ContextMenuTrigger = ContextMenuPrimitive.Trigger + +const ContextMenuGroup = ContextMenuPrimitive.Group + +const ContextMenuPortal = ContextMenuPrimitive.Portal + +const ContextMenuSub = ContextMenuPrimitive.Sub + +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup + +const ContextMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( + <ContextMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className + )} + {...props}> + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </ContextMenuPrimitive.SubTrigger> +)) +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName + +const ContextMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]", + className + )} + {...props} /> +)) +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName + +const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.Portal> + <ContextMenuPrimitive.Content + ref={ref} + className={cn( + "z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]", + className + )} + {...props} /> + </ContextMenuPrimitive.Portal> +)) +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName + +const ContextMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( + <ContextMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className + )} + {...props} /> +)) +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName + +const ContextMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( + <ContextMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props}> + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.CheckboxItem> +)) +ContextMenuCheckboxItem.displayName = + ContextMenuPrimitive.CheckboxItem.displayName + +const ContextMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( + <ContextMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props}> + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <ContextMenuPrimitive.ItemIndicator> + <Circle className="h-4 w-4 fill-current" /> + </ContextMenuPrimitive.ItemIndicator> + </span> + {children} + </ContextMenuPrimitive.RadioItem> +)) +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName + +const ContextMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( + <ContextMenuPrimitive.Label + ref={ref} + className={cn( + "px-2 py-1.5 text-sm font-semibold text-foreground", + inset && "pl-8", + className + )} + {...props} /> +)) +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName + +const ContextMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( + <ContextMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-border", className)} + {...props} /> +)) +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName + +const ContextMenuShortcut = ({ + className, + ...props +}) => { + return ( + <span + className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} + {...props} /> + ); +} +ContextMenuShortcut.displayName = "ContextMenuShortcut" + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/dialog.jsx b/apps/web/src/components/ui/dialog.jsx new file mode 100644 index 0000000..e7fda2f --- /dev/null +++ b/apps/web/src/components/ui/dialog.jsx @@ -0,0 +1,94 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( + <DialogPrimitive.Overlay + ref={ref} + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} /> +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( + <DialogPortal> + <DialogOverlay /> + <DialogPrimitive.Content + ref={ref} + className={cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg", + className + )} + {...props}> + {children} + <DialogPrimitive.Close + className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </DialogPrimitive.Close> + </DialogPrimitive.Content> + </DialogPortal> +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}) => ( + <div + className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} + {...props} /> +) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}) => ( + <div + className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} + {...props} /> +) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( + <DialogPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold leading-none tracking-tight", className)} + {...props} /> +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( + <DialogPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} /> +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/drawer.jsx b/apps/web/src/components/ui/drawer.jsx new file mode 100644 index 0000000..261339d --- /dev/null +++ b/apps/web/src/components/ui/drawer.jsx @@ -0,0 +1,90 @@ +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +const Drawer = ({ + shouldScaleBackground = true, + ...props +}) => ( + <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} /> +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef(({ className, ...props }, ref) => ( + <DrawerPrimitive.Overlay + ref={ref} + className={cn("fixed inset-0 z-50 bg-black/80", className)} + {...props} /> +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef(({ className, children, ...props }, ref) => ( + <DrawerPortal> + <DrawerOverlay /> + <DrawerPrimitive.Content + ref={ref} + className={cn( + "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background", + className + )} + {...props}> + <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" /> + {children} + </DrawerPrimitive.Content> + </DrawerPortal> +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}) => ( + <div + className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} + {...props} /> +) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}) => ( + <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} /> +) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef(({ className, ...props }, ref) => ( + <DrawerPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold leading-none tracking-tight", className)} + {...props} /> +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef(({ className, ...props }, ref) => ( + <DrawerPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} /> +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/dropdown-menu.jsx b/apps/web/src/components/ui/dropdown-menu.jsx new file mode 100644 index 0000000..cd34591 --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.jsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( + <DropdownMenuPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + inset && "pl-8", + className + )} + {...props}> + {children} + <ChevronRight className="ml-auto" /> + </DropdownMenuPrimitive.SubTrigger> +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", + className + )} + {...props} /> +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( + <DropdownMenuPrimitive.Portal> + <DropdownMenuPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", + className + )} + {...props} /> + </DropdownMenuPrimitive.Portal> +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + inset && "pl-8", + className + )} + {...props} /> +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( + <DropdownMenuPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props}> + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.CheckboxItem> +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( + <DropdownMenuPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props}> + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.ItemIndicator> + <Circle className="h-2 w-2 fill-current" /> + </DropdownMenuPrimitive.ItemIndicator> + </span> + {children} + </DropdownMenuPrimitive.RadioItem> +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( + <DropdownMenuPrimitive.Label + ref={ref} + className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} + {...props} /> +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( + <DropdownMenuPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} /> +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}) => { + return ( + <span + className={cn("ml-auto text-xs tracking-widest opacity-60", className)} + {...props} /> + ); +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/empty.jsx b/apps/web/src/components/ui/empty.jsx new file mode 100644 index 0000000..d8a17fc --- /dev/null +++ b/apps/web/src/components/ui/empty.jsx @@ -0,0 +1,110 @@ +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +function Empty({ + className, + ...props +}) { + return ( + <div + data-slot="empty" + className={cn( + "flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12", + className + )} + {...props} /> + ); +} + +function EmptyHeader({ + className, + ...props +}) { + return ( + <div + data-slot="empty-header" + className={cn("flex max-w-sm flex-col items-center gap-2 text-center", className)} + {...props} /> + ); +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}) { + return ( + <div + data-slot="empty-icon" + data-variant={variant} + className={cn(emptyMediaVariants({ variant, className }))} + {...props} /> + ); +} + +function EmptyTitle({ + className, + ...props +}) { + return ( + <div + data-slot="empty-title" + className={cn("text-lg font-medium tracking-tight", className)} + {...props} /> + ); +} + +function EmptyDescription({ + className, + ...props +}) { + return ( + <div + data-slot="empty-description" + className={cn( + "text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} /> + ); +} + +function EmptyContent({ + className, + ...props +}) { + return ( + <div + data-slot="empty-content" + className={cn( + "flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm", + className + )} + {...props} /> + ); +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/field.jsx b/apps/web/src/components/ui/field.jsx new file mode 100644 index 0000000..5f68754 --- /dev/null +++ b/apps/web/src/components/ui/field.jsx @@ -0,0 +1,234 @@ +import { useMemo } from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ + className, + ...props +}) { + return ( + <fieldset + data-slot="field-set" + className={cn( + "flex flex-col gap-6", + "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} /> + ); +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}) { + return ( + <legend + data-slot="field-legend" + data-variant={variant} + className={cn( + "mb-3 font-medium", + "data-[variant=legend]:text-base", + "data-[variant=label]:text-sm", + className + )} + {...props} /> + ); +} + +function FieldGroup({ + className, + ...props +}) { + return ( + <div + data-slot="field-group" + className={cn( + "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", + className + )} + {...props} /> + ); +} + +const fieldVariants = cva("group/field data-[invalid=true]:text-destructive flex w-full gap-3", { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px has-[>[data-slot=field-content]]:items-start", + ], + responsive: [ + "@md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto flex-col [&>*]:w-full [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, +}) + +function Field({ + className, + orientation = "vertical", + ...props +}) { + return ( + <div + role="group" + data-slot="field" + data-orientation={orientation} + className={cn(fieldVariants({ orientation }), className)} + {...props} /> + ); +} + +function FieldContent({ + className, + ...props +}) { + return ( + <div + data-slot="field-content" + className={cn("group/field-content flex flex-1 flex-col gap-1.5 leading-snug", className)} + {...props} /> + ); +} + +function FieldLabel({ + className, + ...props +}) { + return ( + <Label + data-slot="field-label" + className={cn( + "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", + "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>[data-slot=field]]:p-4", + "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", + className + )} + {...props} /> + ); +} + +function FieldTitle({ + className, + ...props +}) { + return ( + <div + data-slot="field-label" + className={cn( + "flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50", + className + )} + {...props} /> + ); +} + +function FieldDescription({ + className, + ...props +}) { + return ( + <p + data-slot="field-description" + className={cn( + "text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance", + "nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5", + "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} /> + ); +} + +function FieldSeparator({ + children, + className, + ...props +}) { + return ( + <div + data-slot="field-separator" + data-content={!!children} + className={cn( + "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", + className + )} + {...props}> + <Separator className="absolute inset-0 top-1/2" /> + {children && ( + <span + className="bg-background text-muted-foreground relative mx-auto block w-fit px-2" + data-slot="field-separator-content"> + {children} + </span> + )} + </div> + ); +} + +function FieldError({ + className, + children, + errors, + ...props +}) { + const content = useMemo(() => { + if (children) { + return children + } + + if (!errors) { + return null + } + + if (errors?.length === 1 && errors[0]?.message) { + return errors[0].message + } + + return ( + <ul className="ml-4 flex list-disc flex-col gap-1"> + {errors.map((error, index) => + error?.message && <li key={index}>{error.message}</li>)} + </ul> + ); + }, [children, errors]) + + if (!content) { + return null + } + + return ( + <div + role="alert" + data-slot="field-error" + className={cn("text-destructive text-sm font-normal", className)} + {...props}> + {content} + </div> + ); +} + +export { + Field, + FieldLabel, + FieldDescription, + FieldError, + FieldGroup, + FieldLegend, + FieldSeparator, + FieldSet, + FieldContent, + FieldTitle, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/form.jsx b/apps/web/src/components/ui/form.jsx new file mode 100644 index 0000000..bb7cbf3 --- /dev/null +++ b/apps/web/src/components/ui/form.jsx @@ -0,0 +1,138 @@ +"use client"; +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { Controller, FormProvider, useFormContext } from "react-hook-form"; + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +const FormFieldContext = React.createContext(null) + +const FormField = ( + { + ...props + } +) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ); +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + if (!itemContext) { + throw new Error("useFormField should be used within <FormItem>") + } + + const fieldState = getFieldState(fieldContext.name, formState) + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +const FormItemContext = React.createContext(null) + +const FormItem = React.forwardRef(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ); +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} /> + ); +}) +FormLabel.displayName = "FormLabel" + +const FormControl = React.forwardRef(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} /> + ); +}) +FormControl.displayName = "FormControl" + +const FormDescription = React.forwardRef(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-[0.8rem] text-muted-foreground", className)} + {...props} /> + ); +}) +FormDescription.displayName = "FormDescription" + +const FormMessage = React.forwardRef(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message ?? "") : children + + if (!body) { + return null + } + + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-[0.8rem] font-medium text-destructive", className)} + {...props}> + {body} + </p> + ); +}) +FormMessage.displayName = "FormMessage" + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/hover-card.jsx b/apps/web/src/components/ui/hover-card.jsx new file mode 100644 index 0000000..8725485 --- /dev/null +++ b/apps/web/src/components/ui/hover-card.jsx @@ -0,0 +1,25 @@ +"use client" + +import * as React from "react" +import * as HoverCardPrimitive from "@radix-ui/react-hover-card" + +import { cn } from "@/lib/utils" + +const HoverCard = HoverCardPrimitive.Root + +const HoverCardTrigger = HoverCardPrimitive.Trigger + +const HoverCardContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <HoverCardPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]", + className + )} + {...props} /> +)) +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName + +export { HoverCard, HoverCardTrigger, HoverCardContent } \ No newline at end of file diff --git a/apps/web/src/components/ui/input-group.jsx b/apps/web/src/components/ui/input-group.jsx new file mode 100644 index 0000000..e3fa5c1 --- /dev/null +++ b/apps/web/src/components/ui/input-group.jsx @@ -0,0 +1,160 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ + className, + ...props +}) { + return ( + <div + data-slot="input-group" + role="group" + className={cn( + "group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]", + "h-9 has-[>textarea]:h-auto", + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1", + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + className + )} + {...props} /> + ); +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}) { + return ( + <div + role="group" + data-slot="input-group-addon" + data-align={align} + className={cn(inputGroupAddonVariants({ align }), className)} + onClick={(e) => { + if ((e.target).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} /> + ); +} + +const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, +}) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}) { + return ( + <Button + type={type} + data-size={size} + variant={variant} + className={cn(inputGroupButtonVariants({ size }), className)} + {...props} /> + ); +} + +function InputGroupText({ + className, + ...props +}) { + return ( + <span + className={cn( + "text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none", + className + )} + {...props} /> + ); +} + +function InputGroupInput({ + className, + ...props +}) { + return ( + <Input + data-slot="input-group-control" + className={cn( + "flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent", + className + )} + {...props} /> + ); +} + +function InputGroupTextarea({ + className, + ...props +}) { + return ( + <Textarea + data-slot="input-group-control" + className={cn( + "flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent", + className + )} + {...props} /> + ); +} + +export { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupText, + InputGroupInput, + InputGroupTextarea, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/input-otp.jsx b/apps/web/src/components/ui/input-otp.jsx new file mode 100644 index 0000000..f3087d4 --- /dev/null +++ b/apps/web/src/components/ui/input-otp.jsx @@ -0,0 +1,53 @@ +import * as React from "react" +import { OTPInput, OTPInputContext } from "input-otp" +import { Minus } from "lucide-react" + +import { cn } from "@/lib/utils" + +const InputOTP = React.forwardRef(({ className, containerClassName, ...props }, ref) => ( + <OTPInput + ref={ref} + containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)} + className={cn("disabled:cursor-not-allowed", className)} + {...props} /> +)) +InputOTP.displayName = "InputOTP" + +const InputOTPGroup = React.forwardRef(({ className, ...props }, ref) => ( + <div ref={ref} className={cn("flex items-center", className)} {...props} /> +)) +InputOTPGroup.displayName = "InputOTPGroup" + +const InputOTPSlot = React.forwardRef(({ index, className, ...props }, ref) => { + const inputOTPContext = React.useContext(OTPInputContext) + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index] + + return ( + <div + ref={ref} + className={cn( + "relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md", + isActive && "z-10 ring-1 ring-ring", + className + )} + {...props}> + {char} + {hasFakeCaret && ( + <div + className="pointer-events-none absolute inset-0 flex items-center justify-center"> + <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" /> + </div> + )} + </div> + ); +}) +InputOTPSlot.displayName = "InputOTPSlot" + +const InputOTPSeparator = React.forwardRef(({ ...props }, ref) => ( + <div ref={ref} role="separator" {...props}> + <Minus /> + </div> +)) +InputOTPSeparator.displayName = "InputOTPSeparator" + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator } \ No newline at end of file diff --git a/apps/web/src/components/ui/input.jsx b/apps/web/src/components/ui/input.jsx new file mode 100644 index 0000000..625b08c --- /dev/null +++ b/apps/web/src/components/ui/input.jsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + <input + type={type} + className={cn( + "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + className + )} + ref={ref} + {...props} /> + ); +}) +Input.displayName = "Input" + +export { Input } \ No newline at end of file diff --git a/apps/web/src/components/ui/item.jsx b/apps/web/src/components/ui/item.jsx new file mode 100644 index 0000000..da93187 --- /dev/null +++ b/apps/web/src/components/ui/item.jsx @@ -0,0 +1,197 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" + +function ItemGroup({ + className, + ...props +}) { + return ( + <div + role="list" + data-slot="item-group" + className={cn("group/item-group flex flex-col", className)} + {...props} /> + ); +} + +function ItemSeparator({ + className, + ...props +}) { + return ( + <Separator + data-slot="item-separator" + orientation="horizontal" + className={cn("my-0", className)} + {...props} /> + ); +} + +const itemVariants = cva( + "group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 [a]:transition-colors flex flex-wrap items-center rounded-md border border-transparent text-sm outline-none transition-colors duration-100 focus-visible:ring-[3px]", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border-border", + muted: "bg-muted/50", + }, + size: { + default: "gap-4 p-4 ", + sm: "gap-2.5 px-4 py-3", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Item({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}) { + const Comp = asChild ? Slot : "div" + return ( + <Comp + data-slot="item" + data-variant={variant} + data-size={size} + className={cn(itemVariants({ variant, size, className }))} + {...props} /> + ); +} + +const itemMediaVariants = cva( + "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none", + { + variants: { + variant: { + default: "bg-transparent", + icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4", + image: + "size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function ItemMedia({ + className, + variant = "default", + ...props +}) { + return ( + <div + data-slot="item-media" + data-variant={variant} + className={cn(itemMediaVariants({ variant, className }))} + {...props} /> + ); +} + +function ItemContent({ + className, + ...props +}) { + return ( + <div + data-slot="item-content" + className={cn( + "flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none", + className + )} + {...props} /> + ); +} + +function ItemTitle({ + className, + ...props +}) { + return ( + <div + data-slot="item-title" + className={cn( + "flex w-fit items-center gap-2 text-sm font-medium leading-snug", + className + )} + {...props} /> + ); +} + +function ItemDescription({ + className, + ...props +}) { + return ( + <p + data-slot="item-description" + className={cn( + "text-muted-foreground line-clamp-2 text-balance text-sm font-normal leading-normal", + "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", + className + )} + {...props} /> + ); +} + +function ItemActions({ + className, + ...props +}) { + return ( + <div + data-slot="item-actions" + className={cn("flex items-center gap-2", className)} + {...props} /> + ); +} + +function ItemHeader({ + className, + ...props +}) { + return ( + <div + data-slot="item-header" + className={cn("flex basis-full items-center justify-between gap-2", className)} + {...props} /> + ); +} + +function ItemFooter({ + className, + ...props +}) { + return ( + <div + data-slot="item-footer" + className={cn("flex basis-full items-center justify-between gap-2", className)} + {...props} /> + ); +} + +export { + Item, + ItemMedia, + ItemContent, + ItemActions, + ItemGroup, + ItemSeparator, + ItemTitle, + ItemDescription, + ItemHeader, + ItemFooter, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/kbd.jsx b/apps/web/src/components/ui/kbd.jsx new file mode 100644 index 0000000..9d98a02 --- /dev/null +++ b/apps/web/src/components/ui/kbd.jsx @@ -0,0 +1,32 @@ +import { cn } from "@/lib/utils" + +function Kbd({ + className, + ...props +}) { + return ( + <kbd + data-slot="kbd" + className={cn( + "bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 select-none items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium", + "[&_svg:not([class*='size-'])]:size-3", + "[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10", + className + )} + {...props} /> + ); +} + +function KbdGroup({ + className, + ...props +}) { + return ( + <kbd + data-slot="kbd-group" + className={cn("inline-flex items-center gap-1", className)} + {...props} /> + ); +} + +export { Kbd, KbdGroup } \ No newline at end of file diff --git a/apps/web/src/components/ui/label.jsx b/apps/web/src/components/ui/label.jsx new file mode 100644 index 0000000..524b0cb --- /dev/null +++ b/apps/web/src/components/ui/label.jsx @@ -0,0 +1,18 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef(({ className, ...props }, ref) => ( + <LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} /> +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } \ No newline at end of file diff --git a/apps/web/src/components/ui/menubar.jsx b/apps/web/src/components/ui/menubar.jsx new file mode 100644 index 0000000..89c4655 --- /dev/null +++ b/apps/web/src/components/ui/menubar.jsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as MenubarPrimitive from "@radix-ui/react-menubar" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +function MenubarMenu({ + ...props +}) { + return <MenubarPrimitive.Menu {...props} />; +} + +function MenubarGroup({ + ...props +}) { + return <MenubarPrimitive.Group {...props} />; +} + +function MenubarPortal({ + ...props +}) { + return <MenubarPrimitive.Portal {...props} />; +} + +function MenubarRadioGroup({ + ...props +}) { + return <MenubarPrimitive.RadioGroup {...props} />; +} + +function MenubarSub({ + ...props +}) { + return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />; +} + +const Menubar = React.forwardRef(({ className, ...props }, ref) => ( + <MenubarPrimitive.Root + ref={ref} + className={cn( + "flex h-9 items-center space-x-1 rounded-md border bg-background p-1 shadow-sm", + className + )} + {...props} /> +)) +Menubar.displayName = MenubarPrimitive.Root.displayName + +const MenubarTrigger = React.forwardRef(({ className, ...props }, ref) => ( + <MenubarPrimitive.Trigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-3 py-1 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + className + )} + {...props} /> +)) +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName + +const MenubarSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( + <MenubarPrimitive.SubTrigger + ref={ref} + className={cn( + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", + inset && "pl-8", + className + )} + {...props}> + {children} + <ChevronRight className="ml-auto h-4 w-4" /> + </MenubarPrimitive.SubTrigger> +)) +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName + +const MenubarSubContent = React.forwardRef(({ className, ...props }, ref) => ( + <MenubarPrimitive.SubContent + ref={ref} + className={cn( + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]", + className + )} + {...props} /> +)) +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName + +const MenubarContent = React.forwardRef(( + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, + ref +) => ( + <MenubarPrimitive.Portal> + <MenubarPrimitive.Content + ref={ref} + align={align} + alignOffset={alignOffset} + sideOffset={sideOffset} + className={cn( + "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]", + className + )} + {...props} /> + </MenubarPrimitive.Portal> +)) +MenubarContent.displayName = MenubarPrimitive.Content.displayName + +const MenubarItem = React.forwardRef(({ className, inset, ...props }, ref) => ( + <MenubarPrimitive.Item + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + inset && "pl-8", + className + )} + {...props} /> +)) +MenubarItem.displayName = MenubarPrimitive.Item.displayName + +const MenubarCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( + <MenubarPrimitive.CheckboxItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + checked={checked} + {...props}> + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.CheckboxItem> +)) +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName + +const MenubarRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( + <MenubarPrimitive.RadioItem + ref={ref} + className={cn( + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props}> + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <MenubarPrimitive.ItemIndicator> + <Circle className="h-4 w-4 fill-current" /> + </MenubarPrimitive.ItemIndicator> + </span> + {children} + </MenubarPrimitive.RadioItem> +)) +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName + +const MenubarLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( + <MenubarPrimitive.Label + ref={ref} + className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} + {...props} /> +)) +MenubarLabel.displayName = MenubarPrimitive.Label.displayName + +const MenubarSeparator = React.forwardRef(({ className, ...props }, ref) => ( + <MenubarPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} /> +)) +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName + +const MenubarShortcut = ({ + className, + ...props +}) => { + return ( + <span + className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} + {...props} /> + ); +} +MenubarShortcut.displayname = "MenubarShortcut" + +export { + Menubar, + MenubarMenu, + MenubarTrigger, + MenubarContent, + MenubarItem, + MenubarSeparator, + MenubarLabel, + MenubarCheckboxItem, + MenubarRadioGroup, + MenubarRadioItem, + MenubarPortal, + MenubarSubContent, + MenubarSubTrigger, + MenubarGroup, + MenubarSub, + MenubarShortcut, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/navigation-menu.jsx b/apps/web/src/components/ui/navigation-menu.jsx new file mode 100644 index 0000000..4363894 --- /dev/null +++ b/apps/web/src/components/ui/navigation-menu.jsx @@ -0,0 +1,104 @@ +import * as React from "react" +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" +import { cva } from "class-variance-authority" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const NavigationMenu = React.forwardRef(({ className, children, ...props }, ref) => ( + <NavigationMenuPrimitive.Root + ref={ref} + className={cn( + "relative z-10 flex max-w-max flex-1 items-center justify-center", + className + )} + {...props}> + {children} + <NavigationMenuViewport /> + </NavigationMenuPrimitive.Root> +)) +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName + +const NavigationMenuList = React.forwardRef(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.List + ref={ref} + className={cn( + "group flex flex-1 list-none items-center justify-center space-x-1", + className + )} + {...props} /> +)) +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName + +const NavigationMenuItem = NavigationMenuPrimitive.Item + +const navigationMenuTriggerStyle = cva( + "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent" +) + +const NavigationMenuTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( + <NavigationMenuPrimitive.Trigger + ref={ref} + className={cn(navigationMenuTriggerStyle(), "group", className)} + {...props}> + {children}{" "} + <ChevronDown + className="relative top-[1px] ml-1 h-3 w-3 transition duration-300 group-data-[state=open]:rotate-180" + aria-hidden="true" /> + </NavigationMenuPrimitive.Trigger> +)) +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName + +const NavigationMenuContent = React.forwardRef(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.Content + ref={ref} + className={cn( + "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ", + className + )} + {...props} /> +)) +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName + +const NavigationMenuLink = NavigationMenuPrimitive.Link + +const NavigationMenuViewport = React.forwardRef(({ className, ...props }, ref) => ( + <div className={cn("absolute left-0 top-full flex justify-center")}> + <NavigationMenuPrimitive.Viewport + className={cn( + "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]", + className + )} + ref={ref} + {...props} /> + </div> +)) +NavigationMenuViewport.displayName = + NavigationMenuPrimitive.Viewport.displayName + +const NavigationMenuIndicator = React.forwardRef(({ className, ...props }, ref) => ( + <NavigationMenuPrimitive.Indicator + ref={ref} + className={cn( + "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in", + className + )} + {...props}> + <div + className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" /> + </NavigationMenuPrimitive.Indicator> +)) +NavigationMenuIndicator.displayName = + NavigationMenuPrimitive.Indicator.displayName + +export { + navigationMenuTriggerStyle, + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuContent, + NavigationMenuTrigger, + NavigationMenuLink, + NavigationMenuIndicator, + NavigationMenuViewport, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/pagination.jsx b/apps/web/src/components/ui/pagination.jsx new file mode 100644 index 0000000..e30886e --- /dev/null +++ b/apps/web/src/components/ui/pagination.jsx @@ -0,0 +1,100 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button"; + +const Pagination = ({ + className, + ...props +}) => ( + <nav + role="navigation" + aria-label="pagination" + className={cn("mx-auto flex w-full justify-center", className)} + {...props} /> +) +Pagination.displayName = "Pagination" + +const PaginationContent = React.forwardRef(({ className, ...props }, ref) => ( + <ul + ref={ref} + className={cn("flex flex-row items-center gap-1", className)} + {...props} /> +)) +PaginationContent.displayName = "PaginationContent" + +const PaginationItem = React.forwardRef(({ className, ...props }, ref) => ( + <li ref={ref} className={cn("", className)} {...props} /> +)) +PaginationItem.displayName = "PaginationItem" + +const PaginationLink = ({ + className, + isActive, + size = "icon", + ...props +}) => ( + <a + aria-current={isActive ? "page" : undefined} + className={cn(buttonVariants({ + variant: isActive ? "outline" : "ghost", + size, + }), className)} + {...props} /> +) +PaginationLink.displayName = "PaginationLink" + +const PaginationPrevious = ({ + className, + ...props +}) => ( + <PaginationLink + aria-label="Go to previous page" + size="default" + className={cn("gap-1 pl-2.5", className)} + {...props}> + <ChevronLeft className="h-4 w-4" /> + <span>Previous</span> + </PaginationLink> +) +PaginationPrevious.displayName = "PaginationPrevious" + +const PaginationNext = ({ + className, + ...props +}) => ( + <PaginationLink + aria-label="Go to next page" + size="default" + className={cn("gap-1 pr-2.5", className)} + {...props}> + <span>Next</span> + <ChevronRight className="h-4 w-4" /> + </PaginationLink> +) +PaginationNext.displayName = "PaginationNext" + +const PaginationEllipsis = ({ + className, + ...props +}) => ( + <span + aria-hidden + className={cn("flex h-9 w-9 items-center justify-center", className)} + {...props}> + <MoreHorizontal className="h-4 w-4" /> + <span className="sr-only">More pages</span> + </span> +) +PaginationEllipsis.displayName = "PaginationEllipsis" + +export { + Pagination, + PaginationContent, + PaginationLink, + PaginationItem, + PaginationPrevious, + PaginationNext, + PaginationEllipsis, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/popover.jsx b/apps/web/src/components/ui/popover.jsx new file mode 100644 index 0000000..e7fa724 --- /dev/null +++ b/apps/web/src/components/ui/popover.jsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + <PopoverPrimitive.Portal> + <PopoverPrimitive.Content + ref={ref} + align={align} + sideOffset={sideOffset} + className={cn( + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]", + className + )} + {...props} /> + </PopoverPrimitive.Portal> +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } \ No newline at end of file diff --git a/apps/web/src/components/ui/progress.jsx b/apps/web/src/components/ui/progress.jsx new file mode 100644 index 0000000..e3a0b5b --- /dev/null +++ b/apps/web/src/components/ui/progress.jsx @@ -0,0 +1,23 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef(({ className, value, ...props }, ref) => ( + <ProgressPrimitive.Root + ref={ref} + className={cn( + "relative h-2 w-full overflow-hidden rounded-full bg-primary/20", + className + )} + {...props}> + <ProgressPrimitive.Indicator + className="h-full w-full flex-1 bg-primary transition-all" + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} /> + </ProgressPrimitive.Root> +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } \ No newline at end of file diff --git a/apps/web/src/components/ui/radio-group.jsx b/apps/web/src/components/ui/radio-group.jsx new file mode 100644 index 0000000..fa108ba --- /dev/null +++ b/apps/web/src/components/ui/radio-group.jsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" +import { Circle } from "lucide-react" + +import { cn } from "@/lib/utils" + +const RadioGroup = React.forwardRef(({ className, ...props }, ref) => { + return (<RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />); +}) +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName + +const RadioGroupItem = React.forwardRef(({ className, ...props }, ref) => { + return ( + <RadioGroupPrimitive.Item + ref={ref} + className={cn( + "aspect-square h-4 w-4 rounded-full border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50", + className + )} + {...props}> + <RadioGroupPrimitive.Indicator className="flex items-center justify-center"> + <Circle className="h-3.5 w-3.5 fill-primary" /> + </RadioGroupPrimitive.Indicator> + </RadioGroupPrimitive.Item> + ); +}) +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName + +export { RadioGroup, RadioGroupItem } \ No newline at end of file diff --git a/apps/web/src/components/ui/resizable.jsx b/apps/web/src/components/ui/resizable.jsx new file mode 100644 index 0000000..db588f8 --- /dev/null +++ b/apps/web/src/components/ui/resizable.jsx @@ -0,0 +1,52 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { GripVertical } from "lucide-react"; +import { Group, Panel, Separator } from "react-resizable-panels"; + +import { cn } from "@/lib/utils"; + +const PanelGroupDirectionContext = createContext("horizontal"); + +const ResizablePanelGroup = ({ + className, + direction, + orientation = direction ?? "horizontal", + ...props +}) => ( + <PanelGroupDirectionContext.Provider value={orientation}> + <Group + className={cn( + "flex h-full w-full data-[panel-group-direction=vertical]:flex-col", + className, + )} + data-panel-group-direction={orientation} + orientation={orientation} + {...props} + /> + </PanelGroupDirectionContext.Provider> +); + +const ResizablePanel = Panel; + +const ResizableHandle = ({ withHandle, className, ...props }) => { + const direction = useContext(PanelGroupDirectionContext); + return ( + <Separator + className={cn( + "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90", + className, + )} + data-panel-group-direction={direction} + {...props} + > + {withHandle && ( + <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border"> + <GripVertical className="h-2.5 w-2.5" /> + </div> + )} + </Separator> + ); +}; + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; \ No newline at end of file diff --git a/apps/web/src/components/ui/scroll-area.jsx b/apps/web/src/components/ui/scroll-area.jsx new file mode 100644 index 0000000..e1b3b41 --- /dev/null +++ b/apps/web/src/components/ui/scroll-area.jsx @@ -0,0 +1,38 @@ +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +const ScrollArea = React.forwardRef(({ className, children, ...props }, ref) => ( + <ScrollAreaPrimitive.Root + ref={ref} + className={cn("relative overflow-hidden", className)} + {...props}> + <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]"> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +const ScrollBar = React.forwardRef(({ className, orientation = "vertical", ...props }, ref) => ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + ref={ref} + orientation={orientation} + className={cn( + "flex touch-none select-none transition-colors", + orientation === "vertical" && + "h-full w-2.5 border-l border-l-transparent p-[1px]", + orientation === "horizontal" && + "h-2.5 flex-col border-t border-t-transparent p-[1px]", + className + )} + {...props}> + <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName + +export { ScrollArea, ScrollBar } \ No newline at end of file diff --git a/apps/web/src/components/ui/select.jsx b/apps/web/src/components/ui/select.jsx new file mode 100644 index 0000000..301b70d --- /dev/null +++ b/apps/web/src/components/ui/select.jsx @@ -0,0 +1,119 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Trigger + ref={ref} + className={cn( + "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1", + className + )} + {...props}> + {children} + <SelectPrimitive.Icon asChild> + <ChevronDown className="h-4 w-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollUpButton + ref={ref} + className={cn("flex cursor-default items-center justify-center py-1", className)} + {...props}> + <ChevronUp className="h-4 w-4" /> + </SelectPrimitive.ScrollUpButton> +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => ( + <SelectPrimitive.ScrollDownButton + ref={ref} + className={cn("flex cursor-default items-center justify-center py-1", className)} + {...props}> + <ChevronDown className="h-4 w-4" /> + </SelectPrimitive.ScrollDownButton> +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + ref={ref} + className={cn( + "relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className + )} + position={position} + {...props}> + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn("p-1", position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}> + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef(({ className, ...props }, ref) => ( + <SelectPrimitive.Label + ref={ref} + className={cn("px-2 py-1.5 text-sm font-semibold", className)} + {...props} /> +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => ( + <SelectPrimitive.Item + ref={ref} + className={cn( + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + className + )} + {...props}> + <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <Check className="h-4 w-4" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => ( + <SelectPrimitive.Separator + ref={ref} + className={cn("-mx-1 my-1 h-px bg-muted", className)} + {...props} /> +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/separator.jsx b/apps/web/src/components/ui/separator.jsx new file mode 100644 index 0000000..f35905c --- /dev/null +++ b/apps/web/src/components/ui/separator.jsx @@ -0,0 +1,25 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@/lib/utils" + +const Separator = React.forwardRef(( + { className, orientation = "horizontal", decorative = true, ...props }, + ref +) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "shrink-0 bg-border", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className + )} + {...props} /> +)) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } \ No newline at end of file diff --git a/apps/web/src/components/ui/sheet.jsx b/apps/web/src/components/ui/sheet.jsx new file mode 100644 index 0000000..707add4 --- /dev/null +++ b/apps/web/src/components/ui/sheet.jsx @@ -0,0 +1,109 @@ +"use client"; +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva } from "class-variance-authority"; +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef(({ className, ...props }, ref) => ( + <SheetPrimitive.Overlay + className={cn( + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + className + )} + {...props} + ref={ref} /> +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +const SheetContent = React.forwardRef(({ side = "right", className, children, ...props }, ref) => ( + <SheetPortal> + <SheetOverlay /> + <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> + <SheetPrimitive.Close + className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"> + <X className="h-4 w-4" /> + <span className="sr-only">Close</span> + </SheetPrimitive.Close> + {children} + </SheetPrimitive.Content> + </SheetPortal> +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}) => ( + <div + className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} + {...props} /> +) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}) => ( + <div + className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} + {...props} /> +) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef(({ className, ...props }, ref) => ( + <SheetPrimitive.Title + ref={ref} + className={cn("text-lg font-semibold text-foreground", className)} + {...props} /> +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef(({ className, ...props }, ref) => ( + <SheetPrimitive.Description + ref={ref} + className={cn("text-sm text-muted-foreground", className)} + {...props} /> +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/sidebar.jsx b/apps/web/src/components/ui/sidebar.jsx new file mode 100644 index 0000000..05eb91b --- /dev/null +++ b/apps/web/src/components/ui/sidebar.jsx @@ -0,0 +1,629 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva } from "class-variance-authority"; +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef(( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref +) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback((value) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, [setOpenProp, open]) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open); + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown); + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo(() => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]) + + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + style={ + { + "--sidebar-width": SIDEBAR_WIDTH, + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON, + ...style + } + } + className={cn( + "group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", + className + )} + ref={ref} + {...props}> + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ); +}) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef(( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref +) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( + <div + className={cn( + "flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", + className + )} + ref={ref} + {...props}> + {children} + </div> + ); + } + + if (isMobile) { + return ( + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}> + <SheetContent + data-sidebar="sidebar" + data-mobile="true" + className="w-[--sidebar-width] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden" + style={ + { + "--sidebar-width": SIDEBAR_WIDTH_MOBILE + } + } + side={side}> + <SheetHeader className="sr-only"> + <SheetTitle>Sidebar</SheetTitle> + <SheetDescription>Displays the mobile sidebar.</SheetDescription> + </SheetHeader> + <div className="flex h-full w-full flex-col">{children}</div> + </SheetContent> + </Sheet> + ); + } + + return ( + <div + ref={ref} + className="group peer hidden text-sidebar-foreground md:block" + data-state={state} + data-collapsible={state === "collapsed" ? collapsible : ""} + data-variant={variant} + data-side={side}> + {/* This is what handles the sidebar gap on desktop */} + <div + className={cn( + "relative w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear", + "group-data-[collapsible=offcanvas]:w-0", + "group-data-[side=right]:rotate-180", + variant === "floating" || variant === "inset" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon]" + )} /> + <div + className={cn( + "fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex", + side === "left" + ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" + : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", + // Adjust the padding for floating and inset variants. + variant === "floating" || variant === "inset" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]" + : "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l", + className + )} + {...props}> + <div + data-sidebar="sidebar" + className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"> + {children} + </div> + </div> + </div> + ); +}) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + <Button + ref={ref} + data-sidebar="trigger" + variant="ghost" + size="icon" + className={cn("h-7 w-7", className)} + onClick={(event) => { + onClick?.(event) + toggleSidebar() + }} + {...props}> + <PanelLeft /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ); +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + <button + ref={ref} + data-sidebar="rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex", + "[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize", + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar", + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", + className + )} + {...props} /> + ); +}) +SidebarRail.displayName = "SidebarRail" + +const SidebarInset = React.forwardRef(({ className, ...props }, ref) => { + return ( + <main + ref={ref} + className={cn( + "relative flex w-full flex-1 flex-col bg-background", + "md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow", + className + )} + {...props} /> + ); +}) +SidebarInset.displayName = "SidebarInset" + +const SidebarInput = React.forwardRef(({ className, ...props }, ref) => { + return ( + <Input + ref={ref} + data-sidebar="input" + className={cn( + "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", + className + )} + {...props} /> + ); +}) +SidebarInput.displayName = "SidebarInput" + +const SidebarHeader = React.forwardRef(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="header" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} /> + ); +}) +SidebarHeader.displayName = "SidebarHeader" + +const SidebarFooter = React.forwardRef(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="footer" + className={cn("flex flex-col gap-2 p-2", className)} + {...props} /> + ); +}) +SidebarFooter.displayName = "SidebarFooter" + +const SidebarSeparator = React.forwardRef(({ className, ...props }, ref) => { + return ( + <Separator + ref={ref} + data-sidebar="separator" + className={cn("mx-2 w-auto bg-sidebar-border", className)} + {...props} /> + ); +}) +SidebarSeparator.displayName = "SidebarSeparator" + +const SidebarContent = React.forwardRef(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="content" + className={cn( + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden", + className + )} + {...props} /> + ); +}) +SidebarContent.displayName = "SidebarContent" + +const SidebarGroup = React.forwardRef(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="group" + className={cn("relative flex w-full min-w-0 flex-col p-2", className)} + {...props} /> + ); +}) +SidebarGroup.displayName = "SidebarGroup" + +const SidebarGroupLabel = React.forwardRef(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "div" + + return ( + <Comp + ref={ref} + data-sidebar="group-label" + className={cn( + "flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", + className + )} + {...props} /> + ); +}) +SidebarGroupLabel.displayName = "SidebarGroupLabel" + +const SidebarGroupAction = React.forwardRef(({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + ref={ref} + data-sidebar="group-action" + className={cn( + "absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} /> + ); +}) +SidebarGroupAction.displayName = "SidebarGroupAction" + +const SidebarGroupContent = React.forwardRef(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="group-content" + className={cn("w-full text-sm", className)} + {...props} /> +)) +SidebarGroupContent.displayName = "SidebarGroupContent" + +const SidebarMenu = React.forwardRef(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu" + className={cn("flex w-full min-w-0 flex-col gap-1", className)} + {...props} /> +)) +SidebarMenu.displayName = "SidebarMenu" + +const SidebarMenuItem = React.forwardRef(({ className, ...props }, ref) => ( + <li + ref={ref} + data-sidebar="menu-item" + className={cn("group/menu-item relative", className)} + {...props} /> +)) +SidebarMenuItem.displayName = "SidebarMenuItem" + +const sidebarMenuButtonVariants = cva( + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", + outline: + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", + }, + size: { + default: "h-8 text-sm", + sm: "h-7 text-xs", + lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const SidebarMenuButton = React.forwardRef(( + { + asChild = false, + isActive = false, + variant = "default", + size = "default", + tooltip, + className, + ...props + }, + ref +) => { + const Comp = asChild ? Slot : "button" + const { isMobile, state } = useSidebar() + + const button = ( + <Comp + ref={ref} + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} /> + ) + + if (!tooltip) { + return button + } + + if (typeof tooltip === "string") { + tooltip = { + children: tooltip, + } + } + + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent + side="right" + align="center" + hidden={state !== "collapsed" || isMobile} + {...tooltip} /> + </Tooltip> + ); +}) +SidebarMenuButton.displayName = "SidebarMenuButton" + +const SidebarMenuAction = React.forwardRef(({ className, asChild = false, showOnHover = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + ref={ref} + data-sidebar="menu-action" + className={cn( + "absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0", + // Increases the hit area of the button on mobile. + "after:absolute after:-inset-2 after:md:hidden", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + showOnHover && + "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", + className + )} + {...props} /> + ); +}) +SidebarMenuAction.displayName = "SidebarMenuAction" + +const SidebarMenuBadge = React.forwardRef(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="menu-badge" + className={cn( + "pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground", + "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground", + "peer-data-[size=sm]/menu-button:top-1", + "peer-data-[size=default]/menu-button:top-1.5", + "peer-data-[size=lg]/menu-button:top-2.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} /> +)) +SidebarMenuBadge.displayName = "SidebarMenuBadge" + +const SidebarMenuSkeleton = React.forwardRef(({ className, showIcon = false, ...props }, ref) => { + // Random width between 50 to 90%. + const width = React.useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%`; + }, []) + + return ( + <div + ref={ref} + data-sidebar="menu-skeleton" + className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)} + {...props}> + {showIcon && ( + <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" /> + )} + <Skeleton + className="h-4 max-w-[--skeleton-width] flex-1" + data-sidebar="menu-skeleton-text" + style={ + { + "--skeleton-width": width + } + } /> + </div> + ); +}) +SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton" + +const SidebarMenuSub = React.forwardRef(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu-sub" + className={cn( + "mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} /> +)) +SidebarMenuSub.displayName = "SidebarMenuSub" + +const SidebarMenuSubItem = React.forwardRef(({ ...props }, ref) => <li ref={ref} {...props} />) +SidebarMenuSubItem.displayName = "SidebarMenuSubItem" + +const SidebarMenuSubButton = React.forwardRef( + ({ asChild = false, size = "md", isActive, className, ...props }, ref) => { + const Comp = asChild ? Slot : "a" + + return ( + <Comp + ref={ref} + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", + size === "sm" && "text-xs", + size === "md" && "text-sm", + "group-data-[collapsible=icon]:hidden", + className + )} + {...props} /> + ); + } +) +SidebarMenuSubButton.displayName = "SidebarMenuSubButton" + +export { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarInput, + SidebarInset, + SidebarMenu, + SidebarMenuAction, + SidebarMenuBadge, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarSeparator, + SidebarTrigger, + useSidebar, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/skeleton.jsx b/apps/web/src/components/ui/skeleton.jsx new file mode 100644 index 0000000..bcd16b8 --- /dev/null +++ b/apps/web/src/components/ui/skeleton.jsx @@ -0,0 +1,14 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}) { + return ( + <div + className={cn("animate-pulse rounded-md bg-primary/10", className)} + {...props} /> + ); +} + +export { Skeleton } \ No newline at end of file diff --git a/apps/web/src/components/ui/slider.jsx b/apps/web/src/components/ui/slider.jsx new file mode 100644 index 0000000..353aafa --- /dev/null +++ b/apps/web/src/components/ui/slider.jsx @@ -0,0 +1,21 @@ +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" + +import { cn } from "@/lib/utils" + +const Slider = React.forwardRef(({ className, ...props }, ref) => ( + <SliderPrimitive.Root + ref={ref} + className={cn("relative flex w-full touch-none select-none items-center", className)} + {...props}> + <SliderPrimitive.Track + className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"> + <SliderPrimitive.Range className="absolute h-full bg-primary" /> + </SliderPrimitive.Track> + <SliderPrimitive.Thumb + className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" /> + </SliderPrimitive.Root> +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } \ No newline at end of file diff --git a/apps/web/src/components/ui/sonner.jsx b/apps/web/src/components/ui/sonner.jsx new file mode 100644 index 0000000..c2f2c98 --- /dev/null +++ b/apps/web/src/components/ui/sonner.jsx @@ -0,0 +1,28 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +const Toaster = ({ + ...props +}) => { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme} + className="toaster group" + toastOptions={{ + classNames: { + toast: + "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg", + description: "group-[.toast]:text-muted-foreground", + actionButton: + "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground", + cancelButton: + "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground", + }, + }} + {...props} /> + ); +} + +export { Toaster } \ No newline at end of file diff --git a/apps/web/src/components/ui/spinner.jsx b/apps/web/src/components/ui/spinner.jsx new file mode 100644 index 0000000..2a0b707 --- /dev/null +++ b/apps/web/src/components/ui/spinner.jsx @@ -0,0 +1,18 @@ +import { Loader2Icon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Spinner({ + className, + ...props +}) { + return ( + <Loader2Icon + role="status" + aria-label="Loading" + className={cn("size-4 animate-spin", className)} + {...props} /> + ); +} + +export { Spinner } \ No newline at end of file diff --git a/apps/web/src/components/ui/switch.jsx b/apps/web/src/components/ui/switch.jsx new file mode 100644 index 0000000..e0f9f09 --- /dev/null +++ b/apps/web/src/components/ui/switch.jsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef(({ className, ...props }, ref) => ( + <SwitchPrimitives.Root + className={cn( + "peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input", + className + )} + {...props} + ref={ref}> + <SwitchPrimitives.Thumb + className={cn( + "pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" + )} /> + </SwitchPrimitives.Root> +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } \ No newline at end of file diff --git a/apps/web/src/components/ui/table.jsx b/apps/web/src/components/ui/table.jsx new file mode 100644 index 0000000..08b6e73 --- /dev/null +++ b/apps/web/src/components/ui/table.jsx @@ -0,0 +1,86 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Table = React.forwardRef(({ className, ...props }, ref) => ( + <div className="relative w-full overflow-auto"> + <table + ref={ref} + className={cn("w-full caption-bottom text-sm", className)} + {...props} /> + </div> +)) +Table.displayName = "Table" + +const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> +)) +TableHeader.displayName = "TableHeader" + +const TableBody = React.forwardRef(({ className, ...props }, ref) => ( + <tbody + ref={ref} + className={cn("[&_tr:last-child]:border-0", className)} + {...props} /> +)) +TableBody.displayName = "TableBody" + +const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( + <tfoot + ref={ref} + className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} + {...props} /> +)) +TableFooter.displayName = "TableFooter" + +const TableRow = React.forwardRef(({ className, ...props }, ref) => ( + <tr + ref={ref} + className={cn( + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", + className + )} + {...props} /> +)) +TableRow.displayName = "TableRow" + +const TableHead = React.forwardRef(({ className, ...props }, ref) => ( + <th + ref={ref} + className={cn( + "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} /> +)) +TableHead.displayName = "TableHead" + +const TableCell = React.forwardRef(({ className, ...props }, ref) => ( + <td + ref={ref} + className={cn( + "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", + className + )} + {...props} /> +)) +TableCell.displayName = "TableCell" + +const TableCaption = React.forwardRef(({ className, ...props }, ref) => ( + <caption + ref={ref} + className={cn("mt-4 text-sm text-muted-foreground", className)} + {...props} /> +)) +TableCaption.displayName = "TableCaption" + +export { + Table, + TableHeader, + TableBody, + TableFooter, + TableHead, + TableRow, + TableCell, + TableCaption, +} \ No newline at end of file diff --git a/apps/web/src/components/ui/tabs.jsx b/apps/web/src/components/ui/tabs.jsx new file mode 100644 index 0000000..15cc449 --- /dev/null +++ b/apps/web/src/components/ui/tabs.jsx @@ -0,0 +1,41 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef(({ className, ...props }, ref) => ( + <TabsPrimitive.List + ref={ref} + className={cn( + "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", + className + )} + {...props} /> +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow", + className + )} + {...props} /> +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef(({ className, ...props }, ref) => ( + <TabsPrimitive.Content + ref={ref} + className={cn( + "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", + className + )} + {...props} /> +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } \ No newline at end of file diff --git a/apps/web/src/components/ui/textarea.jsx b/apps/web/src/components/ui/textarea.jsx new file mode 100644 index 0000000..ff39deb --- /dev/null +++ b/apps/web/src/components/ui/textarea.jsx @@ -0,0 +1,18 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Textarea = React.forwardRef(({ className, ...props }, ref) => { + return ( + <textarea + className={cn( + "flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + className + )} + ref={ref} + {...props} /> + ); +}) +Textarea.displayName = "Textarea" + +export { Textarea } \ No newline at end of file diff --git a/apps/web/src/components/ui/toast.jsx b/apps/web/src/components/ui/toast.jsx new file mode 100644 index 0000000..c5d7c71 --- /dev/null +++ b/apps/web/src/components/ui/toast.jsx @@ -0,0 +1,85 @@ +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva } from "class-variance-authority"; +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef(({ className, ...props }, ref) => ( + <ToastPrimitives.Viewport + ref={ref} + className={cn( + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", + className + )} + {...props} /> +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef(({ className, variant, ...props }, ref) => { + return ( + <ToastPrimitives.Root + ref={ref} + className={cn(toastVariants({ variant }), className)} + {...props} /> + ); +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef(({ className, ...props }, ref) => ( + <ToastPrimitives.Action + ref={ref} + className={cn( + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", + className + )} + {...props} /> +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef(({ className, ...props }, ref) => ( + <ToastPrimitives.Close + ref={ref} + className={cn( + "absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", + className + )} + toast-close="" + {...props}> + <X className="h-4 w-4" /> + </ToastPrimitives.Close> +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef(({ className, ...props }, ref) => ( + <ToastPrimitives.Title + ref={ref} + className={cn("text-sm font-semibold [&+div]:text-xs", className)} + {...props} /> +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef(({ className, ...props }, ref) => ( + <ToastPrimitives.Description ref={ref} className={cn("text-sm opacity-90", className)} {...props} /> +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +export { ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction }; \ No newline at end of file diff --git a/apps/web/src/components/ui/toaster.jsx b/apps/web/src/components/ui/toaster.jsx new file mode 100644 index 0000000..5a7c667 --- /dev/null +++ b/apps/web/src/components/ui/toaster.jsx @@ -0,0 +1,33 @@ +import { useToast } from "@/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + <ToastProvider> + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + <Toast key={id} {...props}> + <div className="grid gap-1"> + {title && <ToastTitle>{title}</ToastTitle>} + {description && ( + <ToastDescription>{description}</ToastDescription> + )} + </div> + {action} + <ToastClose /> + </Toast> + ); + })} + <ToastViewport /> + </ToastProvider> + ); +} \ No newline at end of file diff --git a/apps/web/src/components/ui/toggle-group.jsx b/apps/web/src/components/ui/toggle-group.jsx new file mode 100644 index 0000000..87ca7a0 --- /dev/null +++ b/apps/web/src/components/ui/toggle-group.jsx @@ -0,0 +1,44 @@ +"use client"; +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef(({ className, variant, size, children, ...props }, ref) => ( + <ToggleGroupPrimitive.Root + ref={ref} + className={cn("flex items-center justify-center gap-1", className)} + {...props}> + <ToggleGroupContext.Provider value={{ variant, size }}> + {children} + </ToggleGroupContext.Provider> + </ToggleGroupPrimitive.Root> +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + <ToggleGroupPrimitive.Item + ref={ref} + className={cn(toggleVariants({ + variant: context.variant || variant, + size: context.size || size, + }), className)} + {...props}> + {children} + </ToggleGroupPrimitive.Item> + ); +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } \ No newline at end of file diff --git a/apps/web/src/components/ui/toggle.jsx b/apps/web/src/components/ui/toggle.jsx new file mode 100644 index 0000000..58c8643 --- /dev/null +++ b/apps/web/src/components/ui/toggle.jsx @@ -0,0 +1,38 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-9 px-2 min-w-9", + sm: "h-8 px-1.5 min-w-8", + lg: "h-10 px-2.5 min-w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef(({ className, variant, size, ...props }, ref) => ( + <TogglePrimitive.Root + ref={ref} + className={cn(toggleVariants({ variant, size, className }))} + {...props} /> +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } \ No newline at end of file diff --git a/apps/web/src/components/ui/tooltip.jsx b/apps/web/src/components/ui/tooltip.jsx new file mode 100644 index 0000000..2c1ec9e --- /dev/null +++ b/apps/web/src/components/ui/tooltip.jsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( + <TooltipPrimitive.Portal> + <TooltipPrimitive.Content + ref={ref} + sideOffset={sideOffset} + className={cn( + "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]", + className + )} + {...props} /> + </TooltipPrimitive.Portal> +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } \ No newline at end of file diff --git a/apps/web/src/hooks/use-mobile.jsx b/apps/web/src/hooks/use-mobile.jsx new file mode 100644 index 0000000..a2db2b1 --- /dev/null +++ b/apps/web/src/hooks/use-mobile.jsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange); + }, []) + + return !!isMobile +} \ No newline at end of file diff --git a/apps/web/src/hooks/use-toast.js b/apps/web/src/hooks/use-toast.js new file mode 100644 index 0000000..64da184 --- /dev/null +++ b/apps/web/src/hooks/use-toast.js @@ -0,0 +1,155 @@ +"use client"; +// Inspired by react-hot-toast library +import * as React from "react" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST" +} + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString(); +} + +const toastTimeouts = new Map() + +const addToRemoveQueue = (toastId) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state, action) => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t), + }; + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +} + +const listeners = [] + +let memoryState = { toasts: [] } + +function dispatch(action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +function toast({ + ...props +}) { + const id = genId() + + const update = (props) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + }; + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast } \ No newline at end of file diff --git a/apps/web/src/hooks/useGoogleAnalytics.js b/apps/web/src/hooks/useGoogleAnalytics.js new file mode 100644 index 0000000..591a21e --- /dev/null +++ b/apps/web/src/hooks/useGoogleAnalytics.js @@ -0,0 +1,37 @@ +import { useCallback } from 'react'; + +export const useGoogleAnalytics = () => { + const measurementId = 'G-3DTN0F457M'; + + const initializeGA = useCallback(() => { + try { + if (typeof window !== 'undefined' && typeof window.gtag === 'function') { + console.log(`GA4 initialized with measurement ID ${measurementId}`); + } else { + console.warn('GA4 gtag is not defined on window object yet.'); + } + } catch (error) { + console.error('Error initializing GA4:', error); + } + }, [measurementId]); + + const trackPageView = useCallback((path, title) => { + try { + if (typeof window !== 'undefined' && typeof window.gtag === 'function') { + console.log(`Sending page_view event to GA4: ${path}`); + window.gtag('event', 'page_view', { + page_title: title || document.title, + page_location: window.location.href, + page_path: path, + }); + console.log('GA4 page_view event sent successfully'); + } else { + console.error('Cannot send page_view: gtag is not defined.'); + } + } catch (error) { + console.error('Error sending page_view event to GA4:', error); + } + }, []); + + return { initializeGA, trackPageView }; +}; \ No newline at end of file diff --git a/apps/web/src/index.css b/apps/web/src/index.css new file mode 100644 index 0000000..bb3be6d --- /dev/null +++ b/apps/web/src/index.css @@ -0,0 +1,159 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + /* Korrith Premium Dark Theme */ + --background: 227 43% 4%; /* #06080f */ + --card: 222 37% 7%; /* #0b0f18 */ + --foreground: 220 36% 93%; /* #e7ecf6 */ + + --card-foreground: 220 36% 93%; + --popover: 222 37% 7%; + --popover-foreground: 220 36% 93%; + + --primary: 219 100% 65%; /* #4d8bff */ + --primary-foreground: 227 43% 4%; + + --secondary: 222 37% 12%; + --secondary-foreground: 220 36% 93%; + + --muted: 222 37% 12%; + --muted-foreground: 220 36% 70%; + + --accent: 222 37% 15%; + --accent-foreground: 220 36% 93%; + + --destructive: 356 83% 68%; /* #f06a72 */ + --destructive-foreground: 227 43% 4%; + + --border: 222 37% 15%; + --input: 222 37% 15%; + --ring: 219 100% 65%; + + --radius: 8px; + + /* Korrith Custom Accents */ + --accent-ai: 178 73% 63%; /* #5fe3e0 */ + --accent-green: 160 61% 53%; /* #3ad29f */ + --accent-amber: 40 89% 60%; /* #f4b740 */ + --accent-red: 356 83% 68%; /* #f06a72 */ + --accent-purple: 270 80% 65%; /* #9b4dff */ + } + + .dark { + /* Retain same values since it's a dedicated dark theme system */ + --background: 227 43% 4%; + --card: 222 37% 7%; + --foreground: 220 36% 93%; + --card-foreground: 220 36% 93%; + --popover: 222 37% 7%; + --popover-foreground: 220 36% 93%; + --primary: 219 100% 65%; + --primary-foreground: 227 43% 4%; + --secondary: 222 37% 12%; + --secondary-foreground: 220 36% 93%; + --muted: 222 37% 12%; + --muted-foreground: 220 36% 70%; + --accent: 222 37% 15%; + --accent-foreground: 220 36% 93%; + --destructive: 356 83% 68%; + --destructive-foreground: 227 43% 4%; + --border: 222 37% 15%; + --input: 222 37% 15%; + --ring: 219 100% 65%; + } +} + +@layer base { + * { + border-color: hsl(var(--border)); + } + + body { + font-family: "Inter", -apple-system, BlinkMacSystemFont, sans-serif; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + overflow-x: hidden; + font-feature-settings: "kern" 1, "liga" 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + h1, h2, h3, h4, h5, h6 { + text-wrap: balance; + letter-spacing: -0.02em; + } + + p { + text-wrap: pretty; + line-height: 1.625; + } +} + +@layer utilities { + .font-mono { + font-family: "JetBrains Mono", monospace; + } + + .text-balance { + text-wrap: balance; + } + + .hairline-border { + border: 1px solid hsl(var(--border) / 0.5); + } + + .hairline-border-t { + border-top: 1px solid hsl(var(--border) / 0.5); + } + + .hairline-border-b { + border-bottom: 1px solid hsl(var(--border) / 0.5); + } + + .radial-glow { + position: relative; + } + + .radial-glow::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: 100%; + background: radial-gradient(circle at center, hsl(var(--primary) / 0.15) 0%, transparent 70%); + pointer-events: none; + z-index: -1; + } + + .radial-glow-top::before { + top: 0; + width: 150%; + height: 200%; + background: radial-gradient(circle at top center, hsl(var(--primary) / 0.1) 0%, transparent 50%); + } + + .radial-glow-accent::before { + background: radial-gradient(circle at center, hsl(var(--accent-ai) / 0.15) 0%, transparent 60%); + } + + .radial-glow-purple::before { + background: radial-gradient(circle at center, hsl(var(--accent-purple) / 0.15) 0%, transparent 60%); + } + + .transition-korrith { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 300ms; + } +} + +html { + scroll-behavior: smooth; +} \ No newline at end of file diff --git a/apps/web/src/lib/apiServerClient.js b/apps/web/src/lib/apiServerClient.js new file mode 100644 index 0000000..e35a73a --- /dev/null +++ b/apps/web/src/lib/apiServerClient.js @@ -0,0 +1,11 @@ +export const API_SERVER_URL = '/hcgi/api'; + +const apiServerClient = { + fetch: async (url, options = {}) => { + return await window.fetch(API_SERVER_URL + url, options); + } +}; + +export default apiServerClient; + +export { apiServerClient }; diff --git a/apps/web/src/lib/pocketbaseClient.js b/apps/web/src/lib/pocketbaseClient.js new file mode 100644 index 0000000..39362a8 --- /dev/null +++ b/apps/web/src/lib/pocketbaseClient.js @@ -0,0 +1,9 @@ +import Pocketbase from 'pocketbase'; + +const POCKETBASE_API_URL = '/hcgi/platform'; + +const pocketbaseClient = new Pocketbase(POCKETBASE_API_URL); + +export default pocketbaseClient; + +export { pocketbaseClient }; diff --git a/apps/web/src/lib/utils.js b/apps/web/src/lib/utils.js new file mode 100644 index 0000000..7fe4ab4 --- /dev/null +++ b/apps/web/src/lib/utils.js @@ -0,0 +1,6 @@ +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs) { + return twMerge(clsx(inputs)); +} \ No newline at end of file diff --git a/apps/web/src/main.jsx b/apps/web/src/main.jsx new file mode 100644 index 0000000..9e36b10 --- /dev/null +++ b/apps/web/src/main.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + <App /> +); \ No newline at end of file diff --git a/apps/web/src/pages/AboutPage.jsx b/apps/web/src/pages/AboutPage.jsx new file mode 100644 index 0000000..8c221fe --- /dev/null +++ b/apps/web/src/pages/AboutPage.jsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { ShieldCheck, Lock, FileText, Search } from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-100px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-100px" }, + transition: { staggerChildren: 0.15 } +}; + +export default function AboutPage() { + return ( + <LayoutWrapper> + <Helmet> + <title>About Korrith | Security You Can Prove + + + + {/* Hero Section */} +
+
+
+ +
+ +

+ Security you can prove. +

+

+ Korrith exists to make security something you can prove, not just describe. The conviction behind the product and where we are taking it. +

+
+
+
+ + {/* The Problem Section */} +
+
+ +

The problem we set out to solve

+
+

+ Organizations manage growing numbers of domains, IP addresses, and shifting cloud configurations. That sprawl creates an external attack surface that is hard to monitor continuously. Misconfigured headers, expiring certificates, and exposed interfaces stay common. +

+

+ Security teams are drowning in findings. Most tools flag everything they see, regardless of context, leaving engineers to sift through thousands of false positives and theoretical vulnerabilities. + We saw firsthand how this erodes trust between security and engineering. When an alert fires, it should mean something is actually wrong. +

+
+
+
+
+ + {/* The Choices Section */} +
+
+ +

The choices we made

+

+ To fix the disconnect between raw data and actual security posture, we built Korrith on a strict set of principles. +

+
+ + + {[ + { + icon: ShieldCheck, + title: "Honest by construction", + text: "We made our AI honest by construction, so it cannot invent risk." + }, + { + icon: Lock, + title: "Safe by construction", + text: "We made our active testing safe by construction, so it cannot act outside signed authorization." + }, + { + icon: FileText, + title: "Audit-grade reporting", + text: "We made our reporting audit-grade by default, so the truth the platform holds is the document you export." + }, + { + icon: Search, + title: "Evidence-backed results", + text: "We refused to ever show a clean result we could not back with evidence." + } + ].map((choice, i) => ( + +
+ +
+

{choice.title}

+

+ {choice.text} +

+
+ ))} +
+
+
+ + {/* Where We Are Going Section */} +
+
+ +

Where we are going

+
+

+ At the same time, the cryptography protecting the world's data faces a historic shift. As quantum computing advances, standard public-key cryptography will eventually become vulnerable. Moving to new standards takes years of planning, inventory, and execution. +

+

+ Korrith is being built to address both immediate misconfigurations and long-term systemic shifts: a continuous loop of external risk assessment combined with a structured path to post-quantum migration. We are building the engine to map your external assets safely, categorize services, and surface common exposure with no disruptive testing. +

+
+
+
+
+ + {/* CTA Section */} +
+
+
+ +

Experience security you can prove.

+

+ Join the organizations that are securing their infrastructure and preparing for a quantum future with Korrith. +

+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ActiveValidationPage.jsx b/apps/web/src/pages/ActiveValidationPage.jsx new file mode 100644 index 0000000..a46ec42 --- /dev/null +++ b/apps/web/src/pages/ActiveValidationPage.jsx @@ -0,0 +1,286 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + ShieldCheck, + Activity, + Key, + DoorOpen, + PowerOff, + Server, + ArrowRight, + Shield, + Network +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.1 } +}; + +export default function ActiveValidationPage() { + return ( + + + Active Validation | Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Active Validation Engine +
+ +

+ Prove what is exploitable, safely. +

+ +

+ A detected finding is a hypothesis. Active validation turns it into proof: Korrith actively confirms which exposures are genuinely reachable and exploitable, so you act on what an attacker could really use, not on theoretical noise. And it does this under a cryptographic authorization gate that makes the testing safe by construction. +

+ + +
+
+
+ + {/* What it confirms Section (Numbered/Icon Stack layout) */} +
+
+ +

+ What it confirms +

+

+ Moving beyond raw metrics to concrete proof of exploitability. +

+
+ +
+ {[ + { + icon: Activity, + title: "Reachability", + desc: "Non-destructive checks that confirm an exposure is genuinely live and reachable, turning a detected finding into a confirmed one, with no exploitation and no disruption." + }, + { + icon: Key, + title: "Weak, default, and reused credentials", + desc: "Under explicit authorization, controlled and lockout-safe checks for credentials that should not work but do, the actual vulnerability behind most real intrusions." + }, + { + icon: DoorOpen, + title: "Broken access controls", + desc: "Non-destructive confirmation that a protected resource can be reached when it should not be." + } + ].map((item, idx) => ( + +
+
+ +
+
+
+

{item.title}

+

+ {item.desc} +

+
+
+ ))} +
+
+
+ + {/* Safe by construction Bento Grid Section */} +
+
+ +

+ Safe by construction, not by promise +

+

+ This is the part most tools cannot offer responsibly. Korrith can, because of how it is built: +

+
+ + + {/* Card 1 */} + +
+ +

+ The authorization gate +

+

+ Validation only proceeds under cryptographically signed authorization. The system is designed to fail closed: without explicit, verified permission, the engine simply cannot act. +

+
+ + {/* Card 2 */} + +
+ +

+ Do no harm +

+

+ Credential checks are strictly metered to stay well under lockout thresholds. The engine would rather miss a weak credential than lock a critical account, and it masks its fingerprints to prevent unintended alerts. +

+
+ + {/* Card 3 */} + +
+ +

+ The kill-switch +

+

+ You maintain absolute control over the validation engine. An instant halt and reset control is permanently available, immediately terminating any active validation task. +

+
+ + {/* Card 4 */} + +
+ +

+ Owned assets, staging first +

+

+ Validation is strictly tied to asset ownership and engagement-based activation. We default to proving the system on staging and explicitly owned infrastructure before touching sensitive environments. +

+
+
+
+
+ + {/* Argus Orchestrates & Status Section */} +
+
+
+ + +
+ +

+ Argus orchestrates, the gate acts +

+
+

+ Argus reasons about which exposures are worth validating and in what order, but only the gated execution engine acts, and only inside the authorization. Reasoning and action are separated on purpose, with the kill-switch sitting at the action boundary. +

+
+ + +
+ +

+ Status +

+
+

+ Active validation is built, and available on your own assets first, which is how we prove it before it ever points anywhere else. +

+

+ Client-facing active testing is gated behind signed active-testing terms reviewed by counsel, because the authorization is the entire point and we will not shortcut it. +

+
+ +
+
+
+ + {/* Final CTA Section */} +
+
+ +

+ Ready to see real proof? +

+

+ Stop chasing theoretical metrics and start validating what actually matters. +

+ +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/AgenciesPage.jsx b/apps/web/src/pages/AgenciesPage.jsx new file mode 100644 index 0000000..8cdfaad --- /dev/null +++ b/apps/web/src/pages/AgenciesPage.jsx @@ -0,0 +1,298 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Building2, + BrainCircuit, + FolderTree, + Power, + Microscope, + Repeat, + ArrowRight, + ShieldAlert, + Briefcase +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +const whyItWorks = [ + { + icon: BrainCircuit, + title: "No security expertise required.", + description: "Argus does the reasoning and writes the plain-language explanation. You deliver insight, not jargon.", + color: "text-blue-400", + bg: "bg-blue-400/10 border-blue-400/20" + }, + { + icon: FolderTree, + title: "Multi-client by design.", + description: "Scope, assess, and report per client, with strict boundaries between them. Each client sees only their own posture; you see your whole book in one place.", + color: "text-purple-400", + bg: "bg-purple-400/10 border-purple-400/20" + }, + { + icon: Power, + title: "Start with no install.", + description: "The external assessment needs nothing from the client to begin, so you can lead with value before asking for access.", + color: "text-emerald-400", + bg: "bg-emerald-400/10 border-emerald-400/20" + }, + { + icon: Microscope, + title: "Go deeper when they are ready.", + description: "A read-only, outbound-only plugin or agent gives host-internal depth for clients who want a real audit, still with no risk to their site.", + color: "text-amber-400", + bg: "bg-amber-400/10 border-amber-400/20" + } +]; + +export default function AgenciesPage() { + return ( + + + Korrith for Agencies | A Security Line You Can Actually Sell + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + For Agencies +
+ +

+ Offer your clients security you can prove, without becoming a security firm. +

+ +

+ Your clients trust you with their websites and infrastructure. Security is the question they do not know how to ask and you have not had a clean way to answer. Korrith gives you that answer: assess each client's real exposure, validate what matters, and hand them proof, all under your relationship and your brand of service. +

+ + +
+
+
+ + {/* The Opportunity Section */} +
+
+ +

+ The opportunity hiding in your client base +

+

+ Every client you host or maintain is exposed in ways neither of you can currently see. With Korrith you can run a free external assessment on any client domain you manage and authorize, and show them, in plain language, what an adversary sees. That is the easiest security conversation you will ever have, and it opens a recurring line of revenue that does not require you to hire a security team. +

+
+
+
+ + {/* Why it works for an agency */} +
+
+ +

+ Why it works for an agency specifically +

+
+ + + {whyItWorks.map((item, idx) => ( + +
+ +
+ +

+ {item.title} +

+ +

+ {item.description} +

+
+ ))} +
+
+
+ + {/* Flowing Value Props: Recurring & Assess */} +
+
+ + {/* Recurring */} +
+ +
+ +
+

+ Recurring, not one-time +

+

+ A scan is a one-off. Assurance is a relationship. Korrith runs the loop continuously and proves resolution over time, which means you can charge a recurring fee for something your clients can see working month after month, and show them evidence they can forward to their own customers. +

+
+ + +
+ {/* Abstract decorative element representing loop/recurring */} +
+
+
+
+ +
+
+
+
+ + {/* Assess & Route */} +
+ +
+ {/* Abstract decorative element representing routing */} +
+
+ +
+
+
+
+
+ +
+
+
+ + +
+ +
+

+ Assess and route the hard parts +

+

+ When a fix needs specialized work, such as post-quantum cryptographic remediation, Korrith assesses and routes that subset while you keep the client relationship and the assurance layer. You stay the trusted partner; the specialist work happens behind you. +

+
+
+ +
+
+ + {/* What You Walk Away With (Final CTA) */} +
+
+ +
+ +
+ +
+ +

+ What you walk away with +

+ +

+ A new, defensible, recurring service line, delivered under your relationship, that makes your clients safer and stickier. +

+ +
+ +
+
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ArgusDetailPage.jsx b/apps/web/src/pages/ArgusDetailPage.jsx new file mode 100644 index 0000000..8189241 --- /dev/null +++ b/apps/web/src/pages/ArgusDetailPage.jsx @@ -0,0 +1,347 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { BrainCircuit, Search, ListOrdered, MessageSquare as MessageSquareText, History, ShieldAlert, ArrowRight, Sparkles } from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +export default function ArgusDetailPage() { + return ( + + + Argus | The Reasoning Engine Behind Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + The Intelligence Engine +
+ +

+ Argus reasons.
It does not guess. +

+ +

+ Argus is the security intelligence inside Korrith. It reads your real findings, connects them into attack paths, prioritizes by genuine risk, and explains every conclusion in language a board and an engineer can both follow. It is the difference between a tool that produces data and a tool that produces decisions. +

+
+
+
+ + {/* Capabilities Section - Alternating Layout */} +
+ {/* Decorative vertical line */} +
+ +
+ + {/* 1. Grounded in evidence */} + +
+
+ +
+

+ Grounded in evidence +

+

+ Every insight Argus produces is tied to a specific, verifiable finding. This is enforced, not encouraged: if Argus cannot trace a statement back to a real finding, it does not make the statement. +

+

+ You can click any insight and land on the exact evidence behind it. That is what makes Argus safe to trust, and it is the opposite of a black box that asks you to take its word. +

+
+
+
+ {/* Abstract Visual */} +
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ {/* Connecting lines */} + + + + +
+
+
+ + {/* 2. Reasoned prioritization */} + +
+
+ {/* Abstract Visual */} +
+
+
+
+
+ Priority 1 +
+
+
+
+
+
+
+ +
+

+ Reasoned prioritization, not raw severity +

+

+ A severity score tells you something is bad in the abstract. Argus tells you what matters for you. It looks at which exposures connect into a real attack path, which an adversary would reach first, and which single fix would break the most paths at once. +

+

+ The result is a short, ordered list where the top item is genuinely the most important thing you can do today, not just the highest number in a table. +

+
+
+ + {/* 3. It explains the why */} + +
+
+ +
+

+ It explains the why +

+

+ Argus writes the reasoning alongside the finding, in two registers: a plain-language version a non-technical stakeholder can act on, and a precise version an engineer can implement against. +

+

+ Findings stop being a backlog to triage and become decisions you can make. +

+
+
+
+ +
+
Executive Summary
+
+
+
+
+
+ +
+
// Engineering Context
+
+
+
+
+
+
+
+
+ + {/* 4. It reasons over time */} + +
+
+ {/* Abstract Timeline Visual */} +
+ + +
+
+ +
+
+ {/* Timeline nodes */} +
+
+
+
+
+
+ +
+

+ It reasons over time +

+

+ Argus does not just see today. It reasons longitudinally: what regressed, what is aging, how fast you are closing exposure, and where your trajectory is heading. +

+

+ That turns a point-in-time scan into a sense of whether you are getting safer or quietly slipping. +

+
+
+ + {/* 5. It respects boundaries */} + +
+
+ +
+

+ It respects boundaries +

+

+ Argus reasons within strict client boundaries and never crosses them, and it is constrained to reason only over real findings. +

+

+ It never invents risk, and unverified results are shown as unverified. Intelligence is useless if it hallucinates; Argus operates on a strict foundation of ground truth. +

+
+
+
+
+ {/* Shield abstract */} +
+
+
+
+
+
+
+
+
+
+ Boundary enforced +
+
+
+
+
+
+ +
+
+ + {/* 6. What this gives you (Summary Bento Box) */} +
+
+ +
+ +
+ +
+ +

+ What this gives you +

+ +

+ A security analyst that works at machine speed, shows its evidence, prioritizes by real risk, explains itself, and never makes anything up. +

+
+
+
+ + {/* CTA Section */} +
+
+ +

+ Intelligence layered on deep visibility +

+

+ Argus relies on the Korrith sensor grid to gather the ground truth it reasons over. +

+ +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ArgusGridPage.jsx b/apps/web/src/pages/ArgusGridPage.jsx new file mode 100644 index 0000000..04bdfc7 --- /dev/null +++ b/apps/web/src/pages/ArgusGridPage.jsx @@ -0,0 +1,313 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Network, + Layers, + Globe, + Server, + Activity, + ArrowRight, + GitMerge, + Info +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +export default function ArgusGridPage() { + return ( + + + The Argus Grid | How Korrith Works + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + The Argus Grid +
+ +

+ Three layers of depth. One reasoned view. +

+ +

+ The Argus Grid is how Korrith sees you: from the outside in, the inside out, and then, with your authorization, actively proving what matters. Three layers of assessment, reasoned together by Argus so you get one prioritized story instead of three disconnected alert streams. +

+
+
+
+ + {/* Why a Grid Section */} +
+
+
+ +
+ +
+

+ Why a grid, and not a scanner +

+
+ + +

+ A scanner gives you a flat list. An adversary does not think in lists. They think in paths: what is exposed, what that exposure connects to, and how far they can get. +

+

+ The Argus Grid is built to mirror that. Each layer adds depth, and Argus reasons across all of them to reconstruct the paths an attacker would actually walk, then tells you which fix breaks the most of them at once. +

+
+
+
+
+ + {/* Three Layers Section (Vertical Stacking / Timeline) */} +
+ {/* Vertical connecting line for large screens */} +
+ +
+ + {/* Layer 1 */} + +
+
+ LAYER 1 +
+

External attack surface

+

+ What an adversary sees first, with nothing installed. The moment you verify a domain, Korrith assesses your internet-facing surface: security headers, TLS and certificate posture, email authentication, DNS hygiene, exposed services and ports, content-management fingerprinting and outdated components, and third-party script integrity, all weighted by live exploitability intelligence. +

+

+ This layer is live the instant you onboard, because it needs nothing from inside your perimeter. +

+
+
+
+
+ +
+
+
+ + {/* Layer 2 */} + +
+
+
+ +
+
+
+
+ LAYER 2 +
+

Host-internal depth

+

+ What an attacker would find once past the perimeter. A signed, read-only, least-privilege agent reports your host's real posture as cryptographically signed evidence: software inventory and known vulnerabilities, exposed secrets, configuration drift against recognized benchmarks, identity and access posture with file-integrity monitoring, cryptographic inventory and quantum exposure, operating-system patch level and end-of-life status, and host malware indicators. +

+

+ For sites where a server agent is not appropriate, a read-only, outbound-only plugin provides the same signed-evidence posture. The agent installs only under explicit, in-product authorization, and it never modifies your system or exfiltrates data. +

+
+
+ + {/* Layer 3 */} + +
+
+ LAYER 3 +
+

Active validation

+

+ Proof, not theory. Under cryptographically signed scope, Korrith actively confirms which exposures are genuinely reachable and exploitable, turning a detected finding into a confirmed one. It starts with non-destructive reachability checks and, with explicit authorization, extends to controlled, lockout-safe validation of weak credentials and broken access controls. +

+

+ Every active test runs inside a signed authorization, an approved window, and a live kill-switch. The engine cannot act outside the scope you grant. +

+
+
+
+
+ +
+
+
+ +
+
+
+ + {/* Argus Ties It Together */} +
+
+
+ + {/* Visual connecting layers */} + +
+ +
+ {/* 3 Input nodes */} +
+ + + + + + + + + + + {/* Lines converging */} + + + + + +
+ + {/* Combined node */} +
+ + + +
+
+
+ + +

+ Argus ties it together +

+

+ A header weakness on Layer 1, a host misconfiguration on Layer 2, and a validated foothold on Layer 3 are not three separate problems. +

+

+ Argus reasons across all three to show the path they form, prioritizes by genuine risk, and explains the result in plain language. You get one fix list, ordered by what actually reduces your exposure most. +

+
+ +
+
+
+ + {/* Note Section */} +
+
+ +
+ +
+

+ Note: all three layers and the host agent are built and rolling out to the beta. Client-facing active testing on Layer 3 is gated behind active-testing terms reviewed by counsel. +

+
+
+
+ + {/* CTA Section */} +
+
+ +

+ See the grid in action +

+

+ Discover how comprehensive visibility changes the way you approach security. +

+
+ + +
+
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ArgusPage.jsx b/apps/web/src/pages/ArgusPage.jsx new file mode 100644 index 0000000..f4bc084 --- /dev/null +++ b/apps/web/src/pages/ArgusPage.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +export default function ArgusPage() { + return ( + + Argus | Korrith +
+

Argus

+

Content coming soon in Phase 2.

+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/AssessmentPage.jsx b/apps/web/src/pages/AssessmentPage.jsx new file mode 100644 index 0000000..22070a1 --- /dev/null +++ b/apps/web/src/pages/AssessmentPage.jsx @@ -0,0 +1,422 @@ +import React, { useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { motion } from 'framer-motion'; +import { + ShieldCheck, + Lock, + Mail, + Globe, + Server, + FileCode, + Code, + ArrowRight, + ShieldAlert, + Search, + CheckCircle2 +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { toast } from 'sonner'; +import apiServerClient from '@/lib/apiServerClient.js'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.1 } +}; + +const initialFormState = { + firstName: '', + lastName: '', + email: '', + industry: '', + websites: '', + applications: '', + systems: '' +}; + +export default function AssessmentPage() { + const [formData, setFormData] = useState(initialFormState); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + const handleScrollToForm = () => { + document.getElementById('assessment-form')?.scrollIntoView({ behavior: 'smooth' }); + }; + + const handleResetRequest = () => { + setFormData(initialFormState); + setIsSuccess(false); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + // Client-side validation + if (!formData.industry) { + toast.error('Please select an industry from the dropdown'); + return; + } + + setIsSubmitting(true); + + try { + const response = await apiServerClient.fetch('/assessment/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + firstName: formData.firstName, + lastName: formData.lastName, + email: formData.email, + industry: formData.industry, + websites: formData.websites, + applications: formData.applications, + systems: formData.systems + }) + }); + + if (!response.ok) { + let errorData = { error: 'Request failed' }; + try { + errorData = await response.json(); + } catch (parseErr) { + console.error('Could not parse error response:', parseErr); + } + throw new Error(errorData.error || 'Failed to submit assessment request'); + } + + // Parse success response + const data = await response.json(); + console.log('Assessment submitted successfully:', data); + + setIsSuccess(true); + toast.success('Your assessment request has been submitted successfully'); + + // Reset form fields after successful submission + setFormData(initialFormState); + + } catch (error) { + console.error('Submission error:', error); + toast.error(error.message || 'An unexpected error occurred. Please try again'); + } finally { + setIsSubmitting(false); + } + }; + + const assessmentChecks = [ + { icon: ShieldCheck, title: "Security headers", desc: "How your site instructs browsers to protect users." }, + { icon: Lock, title: "TLS & Certificates", desc: "TLS versions, cipher strength, and certificate validity and expiry." }, + { icon: Mail, title: "Email authentication", desc: "SPF, DKIM, DMARC, and MTA-STS, the records that stop your domain being spoofed." }, + { icon: Globe, title: "DNS hygiene", desc: "Including DNSSEC and CAA configurations." }, + { icon: Server, title: "Internet-exposed services", desc: "Mapping open ports and exposed administrative interfaces." }, + { icon: FileCode, title: "Content management", desc: "Fingerprinting and detecting outdated public components." }, + { icon: Code, title: "Third-party script integrity", desc: "Validating the security of external dependencies loaded by your site." }, + ]; + + return ( + + + {`Get a Free External Assessment | Korrith`} + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Assessment Engine Beta +
+ +

+ See your real exposure.
+ Start free. +

+ +

+ Verify a domain and Korrith assesses your external attack surface, with nothing to install and nothing to configure, then shows you what an adversary sees, prioritized and explained in plain language by Argus. +

+ + +
+
+
+ + {/* Why Start With The Outside */} +
+
+ +
+ +
+

Why start with the outside

+

+ The outside is where every attacker starts, and it is the part of your security you can assess with zero friction and zero risk. No agent, no access, no change to your systems. You verify that you control the domain, and Korrith looks at it the way the internet can already see it. +

+
+
+
+
+ + {/* What the free assessment looks at */} +
+
+ +

What the free assessment looks at

+

+ We analyze your public footprint comprehensively to build a true picture of your external posture. +

+
+ + + {assessmentChecks.map((item, i) => ( + +
+ +
+

{item.title}

+

{item.desc}

+
+ ))} +
+ + +

+ Each finding is weighted with known-exploited and likely-exploited intelligence, so the priorities reflect what attackers are actually using right now, not a raw severity table. +

+
+
+
+ + {/* Form Section */} +
+
+ +
+ + {isSuccess ? ( +
+ + + +

Thank you

+

+ Your assessment request has been submitted successfully. We will review your details and be in touch regarding early access to the beta. +

+ +
+ ) : ( +
+
+ +

Request your assessment

+

Provide your details to get started.

+
+ +
+ {/* User Details */} +
+
+
+ + setFormData({...formData, firstName: e.target.value})} + className="bg-background border-border h-12 px-4 text-base text-foreground placeholder:text-muted-foreground/60" + /> +
+ +
+ + setFormData({...formData, lastName: e.target.value})} + className="bg-background border-border h-12 px-4 text-base text-foreground placeholder:text-muted-foreground/60" + /> +
+
+ +
+
+ + setFormData({...formData, email: e.target.value})} + className="bg-background border-border h-12 px-4 text-base text-foreground placeholder:text-muted-foreground/60" + /> +
+ +
+ + +
+
+
+ + {/* Assessment Targets - Text Inputs */} +
+
+ +

Please provide target details for any relevant categories you wish to assess.

+
+ +
+
+ + setFormData({...formData, websites: e.target.value})} + className="bg-background border-border h-12 px-4 text-base text-foreground placeholder:text-muted-foreground/60" + /> +
+ +
+ + setFormData({...formData, applications: e.target.value})} + className="bg-background border-border h-12 px-4 text-base text-foreground placeholder:text-muted-foreground/60" + /> +
+ +
+ + setFormData({...formData, systems: e.target.value})} + className="bg-background border-border h-12 px-4 text-base text-foreground placeholder:text-muted-foreground/60" + /> +
+
+
+ +
+ +
+
+
+ )} +
+ + +
+ +
+

What it will not do

+

+ Korrith assesses only what you verify and authorize. The free assessment is external and non-intrusive. It does not log in, it does not attack, and it does not touch anything you have not authorized. +

+
+ Note: + the assessment engine is in private testing, and early access is rolling out to the beta. +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/BlogPage.jsx b/apps/web/src/pages/BlogPage.jsx new file mode 100644 index 0000000..e3524d2 --- /dev/null +++ b/apps/web/src/pages/BlogPage.jsx @@ -0,0 +1,312 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + ArrowRight, + BookOpen, + Terminal, + Shield, + FileText, + Cpu, + Clock, + Sparkles, + Lock, + Search +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; +import { Card, CardContent } from '@/components/ui/card.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.1 } +}; + +const topics = [ + { + icon: Sparkles, + text: "The modern adversary: how AI and the dark-web economy changed who you are defending against." + }, + { + icon: Search, + text: "Why a finding is worthless until it is validated, and how to tell the difference." + }, + { + icon: Cpu, + text: "Quantum readiness in practical terms, without the hype." + }, + { + icon: Shield, + text: "How active security testing can be made genuinely safe." + }, + { + icon: FileText, + text: "What audit-grade evidence really means, and why a checklist is not it." + } +]; + +const upcomingArticles = [ + { + title: "Your credentials are probably already for sale.", + subtitle: "What stealer logs and breach dumps mean for your real exposure, and how to find out." + }, + { + title: "Detected is not exploitable.", + subtitle: "Why validation is the step that separates real risk from noise." + }, + { + title: "Defense at machine speed.", + subtitle: "What it looks like to reason about security as fast as attackers now move." + }, + { + title: "Authorization as a hard boundary.", + subtitle: "How a cryptographic gate lets you run offensive testing without becoming a liability." + }, + { + title: "Harvest now, decrypt later.", + subtitle: "A plain-language guide to quantum risk for people who are not cryptographers." + } +]; + +export default function BlogPage() { + return ( + + + Korrith Blog | Security Assurance, the Threat Landscape, and Proof + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Korrith Research & Analysis +
+ +

+ Notes on proving security. +

+ +

+ Practical, opinionated writing on the modern threat landscape and what it actually takes to assess, validate, and prove your security, from the team building Korrith. No vendor fluff, no fear-selling, just useful thinking you can apply. +

+ + +
+
+
+ + {/* Featured & Latest Posts (Placeholder Layout) */} +
+
+ +

Featured Analysis

+
+ + {/* Featured Post Card (1 large span) */} + +
+ +
+
+
+ Research + + + 8 min read + +
+

+ The End of the Scanner Era: Why Detection Without Validation is Dead +

+

+ As external attack surfaces expand and vulnerability disclosures accelerate, relying on raw scanner output has become a liability. Security teams are drowning in noise. It's time for a fundamental shift toward evidence-backed validation. +

+
+ + Read featured article + +
+
+
+ {/* Abstract graphic placeholder */} +
+
+ +
+
+
+
+ + +

Latest Posts

+
+ + {/* Latest Posts Grid (2 columns to avoid the 3-card generic AI pattern) */} +
+ {[ + { + category: "Threat Intel", + title: "How to actually read a CVE", + excerpt: "Most organizations treat CVE severity scores as gospel. Here is why the base score is only half the story, and how to assess true environmental risk.", + time: "5 min read" + }, + { + category: "Engineering", + title: "Building an honest AI for security", + excerpt: "The technical choices behind Korrith's Argus engine. How we constrained the model to prevent hallucination and ensure every claim is backed by cryptographically verifiable proof.", + time: "10 min read" + } + ].map((post, i) => ( + +
+ {post.category} + + {post.time} +
+

+ {post.title} +

+

+ {post.excerpt} +

+
+ + Read post + +
+
+ ))} +
+
+
+ + {/* What we write about Section */} +
+
+
+ +

+ What we write about +

+

+ We focus on the intersection of modern infrastructure, evolving threat models, and cryptographic proof. +

+
+ + + {topics.map((topic, i) => ( + +
+ +
+

+ {topic.text} +

+
+ ))} +
+
+
+
+ + {/* Launch Articles (In Progress) Section */} +
+
+ +
+ +

+ Launch articles + In progress +

+

+ We are currently drafting our foundational series. These deep-dives will establish the technical context for the Korrith platform. +

+
+ + + {upcomingArticles.map((article, i) => ( + +
+ {(i + 1).toString().padStart(2, '0')} +
+
+

+ {article.title} +

+

+ {article.subtitle} +

+
+
+ + + +
+
+ ))} +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/BusinessesPage.jsx b/apps/web/src/pages/BusinessesPage.jsx new file mode 100644 index 0000000..323575c --- /dev/null +++ b/apps/web/src/pages/BusinessesPage.jsx @@ -0,0 +1,272 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Building, + ArrowRight, + Eye, + Crosshair, + FileCheck, + Terminal, + ActivitySquare, + Presentation +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +const howItChanges = [ + { + icon: Eye, + title: "See your true exposure.", + description: "External first, the way an adversary maps you, with host-internal depth through a read-only agent when you are ready. Honestly scored, no fake-clean.", + color: "text-blue-400" + }, + { + icon: Crosshair, + title: "Fix what actually matters.", + description: "Argus prioritizes by genuine risk and validation confirms what is real, so your team works the short list that reduces exposure most, not the long list that exhausts them.", + color: "text-rose-400" + }, + { + icon: FileCheck, + title: "Walk into audits with evidence.", + description: "Validated findings and proof of resolution are the evidence auditors ask for. You export the truth instead of assembling a story.", + color: "text-emerald-400" + } +]; + +export default function BusinessesPage() { + return ( + + + Korrith for Businesses | Prove Your Security, Do Not Just Hope + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + For Businesses +
+ +

+ Prove your security.
+ Do not just hope for it. +

+ +

+ Sooner or later someone, a customer, an auditor, a board, a regulator, asks you to demonstrate that you are secure. Korrith makes that a moment you are ready for: it assesses your real exposure, validates what an attacker could actually use, and produces the evidence you need to answer with confidence. +

+ + +
+
+
+ + {/* The Trap Section */} +
+
+ +

+ The trap most businesses are in +

+
+
+

+ You have tools that generate findings, but no way to know which ones are real, no proof that the ones you fixed are gone, and no clean evidence when someone asks. Security feels like an unending backlog and an annual scramble. Attackers, meanwhile, are faster and better resourced than ever, buying access on the dark web and moving at machine speed. +

+
+
+
+
+ + {/* How Korrith Changes That (Numbered List Layout) */} +
+
+ +

+ How Korrith changes that +

+
+ + + {howItChanges.map((item, idx) => ( + +
+ 0{idx + 1} +
+
+
+ +

+ {item.title} +

+
+

+ {item.description} +

+
+
+ ))} +
+
+
+ + {/* For the Whole Organization (Bento Grid) */} +
+
+ +

+ For the whole organization +

+
+ + + {/* Bento Item 1: Engineers (Col span 2) */} + + +

Engineers

+

+ Your engineers get a short, reasoned, explained list instead of a noise dump. +

+
+ + {/* Bento Item 2: Security Lead (Col span 1) */} + + +

Security Lead

+

+ Your security lead gets a defensible, honest posture and a trajectory over time. +

+
+ + {/* Bento Item 3: Executives (Col span 3) */} + +
+ +
+ +
+
+

Executives & Leadership

+

+ Your executives get something they can show a board, an auditor, or a customer without flinching. +

+
+
+ +
+
+
+ + {/* CTA Section */} +
+
+ +
+ +

+ Start with no commitment +

+ +

+ Run a free external assessment on your own domain. No install, no risk, and a real look at what an adversary sees. +

+ +
+ +
+
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ContactPage.jsx b/apps/web/src/pages/ContactPage.jsx new file mode 100644 index 0000000..5c79438 --- /dev/null +++ b/apps/web/src/pages/ContactPage.jsx @@ -0,0 +1,183 @@ +import React, { useState } from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { ArrowRight, Mail, Calendar, CheckCircle2 } from 'lucide-react'; + +export default function ContactPage() { + const [formData, setFormData] = useState({ + name: '', + email: '', + company: '', + message: '' + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSuccess, setIsSuccess] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + setIsSubmitting(true); + + // Simulate API call and save to local storage + setTimeout(() => { + localStorage.setItem('korrith_contact', JSON.stringify({ + ...formData, + timestamp: new Date().toISOString() + })); + setIsSubmitting(false); + setIsSuccess(true); + setFormData({ name: '', email: '', company: '', message: '' }); + }, 800); + }; + + return ( + + + Contact Us | Korrith + + + +
+
+
+ +
+ + {/* Left Column: Info */} +
+

+ Get in touch +

+

+ We are actively building our post-quantum readiness platform and welcome inquiries. Reach out to ask questions, learn about our timeline, or discuss joining our upcoming beta. +

+ +
+
+
+ +
+
+

Beta access waitlist

+

Interested in testing our tools? Join our waitlist and we will keep you updated on beta availability.

+ + Join the waitlist + +
+
+ +
+
+ +
+
+

Direct email

+

Prefer standard email? Reach out to our small development team directly.

+ + hello@korrith.com + +
+
+
+
+ + {/* Right Column: Form */} +
+ {isSuccess ? ( +
+
+ +
+

Message sent

+

+ Thank you for reaching out. A member of our team will review your message and reply as soon as possible. +

+ +
+ ) : ( +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ + +
+ )} +
+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/DocsPage.jsx b/apps/web/src/pages/DocsPage.jsx new file mode 100644 index 0000000..c182aa4 --- /dev/null +++ b/apps/web/src/pages/DocsPage.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +export default function DocsPage() { + return ( + + Documentation | Korrith +
+

Documentation

+

Content coming soon in Phase 2.

+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/DocumentationPage.jsx b/apps/web/src/pages/DocumentationPage.jsx new file mode 100644 index 0000000..629ea09 --- /dev/null +++ b/apps/web/src/pages/DocumentationPage.jsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + BookOpen, + Rocket, + Activity, + Server, + ShieldAlert, + FileCheck, + Briefcase, + ArrowRight, + Info +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.1 } +}; + +const guides = [ + { + icon: Rocket, + title: "Getting started", + description: "Verify a domain and run your first external assessment.", + color: "text-emerald-400", + bg: "bg-emerald-400/10 border-emerald-400/20" + }, + { + icon: Activity, + title: "Understanding your results", + description: "How scoring works, how Argus prioritizes, and how to read validation status.", + color: "text-primary", + bg: "bg-primary/10 border-primary/20" + }, + { + icon: Server, + title: "Host-internal assessment", + description: "Authorizing access and installing the read-only, least-privilege agent or the WordPress plugin, and exactly what each reads and never does.", + color: "text-accent-purple", + bg: "bg-accent-purple/10 border-accent-purple/20" + }, + { + icon: ShieldAlert, + title: "Authorization and safety", + description: "How the signed authorization gate, scoping, and the kill-switch govern any active testing.", + color: "text-amber-400", + bg: "bg-amber-400/10 border-amber-400/20" + }, + { + icon: FileCheck, + title: "Reports and evidence", + description: "Generating, reading, and sharing audit-grade reports and proof of resolution.", + color: "text-blue-400", + bg: "bg-blue-400/10 border-blue-400/20" + }, + { + icon: Briefcase, + title: "For partners", + description: "Running multi-client assessment with strict per-client boundaries.", + color: "text-rose-400", + bg: "bg-rose-400/10 border-rose-400/20" + } +]; + +export default function DocumentationPage() { + return ( + + + Documentation | Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Documentation +
+ +

+ Everything you need to use Korrith well. +

+ +

+ Clear, practical documentation for getting value from Korrith quickly, whether you are running your first external assessment or authorizing deep host-internal validation. +

+ + +
+
+
+ + {/* Guides Section */} +
+
+ +

Guides

+

+ Step-by-step instructions for configuring, operating, and reading outputs from the Korrith platform. +

+
+ + + {guides.map((guide, idx) => ( + +
+ +
+ +

+ {guide.title} +

+ +

+ {guide.description} +

+ +
+ Read guide +
+ + +
+ ))} +
+
+
+ + {/* Note & CTA Section */} +
+
+ +
+ +
+ +
+ +

+ Documentation expands as the platform moves from private testing into general availability. +

+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ExternalPosturePage.jsx b/apps/web/src/pages/ExternalPosturePage.jsx new file mode 100644 index 0000000..c22e30f --- /dev/null +++ b/apps/web/src/pages/ExternalPosturePage.jsx @@ -0,0 +1,366 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { Shield, Key, MailCheck, Globe, DoorOpen, Fingerprint, Code as CodeXml, Target, Scale, BrainCircuit, Info, ArrowRight } from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@/components/ui/card.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.1 } +}; + +const checks = [ + { + title: "Security headers.", + description: "Whether your site tells browsers to enforce HTTPS, block clickjacking, and restrict scripts, the controls that protect your users from a range of common attacks.", + icon: Shield, + colSpan: "lg:col-span-2", + color: "text-blue-400" + }, + { + title: "TLS and certificates.", + description: "Supported TLS versions, cipher strength, certificate validity, and expiry, so an expired or weak certificate never becomes the thing that breaks trust or traffic.", + icon: Key, + colSpan: "lg:col-span-1", + color: "text-purple-400" + }, + { + title: "Email authentication.", + description: "SPF, DKIM, DMARC, and MTA-STS, the records that decide whether someone can send mail as your domain. Gaps here are how business email compromise and phishing impersonation begin.", + icon: MailCheck, + colSpan: "lg:col-span-1", + color: "text-emerald-400" + }, + { + title: "DNS hygiene.", + description: "DNSSEC and CAA, the controls that protect how your domain resolves and who can issue certificates for it.", + icon: Globe, + colSpan: "lg:col-span-1", + color: "text-amber-400" + }, + { + title: "Exposed services and ports.", + description: "What you are presenting to the internet, including the things you may not realize are exposed.", + icon: DoorOpen, + colSpan: "lg:col-span-1", + color: "text-rose-400" + }, + { + title: "Technology and component fingerprinting.", + description: "Your content-management system and public components, flagged when they are outdated and known to be vulnerable.", + icon: Fingerprint, + colSpan: "lg:col-span-1", + color: "text-cyan-400" + }, + { + title: "Third-party script integrity.", + description: "Whether the external scripts your site loads can be tampered with in transit.", + icon: CodeXml, + colSpan: "lg:col-span-2", + color: "text-indigo-400" + } +]; + +export default function ExternalPosturePage() { + return ( + + + External Posture Assessment | Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Layer 1: External Posture +
+ +

+ See yourself the way an adversary does. +

+ +

+ Before an attacker ever touches your systems, they map you from the outside: your domains, your certificates, your exposed services, your email security, the technologies you run. Korrith does the same assessment, the moment you verify a domain, with nothing to install. +

+
+
+
+ + {/* Why External Posture is Where to Start */} +
+
+
+ +

+ Why external posture is where to start +

+

+ It is the highest-signal, lowest-friction view of your security. It needs no access, changes nothing, and yet it covers the exact surface attackers probe first. +

+

+ A weak external posture is often the difference between an attacker moving on and an attacker moving in. +

+
+
+
+
+ + {/* Every Check, Explained (Bento Grid) */} +
+
+ +

+ Every check, explained +

+
+ + + {checks.map((check, index) => { + const Icon = check.icon; + return ( + + +
+ +
+ +
+ + {check.title} + +
+ + + {check.description} + + +
+
+ ); + })} +
+
+
+ + {/* Features Zig-Zag Layout */} +
+
+ + {/* Prioritized by what is actually exploited */} + +
+
+ +
+

+ Prioritized by what is actually exploited +

+

+ Every finding is weighted with known-exploited and likely-exploited intelligence. A vulnerability that adversaries are actively using is surfaced above one that is theoretically severe but not being weaponized, so your effort goes where the real risk is. +

+
+
+
+
+
+
+
+
01
+
+
+
Exploited
+
+
+
+
02
+
+
+
+
+
+
03
+
+
+
+
+
+
+
+ + {/* Honest scoring */} + +
+
+
+
+
+ +
+ N/A +
+
+
+
+
+
+
+
+
+
+
+ +
+

+ Honest scoring +

+

+ Coverage is reported as it is. Where a check could not run or a source was unavailable, Korrith says so rather than implying a clean result. Your score reflects reality, not optimism. +

+
+
+ + {/* Reasoned, not dumped */} + +
+
+ +
+

+ Reasoned, not dumped +

+

+ Findings come back prioritized and explained by Argus, with the why attached, so you can act immediately or hand the list to your team. +

+
+
+
+
+
+
+
+
+
Argus Reasoning
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + {/* Note Section */} +
+
+ +
+ +
+

+ Note: the external assessment engine is in private testing. +

+
+
+
+ + {/* CTA Section */} +
+
+ +

+ Know your true exposure +

+

+ See what an adversary sees, before they do. +

+ +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/FAQPage.jsx b/apps/web/src/pages/FAQPage.jsx new file mode 100644 index 0000000..ef810c7 --- /dev/null +++ b/apps/web/src/pages/FAQPage.jsx @@ -0,0 +1,179 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { motion } from 'framer-motion'; +import { HelpCircle, Rocket, Shield, Lock, Database } from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from '@/components/ui/accordion.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const faqSections = [ + { + title: "Getting Started", + icon: Rocket, + description: "Everything you need to know about setting up and running your first assessment.", + questions: [ + { + q: "Do I need to install anything to start?", + a: "No. The external assessment runs the moment you verify a domain, with nothing to deploy and nothing changed on your systems." + }, + { + q: "What does the free assessment actually check?", + a: "Your internet-facing surface: security headers, TLS and certificates, email authentication, DNS hygiene, exposed services and ports, the technologies you run and whether they are outdated, and third-party script integrity, all prioritized by what attackers are actively exploiting." + }, + { + q: "Is Korrith available now?", + a: "Korrith is built and rolling out. External assessment is in private testing to the beta, and host-internal posture and active validation on your own assets are built and rolling out. Client-facing active testing is gated behind active-testing terms reviewed by counsel. Join the beta for early access." + } + ] + }, + { + title: "The Agent and Your Systems", + icon: Shield, + description: "Details on our lightweight, read-only host integration.", + questions: [ + { + q: "Is the host agent safe to install?", + a: "Yes. It is read-only and least-privilege. It reports posture as cryptographically signed evidence, it never modifies your system, it never exfiltrates your data, and it installs only under your explicit, in-product authorization." + }, + { + q: "What if I cannot install a server agent?", + a: "For platforms like WordPress, a read-only, outbound-only plugin provides the same signed-evidence posture without a server agent." + } + ] + }, + { + title: "Active Testing and Safety", + icon: Lock, + description: "How we ensure our validation engine does no harm.", + questions: [ + { + q: "Does Korrith ever attack my systems?", + a: "Only under your explicit, signed authorization, only within the exact scope you grant, on the environment you choose, in an approved window, and with a kill-switch that halts everything instantly. The engine cannot act outside that authorization." + }, + { + q: "Will active testing lock out my accounts or break things?", + a: "No. Active validation is designed to do no harm: it stays well under lockout thresholds and would rather miss a weak credential than lock an account, and it starts with non-destructive checks on owned or signed-scope assets, staging first." + } + ] + }, + { + title: "Trust and Data", + icon: Database, + description: "How we handle, protect, and report on your information.", + questions: [ + { + q: "How do you handle my data?", + a: "We collect the posture needed to assess and assure and no more. Sensitive values are minimized, confirmed credentials are never stored or shown in plaintext, and end-user personal data is kept out of the inventories we surface." + }, + { + q: "Can I trust what the AI tells me?", + a: "Argus is constrained to reason only over real findings, and every insight traces back to verifiable evidence. It never invents risk, and unverified results are shown as unverified." + }, + { + q: "Can Korrith help with my audit?", + a: "Yes. Korrith maps validated findings to common frameworks and produces continuous, audit-ready evidence and proof of resolution." + } + ] + } +]; + +export default function FAQPage() { + return ( + + + Frequently Asked Questions | Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Help & Resources +
+ +

+ Frequently Asked Questions +

+ +

+ Clear answers on how Korrith assesses your posture, how active validation works, and how we handle your data with zero-trust safety. +

+
+
+
+ + {/* FAQ Sections */} +
+
+ {faqSections.map((section, idx) => ( + + {/* Category Sidebar */} +
+
+
+ +
+

+ {section.title} +

+
+

+ {section.description} +

+
+ + {/* Accordion List */} +
+ + {section.questions.map((item, qIdx) => { + const value = `item-${idx}-${qIdx}`; + return ( + + + {item.q} + + + {item.a} + + + ); + })} + +
+
+ ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/HomePage.jsx b/apps/web/src/pages/HomePage.jsx new file mode 100644 index 0000000..92c0fb9 --- /dev/null +++ b/apps/web/src/pages/HomePage.jsx @@ -0,0 +1,373 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + ShieldCheck, Activity, Network, FileSearch, CheckCircle, + Database, Lock, Eye, Zap, ChevronRight, Server +} from 'lucide-react'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-100px" }, + transition: { duration: 0.5 } +}; + +export default function HomePage() { + return ( + + + Korrith | External Attack Surface and Quantum Readiness + + + + {/* 1. HERO SECTION */} +
+
+ Abstract dark grid structures representing network infrastructure +
+
+
+ +
+ +
+ + Korrith is in active development +
+

+ See your external attack surface the way an adversary does. +

+

+ Korrith maps your external exposure and your path to quantum-safe cryptography. The assessment engine is in private testing. The full platform is in active development. +

+
+ + Join the beta + + + Explore the roadmap + +
+
+
+
+ + {/* 2. PILLARS SECTION */} +
+
+
+ {[ + { icon: ShieldCheck, title: "Engineering first", desc: "Tools designed by practitioners, for practitioners." }, + { icon: Activity, title: "External visibility", desc: "Your assets mapped exactly as an outside adversary sees them." }, + { icon: Lock, title: "Quantum-safe focus", desc: "A clear path toward post-quantum cryptographic standards." }, + { icon: Database, title: "Contextual findings", desc: "Remediation paths over raw metric counts." } + ].map((point, i) => ( + +
+ +
+
+

{point.title}

+

{point.desc}

+
+
+ ))} +
+
+
+ + {/* 3. PROBLEM STATEMENT SECTION */} +
+
+ +

Modern infrastructure is complex.

+
+

+ Organizations manage growing numbers of domains, IP addresses, and shifting cloud configurations. That sprawl creates an external attack surface that is hard to monitor continuously. Misconfigured headers, expiring certificates, and exposed interfaces stay common. At the same time, the cryptography protecting that data faces a shift. As quantum computing advances, standard public-key cryptography will eventually be vulnerable. Moving to new standards takes years of planning, inventory, and execution. Korrith is being built to address both: a continuous baseline of external risk, and a structured path to post-quantum migration. +

+
+
+
+
+ + {/* 4. WHAT WE ARE PRIORITIZING SECTION (Bento Grid) */} +
+
+
+

What we are prioritizing

+
+ +
+ +
+ +

Clear visibility

+

Map forgotten assets, dangling DNS records, and shadow infrastructure. Security starts with knowing your true perimeter.

+
+ + +
+ +

Cryptographic agility

+
+

Audit existing cryptography and outline a path to post-quantum integration.

+
+ + +
+ +

Honest reporting

+
+

Findings without inflation. Critical severity is reserved for verifiable exposure.

+
+ + +
+ +

Actionable context

+
+

Every flagged issue carries the technical context a team needs to evaluate and fix it.

+
+
+
+
+ + {/* 5. ARGUS ENGINE SECTION */} +
+
+
+ +
+ + Under Development +
+

The Argus engine

+

+ Argus is designed to map your external assets safely. It categorizes services and surfaces common exposure, with no disruptive testing. +

+
    + {['Distributed reconnaissance', 'Passive service identification', 'Iterative asset discovery', 'Built for modern cloud environments'].map((feature, i) => ( +
  • + + {feature} +
  • + ))} +
+
+ + +
+ {Array.from({ length: 64 }).map((_, i) => ( +
+ ))} +
+ +
+
+ Example assessment view (illustrative) +
+
+ {[ + { target: 'example.production.internal', status: 'active', color: 'bg-korrith-green' }, + { target: 'legacy-auth.gateway', status: 'active', color: 'bg-korrith-amber' }, + { target: 'vpn.test.net', status: 'active', color: 'bg-korrith-green' }, + ].map((item, i) => ( +
+
+ + {item.target} +
+
+ +
+
+ ))} +
+
+
+
+
+
+ + {/* 6. WHAT WE ARE BUILDING SECTION */} +
+
+
+

What we are building

+

All of the following is in development. We will not claim a capability as ready until it is.

+
+ +
+ {[ + { num: "01", title: "Baseline external assessment", desc: "Automated evaluation of external infrastructure for misconfigurations and exposed services." }, + { num: "02", title: "Contextual findings", desc: "Results organized by practical exploitability, so teams focus on what matters." }, + { num: "03", title: "Remediation guidance", desc: "Technical context and configuration examples for each gap." }, + { num: "04", title: "Quantum readiness mapping", desc: "Inventory current cryptography and model a phased post-quantum transition." }, + { num: "05", title: "Continuous monitoring and host-level posture", desc: "" } + ].map((item, i) => ( + +
+ {item.num} +
+
+

{item.title}

+ {item.desc &&

{item.desc}

} +
+
+ ))} +
+
+
+ + {/* 7. QUANTUM READINESS SECTION */} +
+
+
+ + Quantum computing hardware visualization +
+
+
+ + +

Preparing for the cryptographic shift.

+

+ As computing power advances, current public-key encryption will eventually face real risk. Migrating to post-quantum cryptography is a multi-year effort for most organizations. Korrith is being built to help you find where vulnerable cryptography lives in your infrastructure and model a structured transition. +

+ + Read our approach + +
+
+
+
+ + {/* 8. CALL-TO-ACTION SECTION */} +
+
+ +

Follow our progress

+

+ Korrith is in active development. Join the beta to help validate the platform on real infrastructure and shape what we build. +

+ + Join the beta + +
+
+
+ + {/* 9. FAQ SECTION */} +
+
+
+

Frequently asked questions

+

Information about our development stage and platform approach.

+
+ + + {[ + { + q: "What stage is Korrith in?", + a: "Korrith is in active development. The external posture engine is in private testing. The broader platform is in progress." + }, + { + q: "How does the external assessment work?", + a: "Argus performs passive reconnaissance and configuration analysis of your public-facing surface, then grades it and explains each finding with evidence. It does not perform intrusive or disruptive testing." + }, + { + q: "What is your approach to post-quantum cryptography?", + a: "We assess where vulnerable cryptography exists and help model a phased migration. This is a readiness view, not a guarantee." + }, + { + q: "How can I take part?", + a: "Join the beta. We are onboarding a small group to validate the platform on real infrastructure." + } + ].map((faq, i) => ( + + + {faq.q} + + + {faq.a} + + + ))} + +
+
+ + {/* 10. CLOSING SECTION */} +
+
+ +

Secure infrastructure. Quantum future.

+

+ Korrith is being built to bring external posture assessment and quantum readiness into one honest platform. +

+ + Join the beta + +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/InternalPosturePage.jsx b/apps/web/src/pages/InternalPosturePage.jsx new file mode 100644 index 0000000..03cffed --- /dev/null +++ b/apps/web/src/pages/InternalPosturePage.jsx @@ -0,0 +1,423 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Server, + PackageSearch, + KeyRound, + Settings2, + UserCheck, + LockKeyhole, + HardDrive, + BugOff, + FileSignature, + ShieldBan, + PenTool, + Network, + Info, + ArrowRight +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.1 } +}; + +const capabilities = [ + { + title: "Software bill of materials", + description: "Software bill of materials and known vulnerabilities, prioritized by what is actually being exploited.", + icon: PackageSearch, + colSpan: "lg:col-span-2", + color: "text-blue-400", + bg: "bg-blue-400/10" + }, + { + title: "Exposed secrets", + description: "Exposed secrets in code and configuration.", + icon: KeyRound, + colSpan: "lg:col-span-1", + color: "text-amber-400", + bg: "bg-amber-400/10" + }, + { + title: "Configuration posture", + description: "Configuration posture against recognized benchmarks.", + icon: Settings2, + colSpan: "lg:col-span-1", + color: "text-purple-400", + bg: "bg-purple-400/10" + }, + { + title: "Identity and access", + description: "Identity and access posture, with file-integrity monitoring.", + icon: UserCheck, + colSpan: "lg:col-span-1", + color: "text-emerald-400", + bg: "bg-emerald-400/10" + }, + { + title: "Cryptographic inventory", + description: "Cryptographic inventory and quantum exposure.", + icon: LockKeyhole, + colSpan: "lg:col-span-1", + color: "text-cyan-400", + bg: "bg-cyan-400/10" + }, + { + title: "Operating-system patch level", + description: "Operating-system patch level and end-of-life status.", + icon: HardDrive, + colSpan: "lg:col-span-1", + color: "text-indigo-400", + bg: "bg-indigo-400/10" + }, + { + title: "Host malware indicators", + description: "Host malware indicators.", + icon: BugOff, + colSpan: "lg:col-span-2", + color: "text-rose-400", + bg: "bg-rose-400/10" + } +]; + +export default function InternalPosturePage() { + return ( + + + Internal Posture and the Host Agent | Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Layer 2: Internal Posture +
+ +

+ See what an attacker finds past the perimeter. +

+ +

+ External posture shows what an adversary sees from outside. Internal posture shows what they would find once inside: the vulnerable package, the exposed secret, the drifted configuration, the over-privileged account. Korrith reports all of it through a read-only, least-privilege agent, as cryptographically signed evidence. +

+
+
+
+ + {/* The Agent Section (Bento Grid) */} +
+
+ +

+ The agent, and what it reads +

+

+ A single read-only server agent, with a read-only, outbound-only WordPress plugin for sites where a server agent is not appropriate, reports your host's real posture: +

+
+ + + {capabilities.map((item, index) => { + const Icon = item.icon; + return ( + +
+ +
+
+ +
+
+

+ {item.description} +

+
+
+
+ ); + })} +
+
+
+ + {/* Feature Sections - Zig Zag */} +
+
+ + {/* Signed evidence you can trust */} + +
+
+ +
+

+ Signed evidence you can trust +

+

+ Every reading the agent sends is cryptographically signed, so the platform can prove the evidence is authentic and untampered. +

+

+ The signing contract holds identically across the server agent and the WordPress plugin, which means the same guarantee whether a host runs a full agent or a plugin. +

+
+
+
+
+
+
+ + Signature Verified +
+
+
+ Payload: + host_posture_v2 +
+
+ Timestamp: + 2026-06-22T14:02:11Z +
+
+ eyJBb...GciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0... +
+
+
+
+
+
+ + {/* What it never does */} + +
+
+
+
+ {["Modify your system", "Exfiltrate your data", "Act without authorization"].map((item, i) => ( +
+ + {item} +
+ ))} +
+
+
+
+
+ +
+

+ What it never does +

+

+ It is read-only and least-privilege by design. It does not modify your system. It does not exfiltrate your data. It does not act without authorization. +

+

+ It reads posture and reports it, nothing more. +

+
+
+ + {/* Authorized, in product */} + +
+
+ +
+

+ Authorized, in product +

+

+ The agent installs only through Korrith's in-product, two-party authorization: the asset owner reviews a versioned consent and e-signs, and the operator countersigns. +

+

+ No paper side-channel, no implicit access. The authorization record is the agent's permission. +

+
+
+
+
+
+
+
+ Asset Owner +
+
+
+
+
+
+
Signed: e3b0c442...
+
+ +
+
+ Operator +
+
+
+
+
+
+
Countersigned: f2d1a334...
+
+
+
+
+
+ + {/* Part of one story */} + +
+
+
+
+
+
+ +
+
+ +
+
+ + {/* Flow lines */} +
+
+
+
+ +
+ Argus +
+
+
+
+
+
+ +
+

+ Part of one story +

+

+ Internal posture findings flow into the same scored, prioritized, Argus-reasoned view as everything else, and into your reports. +

+

+ Host-internal exposure is part of one prioritized picture, not a separate tool to reconcile. +

+
+
+ +
+
+ + {/* Note Section */} +
+
+ +
+ +
+

+ Note: built and rolling out to the beta. +

+
+
+
+ + {/* CTA Section */} +
+
+ +

+ Secure the inside out +

+

+ Get cryptographically signed evidence of your real internal posture. +

+ +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/LoginPage.jsx b/apps/web/src/pages/LoginPage.jsx new file mode 100644 index 0000000..d69a56a --- /dev/null +++ b/apps/web/src/pages/LoginPage.jsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { ArrowLeft, Lock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useToast } from '@/hooks/use-toast.js'; + +function LoginPage() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const { toast } = useToast(); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!email || !password) { + toast({ + title: "Missing credentials", + description: "Please enter both email and password.", + variant: "destructive" + }); + return; + } + + setIsLoading(true); + + // Simulate login attempt + setTimeout(() => { + setIsLoading(false); + toast({ + title: "Login feature coming soon", + description: "Authentication system is currently in development." + }); + }, 1000); + }; + + return ( + <> + + Login - Korrith + + + +
+ {/* Background pattern */} +
+ + + {/* Back to home link */} + + + Back to home + + + {/* Login card */} +
+ {/* Logo and header */} +
+
+ +
+

Welcome back

+

Sign in to your Korrith account

+
+ + {/* Login form */} +
+
+ + setEmail(e.target.value)} + className="bg-background border-border h-12 text-foreground placeholder:text-muted-foreground/50" + required + /> +
+ +
+
+ + + Forgot password? + +
+ setPassword(e.target.value)} + className="bg-background border-border h-12 text-foreground placeholder:text-muted-foreground/50" + required + /> +
+ + +
+ + {/* Divider */} +
+
+
+
+
+ + New to Korrith? + +
+
+ + {/* Sign up link */} +
+ + Request team access + +
+
+ + {/* Security notice */} +

+ Protected by quantum-resistant encryption +

+
+
+ + ); +} + +export default LoginPage; \ No newline at end of file diff --git a/apps/web/src/pages/MSSPsPage.jsx b/apps/web/src/pages/MSSPsPage.jsx new file mode 100644 index 0000000..04505e7 --- /dev/null +++ b/apps/web/src/pages/MSSPsPage.jsx @@ -0,0 +1,380 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Network, + ArrowRight, + ShieldCheck, + Search, + Wrench, + RefreshCw, + FileCheck, + Repeat, + Lock, + Layers, + Activity, + Workflow, + ServerCog, + Briefcase +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +const loopSteps = [ + { icon: Search, label: "Find" }, + { icon: ShieldCheck, label: "Validate" }, + { icon: Wrench, label: "Remediate" }, + { icon: RefreshCw, label: "Re-validate" }, + { icon: FileCheck, label: "Prove" }, + { icon: Repeat, label: "Continuously", isAccent: true } +]; + +export default function MSSPsPage() { + return ( + + + Korrith for MSSPs | Multi-client Assessment and Assurance at Scale + + + + {/* Hero Section with Background Image */} +
+ {/* Background Image & Overlays */} +
+ Abstract architecture representing complex structured networks +
+
+
+
+ +
+ +
+ + For MSSPs +
+ +

+ Run assurance for every client, and prove it. +

+ +

+ You already manage security for many clients. Korrith gives you a clean way to assess, validate, and continuously assure all of them, with strict per-client boundaries, an honest score you can stand behind, and reporting your clients can hand to their own auditors. +

+ + +
+
+
+ + {/* Built for many tenants (Bento Grid) */} +
+
+ +

+ Built for many tenants +

+
+ + + {/* Bento Item 1: Boundaries (Col span 2) */} + +
+
+ +
+

Strict client boundaries

+

+ Per-client scoping is enforced in the platform, not left to process. A client's data, findings, and reports stay theirs. Each client sees only their own posture; you see the estate. +

+
+
+ + {/* Bento Item 2: One Operator View (Col span 1) */} + +
+ +
+

One operator view

+

+ Across your whole book, you see what regressed, what is aging, and where each client sits, so you can prioritize your team's time where the real risk is. +

+
+ + {/* Bento Item 3: Honest Scoring (Col span 3) */} + +
+ +
+
+

Honest scoring everywhere

+

+ No fake-clean, per client, so a quiet gap never hides behind a green dashboard. +

+
+
+
+
+
+ + {/* The Closed Loop Section */} +
+
+ +
+

+ The closed loop is your recurring value +

+

+ The hardest part of an MSSP relationship is demonstrating ongoing value. Korrith's loop does it for you: find, validate, remediate, re-validate, prove, continuously. You can show each client not just what you found, but what you proved fixed, with dates and evidence. That is a renewal conversation that runs itself. +

+
+ + {/* Loop Diagram */} +
+
+ +
+ {loopSteps.map((step, idx) => ( + +
+
+ +
+ + {step.label} + +
+ {idx < loopSteps.length - 1 && ( +
+ +
+ )} +
+ ))} +
+
+
+
+
+ + {/* Active Validation, Safely (Image Background Section) */} +
+
+ Secure technological infrastructure visualization +
+ {/* Subtle gradient overlay to enhance text readability and match theme */} +
+
+ +
+ +
+ +
+

+ Active validation, safely +

+

+ Korrith's active testing is gated by cryptographically signed authorization and a kill-switch, scoped per target, and designed for owned or signed-scope assets on staging first. You get the depth of validation your clients need without the operational and legal risk that usually comes with offensive testing. +

+
+
+
+ + {/* Assess and Route Section */} +
+
+
+ + +
+ + {/* Visual showing routing flow */} +
+ + {/* MSSP Bubble */} +
+
+ +
+
+
MSSP Relationship
+
Client assurance layer
+
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+ {/* Standard Work */} +
+ +
Standard Fixes
+
+ + {/* Specialized Work routed to Korrith */} +
+ +
Specialist Routing
+
+
+ +
+
+ + +
+ +
+

+ Assess and route +

+

+ Where remediation calls for specialized work, such as post-quantum cryptography, Korrith assesses and routes the relevant subset while the assurance relationship stays with you. You keep the client and the recurring revenue; the specialist work happens underneath. +

+
+ +
+
+
+ + {/* What You Get Summary Section */} +
+
+ +

+ What you get +

+

+ A multi-client assurance platform that scales with your book, proves your value continuously, and produces evidence your clients can actually use. +

+ +
+
+
+
Scalable Platform
+
Manage your entire book of business securely from a single view.
+
+
+
+
Continuous Value
+
Turn one-off audits into defensible, recurring revenue streams.
+
+
+
+
Audit-ready Evidence
+
Export clear, validated proof of posture and resolution for clients.
+
+
+ + +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ManagedSecureHostingPage.jsx b/apps/web/src/pages/ManagedSecureHostingPage.jsx new file mode 100644 index 0000000..22dc798 --- /dev/null +++ b/apps/web/src/pages/ManagedSecureHostingPage.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +export default function ManagedSecureHostingPage() { + return ( + + Managed Secure Hosting | Korrith +
+

Managed Secure Hosting

+

Content coming soon in Phase 2.

+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/PartnersPage.jsx b/apps/web/src/pages/PartnersPage.jsx new file mode 100644 index 0000000..47a7d57 --- /dev/null +++ b/apps/web/src/pages/PartnersPage.jsx @@ -0,0 +1,255 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { ArrowRight, HeartHandshake as Handshake, Briefcase, ShieldCheck, Cpu, Repeat, FileCheck, Award } from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.1 } +}; + +export default function PartnersPage() { + return ( + + + Partner with Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Partner Ecosystem +
+ +

+ Build on Korrith. +

+ +

+ Whether you are an agency, a managed security provider, or a technology partner, there is a way to build a real, recurring business on Korrith. You bring the relationship; Korrith brings the assessment, the validation, and the proof. +

+ + +
+
+
+ + {/* Partner Types Section (2+1 Bento Layout to avoid generic 3-card grid) */} +
+
+ +

+ A model for every capability +

+

+ We designed our platform to integrate neatly into your existing business model, removing the friction from offering real security assurance. +

+
+ + + {/* Card 1: Agencies (col-span-1) */} + +
+ +
+ +
+

+ For agencies +

+

+ Add security assessment and assurance to what you already deliver for clients, with per-client reporting, no security team required, and a recurring fee for something clients can see working. +

+
+ + See For Agencies + +
+
+ + {/* Card 2: MSSPs (col-span-1) */} + +
+ +
+ +
+

+ For MSSPs +

+

+ Run multi-client assessment and assurance at scale, with strict per-client boundaries, audit-ready reporting, and a closed loop that proves your value at every renewal. +

+
+ + See For MSSPs + +
+
+ + {/* Card 3: Tech Partners (col-span-1 md:col-span-2 full width) */} + +
+ +
+
+ +
+

+ For technology partners +

+
+ +
+

+ Korrith assesses and routes. Where remediation calls for specialized work, such as post-quantum cryptography, Korrith identifies the relevant subset and routes it to the right capability while the assurance relationship stays in place. +

+ If you provide a specialized remediation capability, there is a natural place for you in that flow. +

+ +
+
+
+
+
+ + {/* What Partners Share Section */} +
+
+
+ +

+ What partners share +

+

+ Regardless of how you go to market, partnering with Korrith establishes a resilient, evidence-backed foundation for your services. +

+
+ + + {[ + { + icon: Repeat, + title: "A recurring relationship", + desc: "Move away from one-time transactional engagements and build continuous, measurable security value." + }, + { + icon: FileCheck, + title: "Reporting and proof", + desc: "Deliver tangible, audit-ready evidence that your clients can confidently use with their own stakeholders and boards." + }, + { + icon: Award, + title: "Evidence-backed credibility", + desc: "Leverage the trust of an honest, validated platform behind your brand, proving outcomes instead of promising them." + } + ].map((item, i) => ( + +
+ +
+
+

+ {item.title} +

+

+ {item.desc} +

+
+
+ ))} +
+
+
+
+ + {/* Bottom CTA */} +
+
+ +

+ Ready to build? +

+

+ Join the partners already building defensible, recurring revenue on the Korrith platform. +

+ +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/PostQuantumReadinessPage.jsx b/apps/web/src/pages/PostQuantumReadinessPage.jsx new file mode 100644 index 0000000..6b3dc79 --- /dev/null +++ b/apps/web/src/pages/PostQuantumReadinessPage.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +export default function PostQuantumReadinessPage() { + return ( + + Post-Quantum Readiness | Korrith +
+

Post-Quantum Readiness

+

Content coming soon in Phase 2.

+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/PrivacyPage.jsx b/apps/web/src/pages/PrivacyPage.jsx new file mode 100644 index 0000000..764d5aa --- /dev/null +++ b/apps/web/src/pages/PrivacyPage.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +export default function PrivacyPage() { + return ( + + + Privacy Policy | Korrith + + + +
+
+

Privacy Policy

+

Last updated: 2026-06-16

+
+ +
+

+ At Korrith, we take your privacy and data security seriously. As we develop our platform, this Privacy Policy describes how we intend to collect, use, maintain, and disclose information collected from future users of our services and beta testers. +

+ +

Introduction

+

+ By accessing or using the Korrith website and participating in any beta programs, you agree to the practices described in this policy. We are building our systems to prioritize minimal data collection and adhere to strict retention schedules for all security posture data gathered during authorized scans. +

+ +

Information we collect

+

+ We collect information that you directly provide to us when requesting beta access or contacting us, including your name, email address, and company name. When our platform becomes active, we will also collect technical data about the domains and infrastructure you explicitly authorize us to assess. +

+

+ This future technical data will include DNS records, TLS certificates, HTTP headers, and public-facing service configurations. +

+ +

How we use your information

+

+ The information we collect is used strictly for the following purposes: +

+
    +
  • To manage beta waitlists and communicate development updates
  • +
  • To provide, operate, and maintain the future Korrith platform
  • +
  • To generate security posture assessments for authorized domains during testing
  • +
  • To send technical notices, updates, and administrative messages
  • +
+ +

Data security

+

+ We are implementing robust, industry standard security measures to protect personal information and future assessment data. We are designing our systems so that data is encrypted in transit using TLS 1.3 and at rest using AES-256. Access to any gathered data is strictly controlled and limited to authorized development personnel. +

+ +

Your rights

+

+ Depending on your jurisdiction, you may have the right to request access to the personal data we currently hold about you, request corrections to inaccurate data, or request the deletion of your waitlist data. To exercise these rights, please contact our team. +

+ +

Contact us

+

+ If you have any questions about this Privacy Policy or our intended data handling practices as we build Korrith, please contact us at privacy@korrith.com. +

+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/QuantumReadinessPage.jsx b/apps/web/src/pages/QuantumReadinessPage.jsx new file mode 100644 index 0000000..11ab902 --- /dev/null +++ b/apps/web/src/pages/QuantumReadinessPage.jsx @@ -0,0 +1,369 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Cpu, + Lock, + Database, + Fingerprint, + ShieldAlert, + ListOrdered, + ArrowRight, + Info, + GitBranch, + Network +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@/components/ui/card.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +export default function QuantumReadinessPage() { + return ( + + + Quantum Readiness | Korrith + + + + {/* Hero Section */} +
+
+
+ + {/* Abstract Quantum Decorative Element */} +
+
+
+
+
+ +
+ +
+ + Layer 4: Quantum Readiness +
+ +

+ Know where quantum breaks your cryptography. +

+ +

+ A sufficiently powerful quantum computer will break much of the public-key cryptography the internet runs on today. The threat is not hypothetical to plan for later: data captured now can be decrypted then, which is why the time to know your exposure is now. Korrith inventories your cryptographic posture and shows you exactly where you are exposed. +

+ +
+ +
+
+
+
+ + {/* Harvest Now, Decrypt Later */} +
+
+ +
+ + + + + Harvest now, decrypt later + + + Adversaries are already capturing encrypted data they cannot read yet, betting they will be able to later. Anything with a long confidentiality lifetime, contracts, health records, intellectual property, is at risk from a future that arrives on someone else's timeline. + + + Quantum readiness is about knowing what you would lose and getting ahead of it. + +
+ + +
+ + {/* Decorative Animation for Harvest/Decrypt */} +
+
+
+
+ +
+ Encrypted Data +
+ +
+ +
+ +
+
+ +
+ Harvested +
+
+ +
+
+ Status + Awaiting Decryption +
+
+ +
+
+
+
+
+
+
+ + {/* What Korrith Inventories (Bento Layout) */} +
+
+ +

+ What Korrith inventories +

+
+ + + {/* Card 1: Full width on top */} + + +
+
+ +
+
+ + Cryptographic inventory + + + The algorithms, key sizes, and protocols actually in use across your assets, not what a policy says should be in use. + +
+
+
+ + {/* Card 2: Half width */} + + +
+
+ +
+
+ + Quantum exposure + + + Which of those are vulnerable to quantum attacks, so you can see your real exposure rather than guess at it. + +
+
+
+ + {/* Card 3: Half width */} + + +
+
+ +
+
+ + Prioritized risk + + + Which exposed cryptography protects your most sensitive or longest-lived data, so you know what to migrate first. + +
+
+
+
+
+
+ + {/* From Inventory to a Plan */} +
+
+ +
+ {/* Visual Flow Indicator */} +
+ {[ + { title: "Clear Inventory", desc: "Discover active cryptographic usage", icon: ListOrdered }, + { title: "Ranked Exposure", desc: "Identify quantum-vulnerable protocols", icon: ShieldAlert }, + { title: "Quantum-Safe Path", desc: "Migrate based on verified priority", icon: GitBranch } + ].map((step, idx) => ( + +
+
+ +
+ {idx < 2 && ( +
+ )} +
+
+

{step.title}

+

{step.desc}

+
+
+ ))} +
+
+ +
+ + + + + From inventory to a plan + + + A cryptographic migration is large and slow, and doing it blind is how organizations stall. Korrith turns it into a sequenced plan: a clear inventory, a ranked exposure list, and a path to quantum-safe cryptography ordered by risk. + + + You move deliberately instead of reactively. + +
+
+
+
+ + {/* How it connects & Note */} +
+
+
+ + {/* How it Connects */} + +

+ How it connects +

+

+ This page is about seeing your exposure. The migration path itself, including specialized post-quantum remediation, is covered under Post-Quantum Readiness, where Korrith assesses and routes the cryptographic work while keeping the assurance relationship in place. +

+ +
+ + {/* Note Callout */} + +
+
+ +
+

+ Note: built and rolling out as part of the host-internal assessment. +

+
+
+ +
+
+
+ + {/* CTA Section */} +
+
+ +

+ Prepare for the quantum transition +

+

+ Get an accurate inventory of your cryptography before the threat materializes. +

+ +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ReportsAndEvidencePage.jsx b/apps/web/src/pages/ReportsAndEvidencePage.jsx new file mode 100644 index 0000000..8389470 --- /dev/null +++ b/apps/web/src/pages/ReportsAndEvidencePage.jsx @@ -0,0 +1,358 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + FileSignature, + ShieldCheck, + CheckCircle2, + History, + Users, + BarChart, + ArrowRight, + Info, + FileCheck, + Search, + LockKeyhole, + Check, + Building, + Briefcase +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +export default function ReportsAndEvidencePage() { + return ( + + + Reports and Evidence | Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Layer 3: Reports & Evidence +
+ +

+ Proof, from exposure to closure. +

+ +

+ Most tools end at a list of problems. Korrith ends at proof: a clear, audit-grade report and a tamper-evident record of what was found, what was validated as real, and what has been verified closed. The kind of evidence you can put in front of an auditor, a customer, or a board without rehearsing excuses. +

+ +
+ +
+
+
+
+ + {/* Main Content Sections */} +
+
+ + {/* 1. Validate what is real, first */} + +
+
+ +
+

+ Validate what is real, first +

+

+ A report full of unconfirmed findings wastes everyone's time. Korrith validates exposures before they reach your report, so what you and your stakeholders see is what an attacker could actually use, with the theoretical noise filtered out. +

+

+ Confirmed items are marked confirmed; unverified items are marked unverified. Nothing is dressed up. +

+
+
+
+
+ +
+ {/* Confirmed Item */} +
+
+
+ CVE-2023-44487 +
+ + Confirmed +
+
+
+
+
+ + {/* Unverified Item */} +
+
+ Obsolete Header +
+ + Unverified +
+
+
+
+
+
+
+
+
+ + {/* 2. Prove what is closed */} + +
+
+
+ +
+
+ +
+ {[ + { title: "First seen", date: "Oct 12, 14:00Z", icon: Search, color: "text-muted-foreground", bg: "bg-secondary" }, + { title: "Confirmed", date: "Oct 12, 14:05Z", icon: ShieldCheck, color: "text-primary", bg: "bg-primary/10 border-primary/20" }, + { title: "Remediated", date: "Oct 14, 09:12Z", icon: History, color: "text-amber-500", bg: "bg-amber-500/10 border-amber-500/20" }, + { title: "Verified Closed", date: "Oct 14, 09:15Z", icon: Check, color: "text-emerald-500", bg: "bg-emerald-500/10 border-emerald-500/30", active: true } + ].map((step, idx) => ( +
+
+ +
+
+

{step.title}

+

{step.date}

+
+
+ ))} +
+
+
+
+
+
+ +
+

+ Prove what is closed +

+

+ When you remediate, Korrith does not take your word for it. It re-checks, and only marks a finding resolved when a later assessment actually observed the exposure was gone. +

+

+ Every finding carries its history: first seen, confirmed, remediated, and verified closed, with the evidence and dates attached. A silently reopened issue resurfaces as open, not as a stale green checkmark. +

+
+
+
+
+ + {/* 3 & 4. Audit-grade & Conversations (Grid Layout) */} +
+
+ + {/* Audit-grade by default */} + +
+
+ +
+ +
+ +

+ Audit-grade by default +

+ +

+ Every report is generated from the real underlying record, with a tamper-evident chain of what happened and when. You are not assembling a narrative after the fact. You are exporting the truth the platform already holds. +

+ +
+
+ + Report Hash +
+ a8f9...2c4e +
+
+
+ + {/* Built for conversations */} + +
+
+ +
+
+ +
+

+ Built for the conversations that matter +

+
+ +
+ {[ + { + icon: FileSignature, + title: "For auditors", + desc: "Validated findings and proof of resolution map to the evidence frameworks ask for." + }, + { + icon: Building, + title: "For customers doing security due diligence", + desc: "A credible, honest posture report instead of a self-attested checklist." + }, + { + icon: BarChart, + title: "For your board", + desc: "A clear trajectory of exposure found and exposure closed, explained by Argus." + } + ].map((item, idx) => ( +
+
+ +
+
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+
+
+
+
+
+ + {/* 5. Clients / Spotlight Section */} +
+
+ +
+
+ +
+
+ +
+

+ Reports your clients can show their clients +

+

+ If you assess on behalf of others, the same report is something your clients can hand to their own auditors and boards, which turns your assessment into a recurring, defensible service. +

+ +
+
+
+
+ + {/* Note Section */} +
+
+ +
+ +
+

+ Note: built and rolling out to the beta. +

+
+
+
+ + {/* CTA Section */} +
+
+ +

+ End the argument over what is real +

+

+ Generate honest, audit-grade proof of your external posture. +

+ +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ReportsPage.jsx b/apps/web/src/pages/ReportsPage.jsx new file mode 100644 index 0000000..568bc51 --- /dev/null +++ b/apps/web/src/pages/ReportsPage.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +export default function ReportsPage() { + return ( + + Reports and Evidence | Korrith +
+

Reports and Evidence

+

Content coming soon in Phase 2.

+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/ResearchPage.jsx b/apps/web/src/pages/ResearchPage.jsx new file mode 100644 index 0000000..3361363 --- /dev/null +++ b/apps/web/src/pages/ResearchPage.jsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +export default function ResearchPage() { + return ( + + Research and Whitepapers | Korrith +
+

Research and Whitepapers

+

Content coming soon in Phase 2.

+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/RoadmapPage.jsx b/apps/web/src/pages/RoadmapPage.jsx new file mode 100644 index 0000000..7a60036 --- /dev/null +++ b/apps/web/src/pages/RoadmapPage.jsx @@ -0,0 +1,265 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Map, + CheckCircle2, + Lock, + Rocket, + ArrowRight, + ShieldCheck, + CircleDot +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +const bulletVariants = { + initial: { opacity: 0, x: -10 }, + whileInView: { opacity: 1, x: 0 }, + transition: { duration: 0.4 } +}; + +export default function RoadmapPage() { + return ( + + + Roadmap | Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Platform Roadmap +
+ +

+ Where Korrith is, and where it is going. +

+ +

+ We would rather show you an honest roadmap than a fake feature list. Korrith is in active development. Here is exactly what is live, what is in private testing, and what is next, and we keep this page current as things move. +

+
+
+
+ + {/* Roadmap Timeline */} +
+
+
+ {/* Vertical timeline line for desktop */} +
+ +
+ + {/* Phase 1: Built and rolling out */} + +
+
+
+ +
+
+ +
+
+ +
+ +
+ + + Built and rolling out + + +
+ {[ + "External attack surface assessment (Layer 1). Headers, TLS and certificates, email authentication, DNS hygiene, exposed services, technology fingerprinting, and third-party script integrity, prioritized by live exploitability intelligence. In private testing to the beta.", + "Host-internal posture (Layer 2). The read-only agent and WordPress plugin reporting signed-evidence posture across software inventory and vulnerabilities, secrets, configuration, identity and access, cryptography and quantum readiness, patching, and malware indicators.", + "Active validation (Layer 3). Non-destructive reachability confirmation and, under signed authorization, lockout-safe credential and access validation, governed by the authorization gate and kill-switch.", + "Cryptographic and quantum-readiness inventory.", + "Reports and evidence, the closed assurance loop." + ].map((text, i) => ( + +
+ +
+

{text}

+
+ ))} +
+
+
+ + {/* Phase 2: Gated behind counsel */} + +
+
+ +
+
+ +
+
+ +
+ +
+ + + Gated behind counsel + + +
+ +
+ +
+

+ Client-facing active testing. The capability is built and proven on our own assets. Pointing it at a client's assets waits on signed active-testing terms reviewed by counsel, because the authorization is the whole point. +

+
+
+
+
+ + {/* Phase 3: On the horizon */} + +
+
+ +
+
+ +
+
+ +
+ +
+ + + On the horizon + + +
+ {[ + "Continuous validation and proof of resolution, so a reopened exposure is caught the same week.", + "Dark-web and breach exposure monitoring, telling you when your credentials are already for sale.", + "Deeper threat intelligence in Argus, prioritizing by what is actually being weaponized against your sector." + ].map((text, i) => ( + +
+ +
+

{text}

+
+ ))} +
+
+
+ +
+
+
+
+ + {/* Our Commitment Section */} +
+
+ +

+ Our commitment +

+

+ Anything not built yet is labeled as such, here and across the product. We will not show you a result we cannot back with evidence. +

+
+
+
+ + {/* CTA Section */} +
+
+ +
+ +

+ Help shape what we build next +

+

+ Join the beta to get early access to live features and provide feedback on our roadmap priorities. +

+ +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/SampleReportPage.jsx b/apps/web/src/pages/SampleReportPage.jsx new file mode 100644 index 0000000..b68bafa --- /dev/null +++ b/apps/web/src/pages/SampleReportPage.jsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + FileText, + ListOrdered, + BrainCircuit, + ShieldCheck, + History, + Download, + ArrowRight, + ShieldAlert, + MessageSquare +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +const reportFeatures = [ + { + icon: FileText, + title: "An honest posture summary.", + description: "A clear score that reflects reality, including where coverage was incomplete, never a fake-clean.", + color: "text-blue-400", + bg: "bg-blue-400/10 border-blue-400/20" + }, + { + icon: ListOrdered, + title: "A prioritized finding list.", + description: "Ordered by genuine risk, with the validated and the unverified clearly distinguished, so the urgent and the theoretical are never confused.", + color: "text-rose-400", + bg: "bg-rose-400/10 border-rose-400/20" + }, + { + icon: BrainCircuit, + title: "Argus reasoning on every item.", + description: "What the finding is, why it matters, and what to do, in language a non-specialist can act on and an engineer can implement against.", + color: "text-purple-400", + bg: "bg-purple-400/10 border-purple-400/20" + }, + { + icon: ShieldCheck, + title: "Validation status.", + description: "Which exposures were actively confirmed as real and reachable, and which are detected but not yet validated.", + color: "text-emerald-400", + bg: "bg-emerald-400/10 border-emerald-400/20" + }, + { + icon: History, + title: "Evidence and history.", + description: "For each finding, when it was first seen, its current state, and, once you remediate, proof of resolution from a later check that actually observed it gone.", + color: "text-amber-400", + bg: "bg-amber-400/10 border-amber-400/20" + } +]; + +export default function SampleReportPage() { + return ( + + + Sample Korrith Report | See the Depth Before You Run One + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Sample Report +
+ +

+ See exactly what you get. +

+ +

+ Before you run your own assessment, see what a Korrith report looks like: the depth of the findings, the plain-language reasoning from Argus, the honest scoring, and the audit-grade evidence that makes it something you can actually show. +

+
+
+
+ + {/* What a Korrith report contains */} +
+
+ +

+ What a Korrith report contains +

+
+ + + {/* Make the first item span full width to break grid monotony */} + {reportFeatures.map((feature, idx) => ( + +
+ +
+ +
+

+ {feature.title} +

+

+ {feature.description} +

+
+
+ ))} +
+
+
+ + {/* Context Sections (Honesty & Conversation) */} +
+
+
+ + +
+ +
+

+ Why the honesty matters in a report +

+

+ A report you cannot trust is worse than no report. Because Korrith never marks something clean it could not confirm, and never marks something fixed it did not re-check, the document holds up under the scrutiny of an auditor, a customer, or a board. You are exporting the truth, not a sales sheet. +

+
+ + +
+ +
+

+ Use it to start the conversation +

+

+ If you assess for clients, the sample report shows them exactly what they will receive, which makes the value obvious before any access is granted. +

+
+ +
+
+
+ + {/* CTA Section */} +
+
+ +
+ +

+ Ready to see the difference? +

+ +
+ + + +
+
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/TermsPage.jsx b/apps/web/src/pages/TermsPage.jsx new file mode 100644 index 0000000..32b6ebc --- /dev/null +++ b/apps/web/src/pages/TermsPage.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; + +export default function TermsPage() { + return ( + + + Terms and Conditions | Korrith + + + +
+
+

Terms and Conditions

+

Last updated: 2026-06-16

+
+ +
+

Acceptance of terms

+

+ By accessing the website at korrith.com and utilizing any currently available Korrith services or waitlists, you are agreeing to be bound by these terms of service, all applicable laws and regulations, and agree that you are responsible for compliance with any applicable local laws. If you do not agree with any of these terms, you are prohibited from using or accessing this site. +

+ +

Development stage acknowledgment

+

+ You acknowledge that the Korrith platform is currently in active development. Any features, timelines, or capabilities described on the website represent our development goals and are subject to change without notice. Participation in any beta program is at your own discretion and risk. +

+ +

Use license

+

+ Permission is granted to temporarily download one copy of the materials (information or software) on Korrith's website for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title. +

+

+ Under this license you may not: +

+
    +
  • Modify or copy the materials
  • +
  • Attempt to use any future platform features to scan domains, networks, or infrastructure that you do not own or possess explicit authorization to test
  • +
  • Attempt to decompile or reverse engineer any software contained on the Korrith website or platform
  • +
  • Remove any copyright or other proprietary notations from the materials
  • +
+ +

Disclaimer

+

+ The materials on Korrith's website and any pre-release versions of the Korrith platform are provided on an 'as is' basis. Korrith makes no warranties, expressed or implied, and hereby disclaims and negates all other warranties including, without limitation, implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property or other violation of rights. +

+ +

Limitations of liability

+

+ In no event shall Korrith or its developers be liable for any damages (including, without limitation, damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use the materials on Korrith's website or early-access platform tools, even if Korrith or a Korrith authorized representative has been notified orally or in writing of the possibility of such damage. +

+ +

Accuracy of materials

+

+ The materials appearing on Korrith's website could include technical, typographical, or photographic errors. Korrith does not warrant that any of the materials on its website are accurate, complete or current. Korrith may make changes to the materials contained on its website at any time without notice. +

+ +

Modifications

+

+ Korrith may revise these terms of service for its website at any time without notice. By using this website you are agreeing to be bound by the then current version of these terms of service. +

+ +

Governing law

+

+ These terms and conditions are governed by and construed in accordance with the laws of the applicable jurisdiction and you irrevocably submit to the exclusive jurisdiction of the courts in that location. +

+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/TrustPage.jsx b/apps/web/src/pages/TrustPage.jsx new file mode 100644 index 0000000..45fb3f0 --- /dev/null +++ b/apps/web/src/pages/TrustPage.jsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + ShieldCheck, + Database, + BrainCircuit, + Activity, + Mail, + Eye, + ArrowRight +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +export default function TrustPage() { + return ( + + + Trust at Korrith | How We Secure Ourselves and Your Data + + + + {/* Hero Section */} +
+
+ +
+ +
+ + Trust & Transparency +
+ +

+ We hold ourselves to what we assess. +

+ +

+ A security company that cannot account for its own security has no standing to assess yours. So the discipline we apply to you, we apply to ourselves, and we are specific about it here. +

+
+
+
+ + {/* Principles Grid Section */} +
+
+ + {/* 1. Data Handling */} + +
+ +
+

How we handle your data

+

+ Korrith collects the posture and findings needed to assess and assure, and no more. Sensitive values are minimized. Confirmed credentials are never stored or displayed in plaintext, only a masked, fingerprinted record is kept. End-user personal data is deliberately kept out of the inventories we surface. We collect to assess, not to accumulate. +

+
+ + {/* 2. AI Constraints */} + +
+ +
+

How we constrain our own AI

+

+ Argus is constrained to reason only over real findings, and every insight is tied to verifiable evidence. It cannot invent risk, it cannot cross client boundaries, and it shows unverified results as unverified. The honesty is built into the system, not left to good intentions. +

+
+ + {/* 3. Active Testing Safe (Full Width for Emphasis) */} + +
+ +
+ +
+ +
+

How we keep active testing safe

+

+ Any active testing runs only inside cryptographically signed authorization, scoped to named targets and test types, in approved windows, on owned or signed-scope assets, staging first, with a global kill-switch that halts everything instantly. It is designed to do no harm, including never locking out accounts. The engine has no path to act outside its authorization. +

+
+
+ + {/* 4. Responsible Disclosure */} + +
+ +
+

Responsible disclosure

+

+ We welcome reports of any security concern in Korrith itself, and we will respond. +

+ +
+ + {/* 5. Honest Status */} + +
+ +
+

Honest status

+

+ Korrith is in active development, and we say so. We will not present a capability as live before it is, or a result as clean before we can prove it. +

+
+ +
+
+
+ + {/* CTA Section */} +
+
+ +

+ Have more questions? +

+

+ We believe in open communication about our architecture, capabilities, and roadmap. If there's something we haven't covered here, our FAQ goes deeper into how the platform works. +

+ + +
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/WebsiteSecurityAuditPage.jsx b/apps/web/src/pages/WebsiteSecurityAuditPage.jsx new file mode 100644 index 0000000..785870b --- /dev/null +++ b/apps/web/src/pages/WebsiteSecurityAuditPage.jsx @@ -0,0 +1,196 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + Globe, + Database, + FileText, + ShieldCheck, + Users, + ArrowRight, + ShieldAlert +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 20 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.6, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +export default function WebsiteSecurityAuditPage() { + return ( + + + Website Security Audits | Korrith + + + + {/* Hero Section */} +
+
+
+ +
+ +
+ + Website Security Audits +
+ +

+ Know whether your website is actually safe. +

+ +

+ Most website owners genuinely do not know if their site is secure, and the tools that claim to tell them produce either a meaningless green badge or an unreadable wall of warnings. Korrith audits your site the way an adversary would assess it, then tells you in plain language what is exposed and what to do about it. +

+ + +
+
+
+ + {/* Two Levels of Audit */} +
+
+ +

+ Two levels of audit +

+
+ + + {/* External Audit Card */} + +
+ +
+ +
+

External audit, no install.

+

+ Verify your domain and Korrith immediately assesses your site's internet-facing surface: headers, TLS and certificates, email authentication, DNS, exposed services, the technologies you run and whether they are outdated, and the integrity of third-party scripts. Nothing is installed and nothing is changed. +

+
+ + {/* Deeper Audit Card */} + +
+ +
+ +
+

Deeper audit, read-only.

+

+ For supported platforms, including WordPress, a read-only, outbound-only plugin reports your site's internal posture as cryptographically signed evidence: components and known vulnerabilities, configuration, identity and access, and file integrity. It never modifies your site, never exfiltrates data, and installs only with your explicit authorization. +

+
+
+
+
+ + {/* What You Actually Receive (Spotlight Layout) */} +
+
+ +
+ +
+

+ What you actually receive +

+

+ A prioritized, plain-language report that says what is wrong, why it matters, and what to do, with the genuinely urgent items separated from the merely tidy. Argus writes the explanation, so you do not need a security background to act on it. +

+
+
+
+ + {/* Grid: Honest Every Time & Perfect For Agencies */} +
+
+ + +
+
+ +
+

+ Honest, every time +

+
+

+ If a check could not run, the report says so. You will never get a clean bill of health that was really just a failed scan. That honesty is the difference between a report you can trust and a badge you cannot. +

+
+ + +
+
+ +
+

+ Perfect for agencies and owners alike +

+
+

+ Run it on your own site, or, if you manage sites for others, run it across all of them with per-client separation and reporting you can deliver. +

+
+
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/src/pages/WhyKorrithPage.jsx b/apps/web/src/pages/WhyKorrithPage.jsx new file mode 100644 index 0000000..6022ae4 --- /dev/null +++ b/apps/web/src/pages/WhyKorrithPage.jsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { Helmet } from 'react-helmet'; +import { Link } from 'react-router-dom'; +import { motion } from 'framer-motion'; +import { + ArrowRight, + BrainCircuit, + Fingerprint, + RefreshCcw, + ShieldCheck, + Crosshair, + Sparkles +} from 'lucide-react'; +import LayoutWrapper from '@/components/LayoutWrapper.jsx'; +import { Button } from '@/components/ui/button.jsx'; + +const fadeInUp = { + initial: { opacity: 0, y: 30 }, + whileInView: { opacity: 1, y: 0 }, + viewport: { once: true, margin: "-50px" }, + transition: { duration: 0.7, ease: "easeOut" } +}; + +const staggerContainer = { + initial: { opacity: 0 }, + whileInView: { opacity: 1 }, + viewport: { once: true, margin: "-50px" }, + transition: { staggerChildren: 0.15 } +}; + +const principles = [ + { + title: "Grounded AI: no invented risk", + body: "AI in security usually means a black box that produces a confident paragraph you cannot check. We built Argus the opposite way. Every insight Argus produces is tied to a specific, verifiable finding through a grounding mechanism, so each claim carries its evidence with it. If Argus cannot point to a real finding behind a statement, it does not make the statement. The practical result: you can trust the priorities, because none of them are hallucinated, and you can click any insight and land on the exact finding that produced it.", + icon: BrainCircuit, + color: "text-purple-400", + bg: "bg-purple-500/10", + border: "border-purple-500/20" + }, + { + title: "Provable authorization: the engine cannot act outside its scope", + body: "The line between an assurance product and a hacking tool is authorization. Most tools treat that line as a policy in a document. Korrith treats it as code. Active testing passes through a single gate that fails closed: it runs only when a current, cryptographically signed authorization covers that exact target and test type, in an approved window, on the agreed environment, with the kill-switch up. Anything else is denied, with a reason. There is no path for the engine to act outside the authorization you grant, and a single control halts everything instantly. This is why we can offer active validation responsibly.", + icon: Fingerprint, + color: "text-blue-400", + bg: "bg-blue-500/10", + border: "border-blue-500/20" + }, + { + title: "The closed loop, with proof of resolution", + body: "Finding a problem is the easy half. Proving it is gone is the half that matters at audit time. Korrith tracks every finding through its whole life: first seen, validated as real, remediated, and verified closed by a later check that actually observed its absence. A fix is not marked done because someone clicked a button. It is shown done because Korrith looked again and the exposure was no longer there. That record is continuous, so a regression that quietly reopens is caught the same week, not at the next annual review.", + icon: RefreshCcw, + color: "text-emerald-400", + bg: "bg-emerald-500/10", + border: "border-emerald-500/20" + }, + { + title: "Honest by design: never fake-clean", + body: "A security tool that reports clean when it simply could not check is worse than useless, because it sells you false confidence. Korrith reports coverage exactly as it is. Where a check could not run or a data source was unavailable, the product says so. Unverified is shown as unverified. That discipline runs through the scoring, the dashboards, and the reports, and it is the reason a Korrith report is something you can put in front of an auditor or a board without flinching.", + icon: ShieldCheck, + color: "text-amber-400", + bg: "bg-amber-500/10", + border: "border-amber-500/20" + }, + { + title: "Built for the threat as it is now", + body: "Attackers use AI to find and exploit faster, and they shop a dark-web economy of stolen credentials and ready footholds. Korrith is built to meet that: external first, the way an adversary maps you, with reasoning that prioritizes by what is actually being weaponized, and a clear path to quantum-safe cryptography as that threat matures.", + icon: Crosshair, + color: "text-rose-400", + bg: "bg-rose-500/10", + border: "border-rose-500/20" + }, + { + title: "What this means for you", + body: "You stop drowning in findings and start working from a short, reasoned, evidence-backed list. You can prove your security on demand. And you can trust what the tool tells you, because it refuses to tell you anything it cannot back.", + icon: Sparkles, + color: "text-primary", + bg: "bg-primary/10", + border: "border-primary/20" + } +]; + +export default function WhyKorrithPage() { + return ( + + + Why Korrith | Reasoned, Proven, Honest Security + + + + {/* Hero Section */} +
+
+
+ +
+ +

+ Most tools find problems.
+ + Korrith proves them. + +

+ +

+ Korrith was built to close the gap between a finding and the truth: is it real, and is it actually fixed. Everything below is a design decision we made on purpose, because security you cannot prove is just a feeling. +

+
+
+
+ + {/* Core Principles (Numbered List Layout) */} +
+
+
+ {principles.map((principle, index) => { + const Icon = principle.icon; + return ( + + {/* Decorative Number */} +
+
+ {(index + 1).toString().padStart(2, '0')} +
+
+ + {/* Content Card */} +
+
+ +
+
+ {(index + 1).toString().padStart(2, '0')} +
+
+ +
+
+ +

+ {principle.title} +

+ +

+ {principle.body} +

+
+
+ ); + })} +
+
+
+ + {/* CTA Section */} +
+
+ +
+ +

+ Stop guessing. Start proving. +

+

+ Experience the platform built to prioritize truth over noise, and evidence over assumptions. +

+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js new file mode 100644 index 0000000..55b7609 --- /dev/null +++ b/apps/web/tailwind.config.js @@ -0,0 +1,87 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: [ + './pages/**/*.{js,jsx}', + './components/**/*.{js,jsx}', + './app/**/*.{js,jsx}', + './src/**/*.{js,jsx}', + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + korrith: { + ai: "hsl(var(--accent-ai))", + green: "hsl(var(--accent-green))", + amber: "hsl(var(--accent-amber))", + red: "hsl(var(--accent-red))", + } + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + fontFamily: { + sans: ["Inter", "sans-serif"], + mono: ["JetBrains Mono", "monospace"], + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} \ No newline at end of file diff --git a/apps/web/tools/generate-llms.js b/apps/web/tools/generate-llms.js new file mode 100644 index 0000000..3a373dc --- /dev/null +++ b/apps/web/tools/generate-llms.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; + +const CLEAN_CONTENT_REGEX = { + comments: /\/\*[\s\S]*?\*\/|\/\/.*$/gm, + templateLiterals: /`[\s\S]*?`/g, + strings: /'[^']*'|"[^"]*"/g, + jsxExpressions: /\{.*?\}/g, + htmlEntities: { + quot: /"/g, + amp: /&/g, + lt: /</g, + gt: />/g, + apos: /'/g + } +}; + +const EXTRACTION_REGEX = { + // `element={}` puts a `>` inside the tag, so the attribute scan has + // to step over brace groups instead of stopping at the first `>`. + route: /{]|\{[^}]*\})*\/?>/g, + path: /path=["']([^"']+)["']/, + element: /element=\{([\s\S]*)\}/, + elementComponent: /<(\w+)/g, + helmet: /]*?>([\s\S]*?)<\/Helmet>/i, + helmetTest: //i, + title: /]*?>\s*(.*?)\s*<\/title>/i, + description: /') + .replace(CLEAN_CONTENT_REGEX.htmlEntities.apos, "'") + .trim(); +} + +function extractRoutes(appJsxPath) { + if (!fs.existsSync(appJsxPath)) return new Map(); + + try { + const content = fs.readFileSync(appJsxPath, 'utf8'); + const routes = new Map(); + const routeMatches = [...content.matchAll(EXTRACTION_REGEX.route)]; + + for (const match of routeMatches) { + const routeTag = match[0]; + const pathMatch = routeTag.match(EXTRACTION_REGEX.path); + const elementMatch = routeTag.match(EXTRACTION_REGEX.element); + const isIndex = routeTag.includes('index'); + + if (elementMatch) { + // A guarded route reads `element={}`, + // so the page is the innermost component rather than the first one. + const componentNames = [...elementMatch[1].matchAll(EXTRACTION_REGEX.elementComponent)]; + const componentName = componentNames.at(-1)?.[1]; + + if (!componentName) continue; + + let routePath; + + if (isIndex) { + routePath = '/'; + } else if (pathMatch) { + routePath = pathMatch[1].startsWith('/') ? pathMatch[1] : `/${pathMatch[1]}`; + } + + routes.set(componentName, routePath); + } + } + + return routes; + } catch (error) { + return new Map(); + } +} + +function findReactFiles(dir) { + return fs.readdirSync(dir).map(item => path.join(dir, item)); +} + +function extractHelmetData(content, filePath, routes) { + const cleanedContent = cleanContent(content); + + if (!EXTRACTION_REGEX.helmetTest.test(cleanedContent)) { + return null; + } + + const helmetMatch = content.match(EXTRACTION_REGEX.helmet); + if (!helmetMatch) return null; + + const helmetContent = helmetMatch[1]; + const titleMatch = helmetContent.match(EXTRACTION_REGEX.title); + const descMatch = helmetContent.match(EXTRACTION_REGEX.description); + + const title = cleanText(titleMatch?.[1]); + const description = cleanText(descMatch?.[1]); + + const fileName = path.basename(filePath, path.extname(filePath)); + const url = routes.size && routes.has(fileName) + ? routes.get(fileName) + : generateFallbackUrl(fileName); + + return { + url, + title: title || 'Untitled Page', + description: description || 'No description available' + }; +} + +function generateFallbackUrl(fileName) { + const cleanName = fileName.replace(/Page$/, '').toLowerCase(); + return cleanName === 'app' ? '/' : `/${cleanName}`; +} + +function generateLlmsTxt(pages) { + const sortedPages = pages.sort((a, b) => a.title.localeCompare(b.title)); + const pageEntries = sortedPages.map(page => + `- [${page.title}](${page.url}): ${page.description}` + ).join('\n'); + + return `## Pages\n${pageEntries}`; +} + +function ensureDirectoryExists(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +function processPageFile(filePath, routes) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + return extractHelmetData(content, filePath, routes); + } catch (error) { + console.error(`❌ Error processing ${filePath}:`, error.message); + return null; + } +} + +function main() { + const pagesDir = path.join(process.cwd(), 'src', 'pages'); + const appJsxPath = path.join(process.cwd(), 'src', 'App.jsx'); + + let pages = []; + + if (!fs.existsSync(pagesDir)) { + pages.push(processPageFile(appJsxPath, new Map())) + pages = pages.filter(Boolean); + } else { + const routes = extractRoutes(appJsxPath); + const reactFiles = findReactFiles(pagesDir); + + pages = reactFiles + .map(filePath => processPageFile(filePath, routes)) + .filter(Boolean); + } + + if (pages.length === 0) { + console.error('❌ No pages with Helmet components found!'); + process.exit(1); + } + + + const llmsTxtContent = generateLlmsTxt(pages); + const outputPath = path.join(process.cwd(), 'public', 'llms.txt'); + + ensureDirectoryExists(path.dirname(outputPath)); + fs.writeFileSync(outputPath, llmsTxtContent, 'utf8'); +} + +const isMainModule = import.meta.url === `file://${process.argv[1]}`; + +if (isMainModule) { + main(); +} diff --git a/apps/web/tools/install-missing-components.js b/apps/web/tools/install-missing-components.js new file mode 100644 index 0000000..8451c47 --- /dev/null +++ b/apps/web/tools/install-missing-components.js @@ -0,0 +1,226 @@ +import fs from "fs"; +import path from "path"; +import { exec } from "child_process"; +import { promisify } from "util"; +import { fileURLToPath } from "url"; + +const APP_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const COMPONENTS_JSON_FILE = "components.json"; +const PACKAGE_JSON_FILE = "package.json"; +const TAILWIND_CONFIG_FILE = "tailwind.config.js"; +const FILES_UPDATED_BY_SHADCN_INIT = [ + PACKAGE_JSON_FILE, + COMPONENTS_JSON_FILE, + TAILWIND_CONFIG_FILE, +]; + +const JSX_EXTENSION = "jsx"; +const JS_EXTENSION = "js"; + +const COMPONENT_NAME_TO_INSTALLABLE_COMPONENT_NAME_MAP = { + toaster: "toast", + "use-toast": "toast", +}; + +async function run() { + let stdout = ""; + let error = null; + const updatedFiles = []; + + try { + process.chdir(APP_DIR); + + // Get a list of components that are imported but not installed + const uniqueComponentNames = await findImportedComponentNames(); + const missingMask = await Promise.all( + uniqueComponentNames.map((name) => + componentExists(name).then((exists) => !exists), + ), + ); + const missingComponentNames = uniqueComponentNames.filter( + (_, i) => missingMask[i], + ); + const installableComponentNames = [ + ...new Set( + missingComponentNames.map( + (name) => + COMPONENT_NAME_TO_INSTALLABLE_COMPONENT_NAME_MAP[name] ?? name, + ), + ), + ]; + + // If the components.json file doesn't exist, initialize shadcn + const componentsJsonPath = path.join(APP_DIR, COMPONENTS_JSON_FILE); + const componentsJsonExists = await pathExists(componentsJsonPath); + + if (!componentsJsonExists) { + const shadcnInitResult = await runCommand( + "npx -y shadcn@3 init -f --src-dir --no-base-style", + ); + + const shadcnInitOutput = [ + shadcnInitResult.stdout, + shadcnInitResult.stderr, + ] + .filter(Boolean) + .join("\n"); + + stdout = shadcnInitOutput; + + const isErrorStatus = !!shadcnInitResult.status; + + if (isErrorStatus) { + error = `Shadcn failed to init: ${shadcnInitOutput}`; + } else { + updatedFiles.push(...FILES_UPDATED_BY_SHADCN_INIT); + } + } + + // If there are installable components, install them + if (installableComponentNames.length && !error) { + const shadcnAddResult = await runCommand( + `npx -y shadcn@3 add ${installableComponentNames.join(" ")} -y -o --src-dir`, + ); + + const shadcnAddOutput = [shadcnAddResult.stdout, shadcnAddResult.stderr] + .filter(Boolean) + .join("\n"); + + stdout = stdout ? `${stdout}\n${shadcnAddOutput}` : shadcnAddOutput; + + const isErrorStatus = !!shadcnAddResult.status; + + if (isErrorStatus) { + error = `Shadcn failed to add: ${shadcnAddOutput}`; + } else { + // Example output of shadcnAddOutput: + // - src/components/ui/button.tsx + // - src/components/ui/input.tsx + // The code below extracts these file paths like this: src/components/ui/button.tsx, src/components/ui/input.tsx + const filesUpdatedByShadcnAdd = shadcnAddOutput + .split("\n") + .flatMap((line) => { + const match = line.match(/^\s*-\s*(src\/\S+)/); + return match ? [match[1].trim()] : []; + }); + + updatedFiles.push(...filesUpdatedByShadcnAdd); + } + } + } catch (err) { + error = error ?? err?.message ?? "unknown error"; + } + + const uniqueUpdatedFiles = [...new Set(updatedFiles)]; + + const result = { + stdout: stdout, + error: error, + updatedFiles: uniqueUpdatedFiles, + }; + + console.log(JSON.stringify(result)); +} + +async function runCommand(command) { + try { + const { stdout, stderr } = await promisify(exec)(command); + + return { + stdout: stdout ?? "", + stderr: stderr ?? "", + status: 0, + }; + } catch (error) { + return { + stdout: error.stdout ?? "", + stderr: error.stderr ?? "", + status: error.code ?? 1, + }; + } +} + +async function pathExists(filePath) { + try { + await fs.promises.access(filePath); + return true; + } catch { + return false; + } +} + +async function scanDirectoryForUiImports(directoryPath) { + const componentNames = new Set(); + const entries = await fs.promises.readdir(directoryPath, { + withFileTypes: true, + }); + + for (const entry of entries) { + const fullEntryPath = path.join(directoryPath, entry.name); + const isDirectory = entry.isDirectory(); + + if (isDirectory) { + const innerDirectoryComponentNames = + await scanDirectoryForUiImports(fullEntryPath); + + for (const name of innerDirectoryComponentNames) { + componentNames.add(name); + } + } + + const isJsxFile = entry.name.endsWith(`.${JSX_EXTENSION}`); + + if (isJsxFile) { + try { + const fileContent = await fs.promises.readFile(fullEntryPath, "utf8"); + + for (const [, componentName] of fileContent.matchAll( + /@\/components\/ui\/([a-z0-9-]+)/g, + )) { + componentNames.add(componentName); + } + } catch { + // Skip unreadable files + } + } + } + + return componentNames; +} + +async function findImportedComponentNames() { + const srcPath = path.join(APP_DIR, "src"); + const srcExists = await pathExists(srcPath); + + if (!srcExists) { + return []; + } + + const uniqueComponentNames = await scanDirectoryForUiImports(srcPath); + + return [...uniqueComponentNames]; +} + +async function componentExists(componentName) { + const componentsUiPath = path.join(APP_DIR, "src", "components", "ui"); + const hooksPath = path.join(APP_DIR, "src", "hooks"); + + const existenceChecks = await Promise.all( + [JSX_EXTENSION, JS_EXTENSION].map(async (extension) => { + const fileName = `${componentName}.${extension}`; + + return ( + (await pathExists(path.join(componentsUiPath, fileName))) || + (await pathExists(path.join(hooksPath, fileName))) + ); + }), + ); + + return existenceChecks.some(Boolean); +} + +run(); diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js new file mode 100644 index 0000000..7b43744 --- /dev/null +++ b/apps/web/vite.config.js @@ -0,0 +1,421 @@ +import path from 'node:path'; +import react from '@vitejs/plugin-react'; +import { createLogger, defineConfig } from 'vite'; +import inlineEditPlugin from './plugins/visual-editor/vite-plugin-react-inline-editor.js'; +import editModeDevPlugin from './plugins/visual-editor/vite-plugin-edit-mode.js'; +import iframeRouteRestorationPlugin from './plugins/vite-plugin-iframe-route-restoration.js'; +import sitePagesPlugin from './plugins/vite-plugin-site-pages.js'; +import pocketbaseAuthPlugin from './plugins/vite-plugin-pocketbase-auth.js'; +import sessionJournalPlugin from './plugins/session-journal/vite-plugin-session-journal.js'; + +import { readFileSync } from 'node:fs'; + +const pkg = JSON.parse(readFileSync('./package.json', 'utf-8')); +const allDeps = Object.keys(pkg.dependencies || {}); + +const isDev = process.env.NODE_ENV !== 'production'; + +// Only the Horizons editor may read this dev server cross-origin. `cors: true` +// sends `Access-Control-Allow-Origin: *`, which lets any site read the source +// transforms and call the dev-only APIs. +const AllowedEditorOrigins = [ + 'https://horizons.hostinger.com', + 'https://horizons.hostinger.dev', +]; + +const configHorizonsViteErrorHandler = ` +const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const addedNode of mutation.addedNodes) { + if ( + addedNode.nodeType === Node.ELEMENT_NODE && + ( + addedNode.tagName?.toLowerCase() === 'vite-error-overlay' || + addedNode.classList?.contains('backdrop') + ) + ) { + handleViteOverlay(addedNode); + } + } + } +}); + +observer.observe(document.documentElement, { + childList: true, + subtree: true +}); + +function handleViteOverlay(node) { + if (!node.shadowRoot) { + return; + } + + const backdrop = node.shadowRoot.querySelector('.backdrop'); + + if (backdrop) { + const overlayHtml = backdrop.outerHTML; + const parser = new DOMParser(); + const doc = parser.parseFromString(overlayHtml, 'text/html'); + const messageBodyElement = doc.querySelector('.message-body'); + const fileElement = doc.querySelector('.file'); + const messageText = messageBodyElement ? messageBodyElement.textContent.trim() : ''; + const fileText = fileElement ? fileElement.textContent.trim() : ''; + const error = messageText + (fileText ? ' File:' + fileText : ''); + + window.parent.postMessage({ + type: 'horizons-vite-error', + error, + }, '*'); + } +} +`; + +const configHorizonsRuntimeErrorHandler = ` +window.onerror = (message, source, lineno, colno, errorObj) => { + const errorDetails = errorObj ? JSON.stringify({ + name: errorObj.name, + message: errorObj.message, + stack: errorObj.stack, + source, + lineno, + colno, + }) : null; + + window.parent.postMessage({ + type: 'horizons-runtime-error', + message, + error: errorDetails + }, '*'); +}; +`; + +const configHorizonsConsoleErrorHandler = ` +const originalConsoleError = console.error; +const MATCH_LINE_COL_REGEX = /:(\\d+):(\\d+)\\)?\\s*$/; // regex to match the :lineNum:colNum +const MATCH_AT_REGEX = /^\\s*at\\s+(?:async\\s+)?(?:.*?\\s+)?\\(?/; // regex to remove the 'at' keyword and any 'async' or function name +const MATCH_PATH_REGEX = /^\\//; // regex to remove the leading slash + +function parseStackFrameLine(line) { + const lineColMatch = line.match(MATCH_LINE_COL_REGEX); + if (!lineColMatch) return null; + const [, lineNum, colNum] = lineColMatch; + const suffix = \`:\${lineNum}:\${colNum}\`; + const idx = line.lastIndexOf(suffix); + if (idx === -1) return null; + const before = line.substring(0, idx); + const path = before.replace(MATCH_AT_REGEX, '').trim(); + + if (!path) return null; + + try { + const pathname = new URL(path).pathname; + const filePath = pathname.replace(MATCH_PATH_REGEX, '') || pathname; + return \`\${filePath}:\${lineNum}:\${colNum}\`; + } catch (e) { + const filePath = path.replace(MATCH_PATH_REGEX, '') || path; + return \`\${filePath}:\${lineNum}:\${colNum}\`; + } +} + +function getFilePathFromStack(stack, skipFrames = 0) { + if (!stack || typeof stack !== 'string') return null; + const lines = stack.split('\\n').slice(1); + + const frames = lines.map(line => parseStackFrameLine(line.replace(/\\r$/, ''))).filter(Boolean); + + return frames[skipFrames] ?? null; +} + +function formatConsoleMessage(args, skipStackFrames = 1) { + let messageString = ''; + let filePath = null; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg instanceof Error) { + filePath = getFilePathFromStack(arg.stack, 0); + messageString = \`\${arg.name}: \${arg.message}\`; + if (filePath) { + messageString = \`\${messageString} at \${filePath}\`; + } + break; + } + } + + if (!messageString) { + messageString = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' '); + const stack = new Error().stack; + filePath = getFilePathFromStack(stack, skipStackFrames); + if (filePath) { + messageString = \`\${messageString} at \${filePath}\`; + } + } + + return messageString; +} + +console.error = function(...args) { + originalConsoleError.apply(console, args); + + window.parent.postMessage({ + type: 'horizons-console-error', + error: formatConsoleMessage(args, 1) + }, '*'); +}; + +const originalConsoleWarn = console.warn; + +console.warn = function(...args) { + originalConsoleWarn.apply(console, args); + + window.parent.postMessage({ + type: 'horizons-console-warn', + warning: formatConsoleMessage(args, 1) + }, '*'); +}; +`; + +const configWindowFetchMonkeyPatch = ` +// Prevents authentication failures (PB returns 400) from crashing the app — use console.info, not console.error/PM2. +// [urlPattern, bodyPattern] tuples; align with sandboxErrorUtils ERROR_BLACKLIST. +const BENIGN_FETCH_ERRORS = [ + [/hcgi\\/platform\\/api\\/collections\\/.*auth-with-password.*/i, /Failed to authenticate/i], + [/hcgi\\/api\\//i, /Insufficient credits/i], + // A user-supplied integration secret (Stripe/PayPal/Twilio/...) is not set yet — an expected setup state, not a bug to fix. + [/hcgi\\/api\\//i, /INTEGRATION_NOT_CONFIGURED/i], +]; + +function isBenignFetchError(url, body) { + return BENIGN_FETCH_ERRORS.some(([urlPattern, bodyPattern]) => + urlPattern.test(url) && (!bodyPattern || bodyPattern.test(body))); +} + +const PLATFORM_URL_PATTERN = /hcgi\\/platform\\//i; +const VALIDATION_CODE_TEXT_PATTERN = /validation_/; + +function hasValidationCode(value) { + if (value == null) { + return false; + } + if (typeof value === 'string') { + return value.startsWith('validation_'); + } + if (Array.isArray(value)) { + return value.some(hasValidationCode); + } + if (typeof value === 'object') { + return Object.values(value).some(hasValidationCode); + } + return false; +} + +function isValidationFetchWarning(url, body) { + if (!PLATFORM_URL_PATTERN.test(url)) { + return false; + } + try { + return hasValidationCode(JSON.parse(body)); + } catch { + return VALIDATION_CODE_TEXT_PATTERN.test(body); + } +} + +const originalFetch = window.fetch; + +window.fetch = function(...args) { + const url = args[0] instanceof Request ? args[0].url : args[0]; + + // Skip WebSocket URLs + if (url.startsWith('ws:') || url.startsWith('wss:')) { + return originalFetch.apply(this, args); + } + + return originalFetch.apply(this, args) + .then(async response => { + const contentType = response.headers.get('Content-Type') || ''; + + // Exclude HTML document responses + const isDocumentResponse = + contentType.includes('text/html') || + contentType.includes('application/xhtml+xml'); + + // no-cors requests (e.g. Google Analytics/Ads beacons) always return an opaque + const isOpaqueResponse = response.type === 'opaque'; + + if (!response.ok && !isDocumentResponse && !isOpaqueResponse) { + const responseClone = response.clone(); + const errorFromRes = await responseClone.text(); + const requestUrl = response.url; + const errorMessage = \`Fetch error from \${requestUrl}: \${errorFromRes}\`; + + if (isBenignFetchError(requestUrl, errorFromRes)) { + console.info(errorMessage); + } else if (isValidationFetchWarning(requestUrl, errorFromRes)) { + console.warn(errorMessage); + } else { + console.error(errorMessage); + } + } + + return response; + }) + .catch(error => { + if (!url.match(/\.html?$/i)) { + // Cancelled in-flight fetch (e.g. preview reload) — not a server failure. + if (error?.name === 'AbortError') { + console.info(error); + } else { + console.error(error); + } + } + + throw error; + }); +}; +`; + +const configNavigationHandler = ` +if (window.navigation && window.self !== window.top) { + window.navigation.addEventListener('navigate', (event) => { + const url = event.destination.url; + + try { + const destinationUrl = new URL(url); + const destinationOrigin = destinationUrl.origin; + const currentOrigin = window.location.origin; + + if (destinationOrigin === currentOrigin) { + return; + } + } catch (error) { + return; + } + + window.parent.postMessage({ + type: 'horizons-navigation-error', + url, + }, '*'); + }); +} +`; + +const addTransformIndexHtml = { + name: 'add-transform-index-html', + transformIndexHtml(html) { + const tags = [ + { + tag: 'script', + attrs: { type: 'module' }, + children: configHorizonsRuntimeErrorHandler, + injectTo: 'head', + }, + { + tag: 'script', + attrs: { type: 'module' }, + children: configHorizonsViteErrorHandler, + injectTo: 'head', + }, + { + tag: 'script', + attrs: { type: 'module' }, + children: configHorizonsConsoleErrorHandler, + injectTo: 'head', + }, + { + tag: 'script', + attrs: { type: 'module' }, + children: configWindowFetchMonkeyPatch, + injectTo: 'head', + }, + { + tag: 'script', + attrs: { type: 'module' }, + children: configNavigationHandler, + injectTo: 'head', + }, + ]; + + if (!isDev && process.env.TEMPLATE_BANNER_SCRIPT_URL && process.env.TEMPLATE_REDIRECT_URL) { + tags.push( + { + tag: 'script', + attrs: { + src: process.env.TEMPLATE_BANNER_SCRIPT_URL, + 'template-redirect-url': process.env.TEMPLATE_REDIRECT_URL, + ...(process.env.TEMPLATE_BANNER_MAIN_TEXT && { 'template-main-text': process.env.TEMPLATE_BANNER_MAIN_TEXT }), + ...(process.env.TEMPLATE_BANNER_CTA_TEXT && { 'template-cta-text': process.env.TEMPLATE_BANNER_CTA_TEXT }), + ...(process.env.TEMPLATE_BANNER_THEME && { 'template-theme': process.env.TEMPLATE_BANNER_THEME }), + }, + injectTo: 'head', + } + ); + } + + return { + html, + tags, + }; + }, +}; + +console.warn = () => { }; + +const logger = createLogger() +const loggerError = logger.error + +logger.error = (msg, options) => { + if (options?.error?.toString().includes('CssSyntaxError: [postcss]')) { + return; + } + + loggerError(msg, options); +} + +export default defineConfig({ + optimizeDeps: { + include: allDeps, + }, + customLogger: logger, + plugins: [ + ...(isDev ? [inlineEditPlugin(), editModeDevPlugin(), iframeRouteRestorationPlugin(), sitePagesPlugin(), pocketbaseAuthPlugin(), sessionJournalPlugin()] : []), + react(), + addTransformIndexHtml + ], + server: { + port: 3000, + cors: { origin: AllowedEditorOrigins }, + headers: { + 'Cross-Origin-Embedder-Policy': 'credentialless', + }, + allowedHosts: [ + '.app-preview.com', + '.app-preview.io', + ], + fs: { + strict: true, + allow: [ + path.resolve(__dirname), + path.join(path.resolve(__dirname, '../..'), 'node_modules'), + ], + }, + }, + resolve: { + extensions: ['.jsx', '.js', '.json'], + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + build: { + rollupOptions: { + external: [ + '@babel/parser', + '@babel/traverse', + '@babel/generator', + '@babel/types' + ], + checks: { + pluginTimings: false, + } + } + } +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d64ece6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,67 @@ +# Korrith — production stack for Coolify / plain Docker Compose. +# +# Expose the `web` service (container port 80) with your domain. +# It serves the built frontend and reverse-proxies same-origin paths: +# /hcgi/api/* -> api:3001 (Express) +# /hcgi/platform/* -> pocketbase:8090 (PocketBase API + admin UI at /hcgi/platform/_/) +# +# Required environment variables are documented in .env.example. + +services: + pocketbase: + build: + context: ./apps/pocketbase + restart: unless-stopped + environment: + PB_ENCRYPTION_KEY: ${PB_ENCRYPTION_KEY:?Set PB_ENCRYPTION_KEY (32+ random chars)} + PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL:?Set the initial PocketBase admin email} + PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD:?Set the initial PocketBase admin password} + volumes: + - pocketbase-data:/pb_data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8090/api/health"] + interval: 10s + timeout: 5s + retries: 12 + start_period: 10s + + api: + build: + context: . + dockerfile: apps/api/Dockerfile + restart: unless-stopped + environment: + PORT: 3001 + CORS_ORIGIN: ${CORS_ORIGIN:-*} + PB_SUPERUSER_EMAIL: ${PB_SUPERUSER_EMAIL} + PB_SUPERUSER_PASSWORD: ${PB_SUPERUSER_PASSWORD} + SMTP_HOST: ${SMTP_HOST:?Set SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:?Set SMTP_USER} + SMTP_PASSWORD: ${SMTP_PASSWORD:?Set SMTP_PASSWORD} + MAIL_FROM: ${MAIL_FROM:-} + MAIL_REPLY_TO: ${MAIL_REPLY_TO:-} + ADMIN_EMAILS: ${ADMIN_EMAILS:-} + depends_on: + pocketbase: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3001/health-check').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 10 + start_period: 20s + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + restart: unless-stopped + ports: + - "80" # Coolify: point your domain at this service, container port 80 + depends_on: + - api + - pocketbase + +volumes: + pocketbase-data: diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..65a5ce0 --- /dev/null +++ b/knip.json @@ -0,0 +1,226 @@ +{ + "ignoreWorkspaces": [ + "apps/!(web|api)" + ], + "workspaces": { + ".": { + "entry": [], + "project": [] + }, + "apps/web": { + "entry": [ + "components.json", + "eslint.config.mjs", + "eslint.unicode-escapes-formatter.mjs", + "eslint.unicode-escapes-plugin.mjs", + "index.html", + "jsconfig.json", + "package.json", + "plugins/session-journal/session-journal-client.js", + "plugins/session-journal/vite-plugin-session-journal.js", + "plugins/site-pages/site-pages-script.js", + "plugins/site-pages/site-pages-server.js", + "plugins/utils/ast-utils.js", + "plugins/utils/same-origin.js", + "plugins/visual-editor/api/draft-snapshot.js", + "plugins/visual-editor/api/draft.js", + "plugins/visual-editor/constants/icons.js", + "plugins/visual-editor/constants/layout.js", + "plugins/visual-editor/constants/messages.js", + "plugins/visual-editor/constants/selectors.js", + "plugins/visual-editor/constants/theme.js", + "plugins/visual-editor/edit-mode-script.js", + "plugins/visual-editor/server/source-writer.js", + "plugins/visual-editor/state/annotation-state.js", + "plugins/visual-editor/state/editing-state.js", + "plugins/visual-editor/state/history-state.js", + "plugins/visual-editor/state/multi-select-state.js", + "plugins/visual-editor/state/panel-state.js", + "plugins/visual-editor/transform/editability.js", + "plugins/visual-editor/transform/inject-attributes.js", + "plugins/visual-editor/ui/annotation-panel/annotation-markers.js", + "plugins/visual-editor/ui/annotation-panel/panel.js", + "plugins/visual-editor/ui/annotation-panel/styles.js", + "plugins/visual-editor/ui/annotation-panel/template.js", + "plugins/visual-editor/ui/inline-edit/cursor-styles.js", + "plugins/visual-editor/ui/inline-edit/edit-action.js", + "plugins/visual-editor/ui/inline-edit/styles.js", + "plugins/visual-editor/ui/overlays/hover-outline.js", + "plugins/visual-editor/ui/overlays/selection-rect.js", + "plugins/visual-editor/ui/overlays/type-tooltip.js", + "plugins/visual-editor/ui/text-format/color-action/color-action.js", + "plugins/visual-editor/ui/text-format/color-action/color-conversions.js", + "plugins/visual-editor/ui/text-format/color-action/gradient.js", + "plugins/visual-editor/ui/text-format/color-action/hex-input.js", + "plugins/visual-editor/ui/text-format/color-action/picker-square.js", + "plugins/visual-editor/ui/text-format/color-action/styles.js", + "plugins/visual-editor/ui/text-format/color-action/swatches.js", + "plugins/visual-editor/ui/text-format/color-action/template.js", + "plugins/visual-editor/ui/text-format/dropdown-panel.js", + "plugins/visual-editor/ui/text-format/font-family-action/font-family-action.js", + "plugins/visual-editor/ui/text-format/font-family-action/styles.js", + "plugins/visual-editor/ui/text-format/font-family-action/template.js", + "plugins/visual-editor/ui/text-format/link-action/link-action.js", + "plugins/visual-editor/ui/text-format/link-action/styles.js", + "plugins/visual-editor/ui/text-format/link-action/template.js", + "plugins/visual-editor/ui/text-format/size-action/size-action.js", + "plugins/visual-editor/ui/text-format/size-action/styles.js", + "plugins/visual-editor/ui/text-format/size-action/template.js", + "plugins/visual-editor/ui/text-format/style-actions.js", + "plugins/visual-editor/ui/text-format/toolbar/positioning.js", + "plugins/visual-editor/ui/text-format/toolbar/styles.js", + "plugins/visual-editor/ui/text-format/toolbar/template.js", + "plugins/visual-editor/ui/text-format/toolbar/toolbar.js", + "plugins/visual-editor/utils/dom-utils.js", + "plugins/visual-editor/utils/html-utils.js", + "plugins/visual-editor/utils/inline-style-state.js", + "plugins/visual-editor/utils/parent-frame.js", + "plugins/visual-editor/utils/selection-mode-metadata.js", + "plugins/visual-editor/utils/session-history-command.js", + "plugins/visual-editor/vite-plugin-edit-mode.js", + "plugins/visual-editor/vite-plugin-react-inline-editor.js", + "plugins/vite-plugin-iframe-route-restoration.js", + "plugins/vite-plugin-pocketbase-auth.js", + "plugins/vite-plugin-site-pages.js", + "postcss.config.js", + "src/api/EcommerceApi.js", + "src/api/InternalEcommerceSubscriptionsApi.js", + "src/App.jsx", + "src/components/BookingSlotPicker.jsx", + "src/components/CountUp.jsx", + "src/components/integrated-ai-chat.jsx", + "src/components/ManageSubscriptionButton.jsx", + "src/components/PlansList.jsx", + "src/components/ProductsList.jsx", + "src/components/ProtectedRoute.jsx", + "src/components/Reveal.jsx", + "src/components/ScrollToTop.jsx", + "src/components/Seo.jsx", + "src/components/ShoppingCart.jsx", + "src/components/SubscribeButton.jsx", + "src/components/SubscriptionAccountSection.jsx", + "src/components/ui/accordion.jsx", + "src/components/ui/alert-dialog.jsx", + "src/components/ui/alert.jsx", + "src/components/ui/aspect-ratio.jsx", + "src/components/ui/avatar.jsx", + "src/components/ui/badge.jsx", + "src/components/ui/breadcrumb.jsx", + "src/components/ui/button-group.jsx", + "src/components/ui/button.jsx", + "src/components/ui/calendar.jsx", + "src/components/ui/card.jsx", + "src/components/ui/carousel.jsx", + "src/components/ui/chart.jsx", + "src/components/ui/checkbox.jsx", + "src/components/ui/collapsible.jsx", + "src/components/ui/command.jsx", + "src/components/ui/context-menu.jsx", + "src/components/ui/dialog.jsx", + "src/components/ui/drawer.jsx", + "src/components/ui/dropdown-menu.jsx", + "src/components/ui/empty.jsx", + "src/components/ui/field.jsx", + "src/components/ui/form.jsx", + "src/components/ui/hover-card.jsx", + "src/components/ui/input-group.jsx", + "src/components/ui/input-otp.jsx", + "src/components/ui/input.jsx", + "src/components/ui/item.jsx", + "src/components/ui/kbd.jsx", + "src/components/ui/label.jsx", + "src/components/ui/menubar.jsx", + "src/components/ui/navigation-menu.jsx", + "src/components/ui/pagination.jsx", + "src/components/ui/popover.jsx", + "src/components/ui/progress.jsx", + "src/components/ui/radio-group.jsx", + "src/components/ui/resizable.jsx", + "src/components/ui/scroll-area.jsx", + "src/components/ui/select.jsx", + "src/components/ui/separator.jsx", + "src/components/ui/sheet.jsx", + "src/components/ui/sidebar.jsx", + "src/components/ui/skeleton.jsx", + "src/components/ui/slider.jsx", + "src/components/ui/sonner.jsx", + "src/components/ui/spinner.jsx", + "src/components/ui/switch.jsx", + "src/components/ui/table.jsx", + "src/components/ui/tabs.jsx", + "src/components/ui/textarea.jsx", + "src/components/ui/toast.jsx", + "src/components/ui/toaster.jsx", + "src/components/ui/toggle-group.jsx", + "src/components/ui/toggle.jsx", + "src/components/ui/tooltip.jsx", + "src/config/subscriptionRoutes.js", + "src/contexts/AuthContext.jsx", + "src/contexts/SubscriptionAuthContext.jsx", + "src/contexts/SupabaseAuthContext.jsx", + "src/hooks/use-animated-text.jsx", + "src/hooks/use-integrated-ai.jsx", + "src/hooks/use-mobile.jsx", + "src/hooks/use-toast.js", + "src/hooks/useBookingAvailability.js", + "src/hooks/useCart.jsx", + "src/hooks/useEcommerceSubscriptionsPlans.js", + "src/hooks/useSubscriptionPolling.js", + "src/hooks/useUserTier.js", + "src/index.css", + "src/lib/apiServerClient.js", + "src/lib/customSupabaseClient.js", + "src/lib/customSupabaseClient.ts", + "src/lib/ecommerceSubscriptionsUtils.js", + "src/lib/format.js", + "src/lib/integratedAiClient.js", + "src/lib/pocketbaseClient.js", + "src/lib/utils.js", + "src/main.jsx", + "src/pages/HomePage.jsx", + "src/pages/PlansPage.jsx", + "src/pages/ProductDetailPage.jsx", + "src/pages/SubscriptionsPage.jsx", + "tailwind.config.js", + "tools/generate-llms.js", + "vite.config.js" + ], + "project": [ + "src/**/*.{js,jsx,css}" + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "apps/api": { + "entry": [ + ".env", + "eslint.config.mjs", + "package.json", + "src/api/ecommerce-subscriptions.js", + "src/api/integrated-ai.js", + "src/constants/common.js", + "src/constants/prompts.js", + "src/main.js", + "src/middleware/error.js", + "src/middleware/file-upload.js", + "src/middleware/global-rate-limit.js", + "src/middleware/integrated-ai-rate-limit.js", + "src/middleware/pocketbase-auth.js", + "src/routes/ecommerce/subscriptions.js", + "src/routes/health-check.js", + "src/routes/index.js", + "src/routes/integrated-ai.js", + "src/utils/ecommerce-subscriptions.js", + "src/utils/integrationConfig.js", + "src/utils/logger.js", + "src/utils/pocketbaseClient.js" + ], + "project": [ + "src/**/*.js" + ] + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..461c338 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10413 @@ +{ + "name": "monorepo", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "monorepo", + "version": "0.0.0", + "workspaces": [ + "apps/*" + ], + "devDependencies": { + "concurrently": "^10.0.4", + "knip": "^6.32.1" + } + }, + "apps/api": { + "version": "0.0.0", + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^17.4.2", + "express": "^5.0.1", + "express-rate-limit": "^8.3.1", + "helmet": "^8.0.0", + "morgan": "^1.10.0", + "nodemailer": "^6.9.0", + "pocketbase": "^0.27.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "eslint": "^9.39.4", + "eslint-plugin-import": "^2.32.0", + "globals": "^15.14.0" + } + }, + "apps/pocketbase": { + "name": "pocketbase-app", + "version": "0.0.0" + }, + "apps/web": { + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-aspect-ratio": "^1.1.8", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-hover-card": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.4", + "@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-tooltip": "^1.2.8", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "^11.15.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.469.0", + "next-themes": "^0.4.6", + "pocketbase": "^0.27.0", + "react": "^18.3.1", + "react-day-picker": "^9.14.0", + "react-dom": "^18.3.1", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^4.7.2", + "react-router-dom": "^7.17.0", + "recharts": "^2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@babel/generator": "^7.26.3", + "@babel/parser": "^7.26.3", + "@babel/traverse": "^7.26.4", + "@babel/types": "^7.26.3", + "@types/node": "^20.17.10", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.2.0", + "autoprefixer": "^10.4.20", + "eslint": "^9.39.4", + "eslint-import-resolver-alias": "^1.1.2", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.1.0", + "globals": "^15.14.0", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "terser": "^5.37.0", + "vite": "^7.3.1" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.148.0.tgz", + "integrity": "sha512-pHASv9g5pASxb7akHERZNSkrEqPhFaUix98o7d9hbTpolnnFWl7UiRrcMhCsV1+iVO4/cJwKsbKRJTFNs2tdBQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.148.0.tgz", + "integrity": "sha512-sg/6Ez0KdAygsu0POELux9wN1Po2CP93WY8eNl4DBKIGprsd4QSHBXOb471Pu9i2OCD5sLkISSb2agZEhVn2Zw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.148.0.tgz", + "integrity": "sha512-yiSJmzGUvCUaJT8X3j40gVcX+ckuHQMuiOtF8DvzTs5+JtB/7XuHFPp4M+vv5u+HlBtDUd4Ks5pyHpWz8mfnkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.148.0.tgz", + "integrity": "sha512-6ZeklaamrMy4H2JmhvcJg6iip59tYILtuLaILxyAHT3l5FDxnI5ihVievAft5ZmAbqtlWHErOi1OpJK8gy1wcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.148.0.tgz", + "integrity": "sha512-vFsPx+a/qFECPnz/H8nC6x6MDvnWscLTCo/5muojEF54ERUq1kdgbvnWo95YnkhjF9sTIcG/uDxQBh1gffaufQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.148.0.tgz", + "integrity": "sha512-eOr3M+6iGbbxNL4PSS0VtsyQ2eOUxSBh00BqO22SbolDimPSYsBuLr/LCrZBkiqW2BoabhR6V4R8jrRAay7hjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.148.0.tgz", + "integrity": "sha512-58ZKDw0mQRbCNfrd2IDyV4o8T7enzGERJn41BH2tjrZVGyiKiFzcfDicuB7Zcpb/1xIOrObovr8Dja6lZi8dLw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.148.0.tgz", + "integrity": "sha512-Fnu95O4eZ5i++GPvIzBEZ8y4ddTLR+D9paYa8JRaRk6ZK7nHQiWP5xtrhcPQsXqgat1d7sU/d5rbbI0p1FTHSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.148.0.tgz", + "integrity": "sha512-3CQy/BMdx7N7H3qrcPxUL+a2CwUZodUcf6oq8iJuNZ9C6Ol1aq3mcWzsgySJ7CHFLvpX21ZDPp1r1X0QLbu/AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.148.0.tgz", + "integrity": "sha512-9LkaYvfiF8hMOw900csAvkf1oxE8XlmMeGowu5BcastSSwV8mKvKRMNU7HsV+ycyj1dQD8pX5qgOw8ja6SJacg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.148.0.tgz", + "integrity": "sha512-2GBiM9h26dR4WJfhoMvnFMnFLf7m/kYs4UMqjvrOfQG4BV1nuTJDH22Zc2MQr3INZF7nSKYQ6xlhD3hQ7A6gug==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.148.0.tgz", + "integrity": "sha512-uPqZexvKJmEgq4mAu36qe2xTfXZE7oyik1R7KtZ5tl8qKlq1U1fIqTFRUEBZqRGvforoTrGIpatRzcoPKO66RA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.148.0.tgz", + "integrity": "sha512-9oUHvnTbp7ZraFsTC8PN6XhdhPSSxZumYvixWl7Smi353gEULvK6yV0sXNVrdFMHQeaDKFCi8TgDhNK7/A+Y+Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.148.0.tgz", + "integrity": "sha512-2qhDSJwKzbSZzF7lDqqk8sr/yXsmwr3PeUa4/nazIF+zFAYz1gVPEfC34GQtGxzJUUmklaYAL63368LEfrMeyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.148.0.tgz", + "integrity": "sha512-qQoPDZUFV0bh9xA09XydmkjMBpgc1ukJuhMvzQ9QeVmFaHTS9W5TE5CoLmSl3QQyUP9OuHO3x/WPZTIIZPWR3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.148.0.tgz", + "integrity": "sha512-1UGbaQWEXUCLqAmaR5kwRDjx/R4S5LQKZkM9CHmaHkuKhriOF32aRLfS0jCRNE2yGQJLMEA1z9UucbBVqjXnDw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.148.0.tgz", + "integrity": "sha512-pWKdzRDNG2+NK4h/V6U/CYERcfYD6u28h5IB/VJVsrZaD3muvE58tUj22lieL5vLZ+XFi1GPv9YXckZbJZ9BLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.148.0.tgz", + "integrity": "sha512-i3p4x+mvwtjcE1J5HM6V7ggsbXiznExN/4MkNyOy3dfXrVV3bnkSfmZxvo6/84qCVX4ShkpNE1SKt9biIF31GQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.148.0.tgz", + "integrity": "sha512-Ye6vB7VQulghWYkYkECOBYFRVEizz4XyRTUAv+t8BuyurhKU7uD0P9eowL+mKG5Mf8MSYx+DI3Cm8SKZvYG7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "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", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "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-aspect-ratio": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.8.tgz", + "integrity": "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "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-aspect-ratio/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "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.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.11.tgz", + "integrity": "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4", + "@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-avatar/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "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-avatar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "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", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "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-presence": "1.1.5", + "@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" + }, + "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-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@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-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-collection/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-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "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-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "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-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", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "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-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "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-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-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "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-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "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-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "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-menu": "2.1.16", + "@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-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "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-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "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-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", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "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-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "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-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "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-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-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "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-menu/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-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-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "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-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-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "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-popover/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-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@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-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "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-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@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-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@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-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@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-primitive/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-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "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-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "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-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "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", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "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-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-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@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-use-callback-ref": "1.1.1", + "@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-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", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "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-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "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-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", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "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-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", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "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-id": "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" + }, + "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-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-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "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-id": "1.1.1", + "@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-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@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-tooltip/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-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "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-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "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-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", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "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-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", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "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-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "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/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/api": { + "resolved": "apps/api", + "link": true + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-alias": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-alias/-/eslint-import-resolver-alias-1.1.2.tgz", + "integrity": "sha512-WdviM1Eu834zsfjHtcGHtGfcu+F30Od3V7I9Fi57uhBEwPkjDcii7/yW8jAT+gOhn4P/vOxxNAXbFAKsrrc15w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + }, + "peerDependencies": { + "eslint-plugin-import": ">=1.4.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/formatly": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.7.0.tgz", + "integrity": "sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0", + "package-manager-detector": "^1.8.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/knip": { + "version": "6.35.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.35.1.tgz", + "integrity": "sha512-22wnEnv4do2fvoeJsxpFCG/MBxReNxoCVBccaCl7suUJsG+F0UvxI9ycxZ9YgtLHSSjrZl5tOas0JZ/R1vJ93g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "fdir": "^6.5.0", + "formatly": "^0.7.0", + "get-tsconfig": "4.14.3", + "jiti": "^2.7.0", + "oxc-parser": "^0.148.0", + "oxc-resolver": "11.24.2", + "picomatch": "^4.0.7", + "smol-toml": "^1.8.0", + "strip-json-comments": "5.0.3", + "tinyglobby": "^0.2.17", + "unbash": "^4.0.11", + "yaml": "^2.9.0", + "zod": "^4.4.3" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/knip/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/knip/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/knip/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/morgan": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.12.0.tgz", + "integrity": "sha512-OHpTRQwn2ezasILW8iKe+Yww1XsfWsZIpUOLF7RDb2g5GwO3trPaRwi7+8BDiJ7HFx2Kg2mfUdCBcVhwYlOz2g==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.4.1", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/next-themes": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz", + "integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/oxc-parser": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.148.0.tgz", + "integrity": "sha512-syxUKHeUll89RIABQADcI7sikYrwyssvA6gj4phSSIPezKVM8yMaLAiLLSc7fmzVvrwybfFGFbW5zme9sX87rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.148.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.148.0", + "@oxc-parser/binding-android-arm64": "0.148.0", + "@oxc-parser/binding-darwin-arm64": "0.148.0", + "@oxc-parser/binding-darwin-x64": "0.148.0", + "@oxc-parser/binding-freebsd-x64": "0.148.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.148.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.148.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.148.0", + "@oxc-parser/binding-linux-arm64-musl": "0.148.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.148.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.148.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.148.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.148.0", + "@oxc-parser/binding-linux-x64-gnu": "0.148.0", + "@oxc-parser/binding-linux-x64-musl": "0.148.0", + "@oxc-parser/binding-openharmony-arm64": "0.148.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.148.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.148.0", + "@oxc-parser/binding-win32-x64-msvc": "0.148.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pocketbase": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.27.3.tgz", + "integrity": "sha512-n2rtQrg3kTF44066B+2eVvQdiYRGZ+RI7isIZqmAptfElLCSDIZrEKLhSrIQA2k5pvQM29Gx5JqGv5n+gtm6dg==", + "license": "MIT" + }, + "node_modules/pocketbase-app": { + "resolved": "apps/pocketbase", + "link": true + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", + "date-fns": "^4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-helmet": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz", + "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.1", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.1.1", + "react-side-effect": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.3.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.72.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", + "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.8.0.tgz", + "integrity": "sha512-2uEABkewb3ky/ZgIlAUxWa1W/LjsK494fdV1QsXxst7CDRHCzo7h22tWWu3NNaBjmiuriOCt3CvhipnaYcpoIw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-router": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "license": "MIT", + "dependencies": { + "react-router": "7.17.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-side-effect": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz", + "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.3.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbash": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.11.tgz", + "integrity": "sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5874754 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "monorepo", + "version": "0.0.0", + "private": true, + "scripts": { + "setup": "npm install", + "dev": "concurrently --kill-others --raw \"npm run dev --prefix apps/web\" \"npm run dev --prefix apps/api\" \"npm run dev --prefix apps/pocketbase\"", + "build": "concurrently --raw \"npm run build --prefix apps/web\"", + "start": "concurrently --kill-others --raw \"npm run start --prefix apps/api\" \"npm run start --prefix apps/pocketbase\"", + "lint": "concurrently --raw \"npm run lint --prefix apps/web\" \"npm run lint --prefix apps/api\"" + }, + "devDependencies": { + "concurrently": "^10.0.4", + "knip": "^6.32.1" + }, + "workspaces": [ + "apps/*" + ] +} \ No newline at end of file