From 950ebc3dadeeb81793038f8548abad19a66659ec Mon Sep 17 00:00:00 2001 From: Aslam Date: Mon, 7 Oct 2024 16:44:17 +0700 Subject: [PATCH] Move to TanStack Start from Next.js (#184) --- web/.env.example | 20 - web/.eslintrc.cjs | 14 + web/.eslintrc.json | 3 - web/.gitignore | 52 +- web/.npmrc | 2 - web/.prettierignore | 4 + web/app.config.ts | 12 + web/app/(auth)/layout.tsx | 7 - .../(auth)/sign-in/[[...sign-in]]/page.tsx | 9 - .../(auth)/sign-up/[[...sign-up]]/page.tsx | 9 - web/app/(pages)/(topics)/[name]/page.tsx | 5 - .../(pages)/community/[topicName]/page.tsx | 5 - web/app/(pages)/edit-profile/page.tsx | 5 - web/app/(pages)/journal/page.tsx | 15 - web/app/(pages)/layout.tsx | 30 - web/app/(pages)/links/page.tsx | 5 - web/app/(pages)/onboarding/page.tsx | 5 - web/app/(pages)/pages/[id]/page.tsx | 5 - web/app/(pages)/pages/page.tsx | 5 - .../(pages)/profile/_components/wrapper.tsx | 165 - web/app/(pages)/profile/page.tsx | 5 - web/app/(pages)/search/page.tsx | 5 - web/app/(pages)/settings/page.tsx | 5 - web/app/(pages)/tasks/page.tsx | 15 - web/app/(pages)/tasks/today/page.tsx | 15 - web/app/(pages)/tasks/upcoming/page.tsx | 15 - web/app/(pages)/tauri/page.tsx | 5 - web/app/(pages)/topics/page.tsx | 5 - web/app/(public)/layout.tsx | 7 - web/app/actions.ts | 227 +- web/app/api/metadata/route.test.ts | 107 - web/app/api/metadata/route.ts | 72 - web/app/api/search-stream/route.ts | 43 - web/app/client.tsx | 8 + web/app/command-palette.css | 127 - web/app/components/DefaultCatchBoundary.tsx | 53 + web/app/components/GlobalKeyboardHandler.tsx | 141 + web/app/components/NotFound.tsx | 25 + web/app/components/Onboarding.tsx | 107 + .../command-palette/command-data.ts | 142 + .../command-palette/command-group.tsx | 73 + .../command-palette/command-palette.tsx | 214 ++ web/app/components/command-palette/utils.ts | 74 + web/app/components/custom/ai-search.tsx | 91 + web/app/components/custom/column.tsx | 44 + web/app/components/custom/content-header.tsx | 58 + web/app/components/custom/date-picker.tsx | 57 + web/app/components/custom/la-icon.tsx | 30 + .../custom/learning-state-selector.tsx | 137 + web/app/components/custom/spinner.tsx | 32 + .../components/custom/textarea-autosize.tsx | 25 + web/app/components/custom/topic-selector.tsx | 208 ++ web/app/components/icons/discord-icon.tsx | 29 + web/app/components/icons/logo-icon.tsx | 64 + web/app/components/shortcut/shortcut.tsx | 190 + .../components/sidebar/partials/feedback.tsx | 158 + .../sidebar/partials/journal-section.tsx | 120 + .../sidebar/partials/link-section.tsx | 115 + .../sidebar/partials/page-section.tsx | 284 ++ .../sidebar/partials/profile-section.tsx | 186 + .../sidebar/partials/task-section.tsx | 110 + .../sidebar/partials/topic-section.tsx | 142 + web/app/components/sidebar/sidebar.tsx | 211 ++ web/app/components/ui/alert-dialog.tsx | 141 + web/{ => app}/components/ui/avatar.tsx | 6 +- web/app/components/ui/badge.tsx | 36 + web/app/components/ui/button.tsx | 57 + web/app/components/ui/calendar.tsx | 70 + web/app/components/ui/checkbox.tsx | 28 + web/app/components/ui/command.tsx | 153 + web/app/components/ui/dialog.tsx | 121 + web/app/components/ui/dropdown-menu.tsx | 198 ++ web/app/components/ui/form.tsx | 176 + web/app/components/ui/input.tsx | 25 + web/app/components/ui/label.tsx | 24 + web/app/components/ui/popover.tsx | 31 + web/app/components/ui/scroll-area.tsx | 46 + web/app/components/ui/select.tsx | 162 + web/app/components/ui/separator.tsx | 29 + web/app/components/ui/sheet.tsx | 138 + web/{ => app}/components/ui/skeleton.tsx | 0 web/{ => app}/components/ui/switch.tsx | 6 +- web/app/components/ui/textarea.tsx | 24 + web/{ => app}/components/ui/toggle-group.tsx | 4 +- web/app/components/ui/toggle.tsx | 43 + web/app/components/ui/tooltip.tsx | 28 + web/app/custom.css | 11 - web/app/data/graph.json | 3129 +++++++++++++++++ web/app/favicon.ico | Bin 25931 -> 0 bytes web/app/fonts.ts | 7 - web/app/global-error.tsx | 23 - web/app/globals.css | 85 - web/app/hooks/actions/use-link-actions.ts | 35 + web/app/hooks/actions/use-page-actions.ts | 50 + web/app/hooks/actions/use-task-actions.ts | 61 + web/app/hooks/use-active-item-scroll.ts | 36 + web/app/hooks/use-awaitable-navigate.ts | 29 + web/app/hooks/use-command-actions.ts | 53 + web/app/hooks/use-event-listener.ts | 37 + web/app/hooks/use-is-mounted.ts | 19 + web/app/hooks/use-key-down.ts | 77 + web/app/hooks/use-keyboard-manager.ts | 62 + web/app/hooks/use-media.ts | 23 + web/app/hooks/use-on-click-outside.ts | 28 + .../minimal-tiptap => app}/hooks/use-theme.ts | 8 +- web/app/hooks/use-touch-sensor.ts | 27 + web/app/layout.tsx | 58 - web/app/lib/constants.ts | 45 + web/app/lib/providers/clerk-provider.tsx | 23 + web/app/lib/providers/jazz-provider.tsx | 71 + web/app/lib/schema/index.ts | 74 + web/{ => app}/lib/schema/journal.ts | 10 +- .../lib/schema/master/force-graph.ts | 8 +- .../lib/schema/master/public-group.ts | 6 +- web/{ => app}/lib/schema/master/topic.ts | 23 +- web/app/lib/schema/personal-link.ts | 24 + web/{ => app}/lib/schema/personal-page.ts | 16 +- web/app/lib/schema/task.ts | 12 + web/app/lib/utils/env.ts | 33 + web/app/lib/utils/force-graph/canvas.ts | 54 + web/app/lib/utils/force-graph/index.ts | 2 + web/app/lib/utils/force-graph/schedule.ts | 151 + web/app/lib/utils/index.ts | 81 + web/app/lib/utils/keyboard.ts | 67 + web/app/lib/utils/schema.ts | 43 + web/app/lib/utils/seo.ts | 33 + web/app/lib/utils/slug.ts | 22 + web/app/lib/utils/url.ts | 25 + web/app/page.tsx | 5 - web/app/routeTree.gen.ts | 641 ++++ web/app/router.tsx | 29 + web/app/routes/__root.tsx | 117 + web/app/routes/_layout.tsx | 17 + .../routes/_layout/(auth)/_auth.sign-in.$.tsx | 21 + .../routes/_layout/(auth)/_auth.sign-up.$.tsx | 14 + web/app/routes/_layout/(auth)/_auth.tsx | 9 + .../(landing)/-components/autocomplete.tsx | 169 + .../-components/force-graph-client.tsx | 381 ++ web/app/routes/_layout/(landing)/index.tsx | 60 + web/app/routes/_layout/_pages.tsx | 57 + web/app/routes/_layout/_pages/(topic)/$.tsx | 107 + .../routes/_layout/_pages/(topic)/-header.tsx | 139 + .../routes/_layout/_pages/(topic)/-item.tsx | 251 ++ .../routes/_layout/_pages/(topic)/-list.tsx | 107 + web/app/routes/_layout/_pages/_protected.tsx | 15 + .../_protected/community/$topicName/-list.tsx | 72 + .../community/$topicName/-thread.tsx | 189 + .../community/$topicName/-toggle.tsx | 56 + .../_protected/community/$topicName/index.tsx | 90 + .../_pages/_protected/journals/index.tsx | 139 + .../_pages/_protected/links/-bottom-bar.tsx | 219 ++ .../_protected/links/-description-input.tsx | 38 + .../_pages/_protected/links/-header.tsx | 159 + .../_layout/_pages/_protected/links/-item.tsx | 220 ++ .../_pages/_protected/links/-link-form.tsx | 338 ++ .../_layout/_pages/_protected/links/-list.tsx | 327 ++ .../_pages/_protected/links/-manage.tsx | 37 + .../_protected/links/-notes-section.tsx | 47 + .../_pages/_protected/links/-schema.ts | 15 + .../_pages/_protected/links/-title-input.tsx | 41 + .../_pages/_protected/links/-url-badge.tsx | 38 + .../_pages/_protected/links/-url-input.tsx | 87 + .../_layout/_pages/_protected/links/index.tsx | 35 + .../_pages/_protected/onboarding/index.tsx | 171 + .../_protected/pages/$pageId/-header.tsx | 54 + .../_pages/_protected/pages/$pageId/index.tsx | 301 ++ .../_pages/_protected/pages/-header.tsx | 65 + .../_layout/_pages/_protected/pages/-item.tsx | 77 + .../_layout/_pages/_protected/pages/-list.tsx | 125 + .../_layout/_pages/_protected/pages/index.tsx | 16 + .../_pages/_protected/profile/index.tsx | 192 + .../_pages/_protected/search/index.tsx | 234 ++ .../_pages/_protected/settings/index.tsx | 132 + .../_layout/_pages/_protected/tasks/-form.tsx | 155 + .../_layout/_pages/_protected/tasks/-item.tsx | 92 + .../_layout/_pages/_protected/tasks/-list.tsx | 31 + .../_layout/_pages/_protected/tasks/index.tsx | 98 + .../_pages/_protected/topics/-header.tsx | 32 + .../_pages/_protected/topics/-item.tsx | 213 ++ .../_pages/_protected/topics/-list.tsx | 156 + .../_pages/_protected/topics/index.tsx | 16 + web/app/ssr.tsx | 17 + web/app/store/any-store.ts | 3 + .../store/keyboard-manager.ts} | 0 web/{ => app}/store/link.ts | 0 web/{ => app}/store/sidebar.ts | 4 +- web/app/styles/app.css | 91 + web/app/styles/command-palette.css | 129 + web/app/styles/custom.css | 11 + web/components.json | 17 - .../custom/GuideCommunityToggle.tsx | 50 - web/components/custom/QuestionList.tsx | 65 - web/components/custom/QuestionThread.tsx | 167 - web/components/custom/ai-search.tsx | 88 - .../custom/clerk/clerk-provider-client.tsx | 22 - .../custom/clerk/sign-in-client.tsx | 16 - .../custom/clerk/sign-up-client.tsx | 7 - .../custom/clerk/signed-in-client.tsx | 7 - web/components/custom/column.tsx | 42 - .../custom/command-palette/command-data.ts | 135 - .../custom/command-palette/command-items.tsx | 57 - .../command-palette/command-palette.tsx | 229 -- .../hooks/use-command-actions.ts | 53 - web/components/custom/content-header.tsx | 59 - web/components/custom/discordIcon.tsx | 23 - .../custom/global-keyboard-handler.tsx | 130 - web/components/custom/la-icon.tsx | 22 - .../custom/learn-anything-onboarding.tsx | 100 - .../custom/learning-state-selector.tsx | 104 - web/components/custom/logo.tsx | 57 - web/components/custom/page-loader.tsx | 14 - web/components/custom/shortcut/shortcut.tsx | 164 - .../custom/sidebar/partial/feedback.tsx | 137 - .../sidebar/partial/journal-section.tsx | 113 - .../custom/sidebar/partial/link-section.tsx | 128 - .../custom/sidebar/partial/page-section.tsx | 256 -- .../sidebar/partial/profile-section.tsx | 156 - .../custom/sidebar/partial/task-section.tsx | 107 - .../custom/sidebar/partial/topic-section.tsx | 136 - web/components/custom/sidebar/sidebar.tsx | 195 - web/components/custom/spinner.tsx | 17 - .../custom/text-blur-transition.tsx | 26 - web/components/custom/textarea-autosize.tsx | 23 - web/components/custom/topic-selector.tsx | 174 - .../components/bubble-menu/bubble-menu.tsx | 116 - .../la-editor/components/ui/icon.tsx | 22 - .../components/ui/popover-wrapper.tsx | 20 - .../la-editor/components/ui/shortcut.tsx | 45 - .../components/ui/toolbar-button.tsx | 47 - .../la-editor/extensions/code/code.ts | 15 - .../la-editor/extensions/heading/heading.ts | 29 - .../la-editor/extensions/link/link.ts | 89 - .../extensions/selection/selection.ts | 36 - .../extensions/slash-command/groups.ts | 122 - .../extensions/slash-command/menu-list.tsx | 159 - .../extensions/slash-command/slash-command.ts | 234 -- .../extensions/slash-command/types.ts | 26 - .../la-editor/extensions/starter-kit.ts | 153 - .../task-item/components/task-item-view.tsx | 50 - .../extensions/task-item/task-item.ts | 64 - .../extensions/task-list/task-list.ts | 12 - .../la-editor/hooks/use-text-menu-commands.ts | 30 - .../la-editor/hooks/use-text-menu-states.ts | 34 - web/components/la-editor/la-editor.tsx | 99 - .../lib/utils/isCustomNodeSelected.ts | 28 - .../la-editor/lib/utils/isTextSelected.ts | 25 - .../styles/partials/code-highlight.css | 86 - .../la-editor/styles/partials/code.css | 86 - .../la-editor/styles/partials/lists.css | 82 - .../la-editor/styles/partials/misc.css | 20 - .../la-editor/styles/partials/typography.css | 27 - .../la-editor/styles/partials/vars.css | 47 - .../bubble-menu/link-bubble-menu.tsx | 106 - .../components/image/image-edit-block.tsx | 102 - .../components/section/five.tsx | 84 - .../minimal-tiptap/components/section/one.tsx | 137 - .../minimal-tiptap/components/section/two.tsx | 100 - .../components/shortcut-key.tsx | 33 - .../components/toolbar-button.tsx | 38 - .../components/toolbar-section.tsx | 112 - .../extensions/code-block-lowlight/index.ts | 1 - .../minimal-tiptap/extensions/color/index.ts | 1 - .../extensions/horizontal-rule/index.ts | 1 - .../minimal-tiptap/extensions/image/image.ts | 9 - .../minimal-tiptap/extensions/image/index.ts | 1 - .../minimal-tiptap/extensions/index.ts | 8 - .../minimal-tiptap/extensions/link/index.ts | 1 - .../extensions/reset-marks-on-enter/index.ts | 1 - .../reset-marks-on-enter.ts | 25 - .../extensions/selection/index.ts | 1 - .../extensions/unset-all-marks/index.ts | 1 - .../unset-all-marks/unset-all-marks.ts | 9 - .../hooks/use-minimal-tiptap.ts | 107 - web/components/minimal-tiptap/index.ts | 1 - .../minimal-tiptap/minimal-tiptap.tsx | 95 - web/components/minimal-tiptap/utils.ts | 14 - web/components/routes/EditProfileRoute.tsx | 44 - web/components/routes/OnboardingRoute.tsx | 141 - web/components/routes/SettingsRoute.tsx | 119 - .../routes/community/CommunityTopicRoute.tsx | 74 - .../routes/journal/JournalRoute.tsx | 114 - web/components/routes/link/LinkRoute.tsx | 21 - web/components/routes/link/bottom-bar.tsx | 180 - web/components/routes/link/header.tsx | 147 - .../routes/link/hooks/use-link-actions.ts | 32 - web/components/routes/link/list.tsx | 280 -- web/components/routes/link/manage.tsx | 33 - .../link/partials/form/description-input.tsx | 33 - .../routes/link/partials/form/link-form.tsx | 288 -- .../link/partials/form/notes-section.tsx | 36 - .../routes/link/partials/form/schema.ts | 15 - .../routes/link/partials/form/title-input.tsx | 36 - .../routes/link/partials/form/url-badge.tsx | 35 - .../routes/link/partials/form/url-input.tsx | 69 - .../routes/link/partials/link-item.tsx | 180 - web/components/routes/page/PageRoute.tsx | 13 - .../routes/page/detail/PageDetailRoute.tsx | 259 -- web/components/routes/page/detail/header.tsx | 43 - web/components/routes/page/header.tsx | 58 - .../routes/page/hooks/use-column-styles.ts | 16 - .../routes/page/hooks/use-page-actions.ts | 45 - web/components/routes/page/list.tsx | 89 - .../routes/page/partials/page-item.tsx | 67 - web/components/routes/public/Autocomplete.tsx | 143 - .../routes/public/PublicHomeRoute.tsx | 55 - web/components/routes/public/anim.ts | 84 - .../routes/public/force-graph-client-lazy.tsx | 359 -- web/components/routes/public/graph-data.json | 1 - web/components/routes/search/header.tsx | 10 - web/components/routes/search/wrapper.tsx | 198 -- web/components/routes/task/TaskForm.tsx | 146 - web/components/routes/task/TaskItem.tsx | 79 - web/components/routes/task/TaskList.tsx | 24 - web/components/routes/task/TaskRoute.tsx | 65 - web/components/routes/task/TodayTaskRoute.tsx | 15 - .../routes/task/UpcomingTaskRoute.tsx | 15 - .../routes/task/new-task-actions.ts | 61 - web/components/routes/tauri/TauriRoute.tsx | 9 - web/components/routes/topics/TopicRoute.tsx | 13 - .../routes/topics/detail/TopicDetailRoute.tsx | 89 - .../routes/topics/detail/header.tsx | 116 - web/components/routes/topics/detail/list.tsx | 93 - .../topics/detail/partials/link-item.tsx | 203 -- web/components/routes/topics/header.tsx | 31 - .../routes/topics/hooks/use-column-styles.ts | 14 - web/components/routes/topics/list.tsx | 123 - .../routes/topics/partials/topic-item.tsx | 174 - web/components/ui/Keybind.tsx | 42 - web/components/ui/LearningTodoStatus.tsx | 70 - web/components/ui/alert-dialog.tsx | 106 - web/components/ui/badge.tsx | 28 - web/components/ui/breadcrumb.tsx | 90 - web/components/ui/button.tsx | 47 - web/components/ui/calendar.tsx | 62 - web/components/ui/checkbox.tsx | 28 - web/components/ui/command.tsx | 134 - web/components/ui/context-menu.tsx | 180 - web/components/ui/date-picker.tsx | 40 - web/components/ui/dialog.tsx | 98 - web/components/ui/drawer.tsx | 118 - web/components/ui/dropdown-menu.tsx | 181 - web/components/ui/form.tsx | 138 - web/components/ui/input.tsx | 22 - web/components/ui/label.tsx | 19 - web/components/ui/popover.tsx | 33 - web/components/ui/scroll-area.tsx | 40 - web/components/ui/select.tsx | 144 - web/components/ui/separator.tsx | 22 - web/components/ui/sheet.tsx | 109 - web/components/ui/sonner.tsx | 40 - web/components/ui/textarea.tsx | 21 - web/components/ui/toggle.tsx | 39 - web/components/ui/tooltip.tsx | 30 - web/hooks/use-active-item-scroll.ts | 31 - web/hooks/use-event-listener.ts | 33 - web/hooks/use-is-mounted.ts | 19 - web/hooks/use-key-down.ts | 79 - web/hooks/use-keyboard-manager.ts | 61 - web/hooks/use-media.ts | 23 - web/hooks/use-on-click-outside.ts | 28 - web/hooks/use-throttle.ts | 34 - web/hooks/use-touch-sensor.ts | 27 - web/instrumentation.ts | 9 - web/jest.config.ts | 24 - web/jest.setup.ts | 2 - web/lib/constants.ts | 19 - web/lib/providers/confirm-provider.tsx | 12 - web/lib/providers/deep-link-provider.tsx | 35 - web/lib/providers/jazz-provider.tsx | 57 - web/lib/providers/jotai-provider.tsx | 7 - web/lib/providers/theme-provider.tsx | 9 - web/lib/schema/global-link.old.ts | 34 - web/lib/schema/global-topic.old.ts | 21 - web/lib/schema/index.ts | 80 - web/lib/schema/personal-link.ts | 69 - web/lib/schema/tasks.ts | 12 - web/lib/types.ts | 44 - web/lib/utils/auth-procedure.ts | 13 - web/lib/utils/canvas.ts | 52 - web/lib/utils/htmlLikeElementUtil.test.tsx | 45 - web/lib/utils/htmlLikeElementUtil.ts | 21 - web/lib/utils/index.ts | 60 - web/lib/utils/keyboard.ts | 59 - web/lib/utils/schedule.ts | 148 - web/lib/utils/schema.test.ts | 39 - web/lib/utils/schema.ts | 42 - web/lib/utils/slug.test.ts | 29 - web/lib/utils/slug.ts | 14 - web/lib/utils/urls.ts | 21 - web/lib/utils/window-size.ts | 28 - web/middleware.ts | 35 - web/next.config.mjs | 83 - web/package.json | 157 +- web/postcss.config.cjs | 7 + web/postcss.config.mjs | 8 - web/public/android-chrome-192x192.png | Bin 0 -> 29964 bytes web/public/android-chrome-512x512.png | Bin 0 -> 109271 bytes web/public/apple-touch-icon.png | Bin 0 -> 27246 bytes web/public/favicon-16x16.png | Bin 0 -> 832 bytes web/public/favicon-32x32.png | Bin 0 -> 2115 bytes web/public/favicon.ico | Bin 0 -> 15406 bytes web/public/favicon.png | Bin 0 -> 1507 bytes web/public/site.webmanifest | 19 + web/readme.md | 125 +- web/sentry.client.config.ts | 26 - web/sentry.server.config.ts | 15 - web/shared/actions.ts | 46 + .../hooks/use-throttle.ts | 8 +- .../components/bubble-menu/bubble-menu.tsx | 115 + .../la-editor/components/bubble-menu/index.ts | 0 web/shared/la-editor/components/ui/icon.tsx | 30 + .../la-editor/components/ui/keybind.tsx | 52 + .../components/ui/popover-wrapper.tsx | 24 + .../la-editor/components/ui/shortcut.tsx | 55 + .../components/ui/toolbar-button.tsx | 57 + .../extensions/blockquote/blockquote.ts | 12 +- .../la-editor/extensions/blockquote/index.ts | 0 .../extensions/bullet-list/bullet-list.ts | 16 +- .../la-editor/extensions/bullet-list/index.ts | 0 .../code-block-lowlight.ts | 10 +- .../extensions/code-block-lowlight/index.ts | 0 web/shared/la-editor/extensions/code/code.ts | 15 + .../la-editor/extensions/code/index.ts | 0 .../extensions/dropcursor/dropcursor.ts | 14 +- .../la-editor/extensions/dropcursor/index.ts | 0 .../la-editor/extensions/heading/heading.ts | 33 + .../la-editor/extensions/heading/index.ts | 0 .../horizontal-rule/horizontal-rule.ts | 10 +- .../extensions/horizontal-rule/index.ts | 0 .../la-editor/extensions/index.ts | 44 +- .../la-editor/extensions/link/index.ts | 0 web/shared/la-editor/extensions/link/link.ts | 97 + .../extensions/ordered-list/index.ts | 0 .../extensions/ordered-list/ordered-list.ts | 16 +- .../la-editor/extensions/paragraph/index.ts | 0 .../extensions/paragraph/paragraph.ts | 16 +- .../la-editor/extensions/selection/index.ts | 0 .../extensions/selection/selection.ts | 22 +- .../extensions/slash-command/groups.ts | 122 + .../extensions/slash-command/index.ts | 0 .../extensions/slash-command/menu-list.tsx | 172 + .../extensions/slash-command/slash-command.ts | 262 ++ .../extensions/slash-command/types.ts | 25 + .../la-editor/extensions/starter-kit.ts | 153 + .../task-item/components/task-item-view.tsx | 68 + .../la-editor/extensions/task-item/index.ts | 0 .../extensions/task-item/task-item.ts | 64 + .../la-editor/extensions/task-list/index.ts | 0 .../extensions/task-list/task-list.ts | 12 + .../la-editor/hooks/use-text-menu-commands.ts | 48 + .../la-editor/hooks/use-text-menu-states.ts | 34 + web/{components => shared}/la-editor/index.ts | 0 web/shared/la-editor/la-editor.tsx | 107 + .../la-editor/lib/utils/index.ts | 8 +- .../lib/utils/isCustomNodeSelected.ts | 37 + .../la-editor/lib/utils/isTextSelected.ts | 26 + .../la-editor/styles/index.css | 0 .../styles/partials/code-highlight.css | 86 + web/shared/la-editor/styles/partials/code.css | 86 + .../la-editor/styles/partials/lists.css | 90 + web/shared/la-editor/styles/partials/misc.css | 22 + .../styles/partials/prosemirror-base.css | 36 +- .../la-editor/styles/partials/typography.css | 27 + web/shared/la-editor/styles/partials/vars.css | 47 + web/{components => shared}/la-editor/types.ts | 20 +- .../bubble-menu/image-bubble-menu.tsx | 14 +- .../bubble-menu/link-bubble-menu.tsx | 113 + .../components/image/image-edit-block.tsx | 125 + .../components/image/image-edit-dialog.tsx | 26 +- .../components/image/image-popover-block.tsx | 10 +- .../components/link/link-edit-block.tsx | 45 +- .../components/link/link-edit-popover.tsx | 42 +- .../components/link/link-popover-block.tsx | 47 +- .../components/section/five.tsx | 93 + .../components/section/four.tsx | 58 +- .../minimal-tiptap/components/section/one.tsx | 151 + .../components/section/three.tsx | 122 +- .../minimal-tiptap/components/section/two.tsx | 115 + .../components/shortcut-key.tsx | 43 + .../components/toolbar-button.tsx | 56 + .../components/toolbar-section.tsx | 120 + .../code-block-lowlight.ts | 20 +- .../extensions/code-block-lowlight/index.ts | 1 + .../minimal-tiptap/extensions/color/color.ts | 14 +- .../minimal-tiptap/extensions/color/index.ts | 1 + .../horizontal-rule/horizontal-rule.ts | 16 +- .../extensions/horizontal-rule/index.ts | 1 + .../image/components/image-view-block.tsx | 26 +- .../minimal-tiptap/extensions/image/image.ts | 9 + .../minimal-tiptap/extensions/image/index.ts | 1 + web/shared/minimal-tiptap/extensions/index.ts | 8 + .../minimal-tiptap/extensions/link/index.ts | 1 + .../minimal-tiptap/extensions/link/link.ts | 36 +- .../extensions/reset-marks-on-enter/index.ts | 1 + .../reset-marks-on-enter.ts | 25 + .../extensions/selection/index.ts | 1 + .../extensions/selection/selection.ts | 36 + .../extensions/unset-all-marks/index.ts | 1 + .../unset-all-marks/unset-all-marks.ts | 9 + .../minimal-tiptap/hooks/use-image-load.ts | 2 +- .../hooks/use-minimal-tiptap.ts | 111 + web/shared/minimal-tiptap/hooks/use-theme.ts | 25 + .../minimal-tiptap/hooks/use-throttle.ts | 34 + web/shared/minimal-tiptap/index.ts | 1 + web/shared/minimal-tiptap/minimal-tiptap.tsx | 103 + .../minimal-tiptap/styles/index.css | 8 +- .../minimal-tiptap/styles/partials/code.css | 0 .../minimal-tiptap/styles/partials/lists.css | 29 +- .../styles/partials/placeholder.css | 0 .../styles/partials/typography.css | 0 .../minimal-tiptap/types.ts | 6 +- web/shared/minimal-tiptap/utils.ts | 92 + web/tailwind.config.ts | 182 +- web/tsconfig.json | 49 +- 514 files changed, 20021 insertions(+), 15508 deletions(-) delete mode 100644 web/.env.example create mode 100644 web/.eslintrc.cjs delete mode 100644 web/.eslintrc.json delete mode 100644 web/.npmrc create mode 100644 web/.prettierignore create mode 100644 web/app.config.ts delete mode 100644 web/app/(auth)/layout.tsx delete mode 100644 web/app/(auth)/sign-in/[[...sign-in]]/page.tsx delete mode 100644 web/app/(auth)/sign-up/[[...sign-up]]/page.tsx delete mode 100644 web/app/(pages)/(topics)/[name]/page.tsx delete mode 100644 web/app/(pages)/community/[topicName]/page.tsx delete mode 100644 web/app/(pages)/edit-profile/page.tsx delete mode 100644 web/app/(pages)/journal/page.tsx delete mode 100644 web/app/(pages)/layout.tsx delete mode 100644 web/app/(pages)/links/page.tsx delete mode 100644 web/app/(pages)/onboarding/page.tsx delete mode 100644 web/app/(pages)/pages/[id]/page.tsx delete mode 100644 web/app/(pages)/pages/page.tsx delete mode 100644 web/app/(pages)/profile/_components/wrapper.tsx delete mode 100644 web/app/(pages)/profile/page.tsx delete mode 100644 web/app/(pages)/search/page.tsx delete mode 100644 web/app/(pages)/settings/page.tsx delete mode 100644 web/app/(pages)/tasks/page.tsx delete mode 100644 web/app/(pages)/tasks/today/page.tsx delete mode 100644 web/app/(pages)/tasks/upcoming/page.tsx delete mode 100644 web/app/(pages)/tauri/page.tsx delete mode 100644 web/app/(pages)/topics/page.tsx delete mode 100644 web/app/(public)/layout.tsx delete mode 100644 web/app/api/metadata/route.test.ts delete mode 100644 web/app/api/metadata/route.ts delete mode 100644 web/app/api/search-stream/route.ts create mode 100644 web/app/client.tsx delete mode 100644 web/app/command-palette.css create mode 100644 web/app/components/DefaultCatchBoundary.tsx create mode 100644 web/app/components/GlobalKeyboardHandler.tsx create mode 100644 web/app/components/NotFound.tsx create mode 100644 web/app/components/Onboarding.tsx create mode 100644 web/app/components/command-palette/command-data.ts create mode 100644 web/app/components/command-palette/command-group.tsx create mode 100644 web/app/components/command-palette/command-palette.tsx create mode 100644 web/app/components/command-palette/utils.ts create mode 100644 web/app/components/custom/ai-search.tsx create mode 100644 web/app/components/custom/column.tsx create mode 100644 web/app/components/custom/content-header.tsx create mode 100644 web/app/components/custom/date-picker.tsx create mode 100644 web/app/components/custom/la-icon.tsx create mode 100644 web/app/components/custom/learning-state-selector.tsx create mode 100644 web/app/components/custom/spinner.tsx create mode 100644 web/app/components/custom/textarea-autosize.tsx create mode 100644 web/app/components/custom/topic-selector.tsx create mode 100644 web/app/components/icons/discord-icon.tsx create mode 100644 web/app/components/icons/logo-icon.tsx create mode 100644 web/app/components/shortcut/shortcut.tsx create mode 100644 web/app/components/sidebar/partials/feedback.tsx create mode 100644 web/app/components/sidebar/partials/journal-section.tsx create mode 100644 web/app/components/sidebar/partials/link-section.tsx create mode 100644 web/app/components/sidebar/partials/page-section.tsx create mode 100644 web/app/components/sidebar/partials/profile-section.tsx create mode 100644 web/app/components/sidebar/partials/task-section.tsx create mode 100644 web/app/components/sidebar/partials/topic-section.tsx create mode 100644 web/app/components/sidebar/sidebar.tsx create mode 100644 web/app/components/ui/alert-dialog.tsx rename web/{ => app}/components/ui/avatar.tsx (96%) create mode 100644 web/app/components/ui/badge.tsx create mode 100644 web/app/components/ui/button.tsx create mode 100644 web/app/components/ui/calendar.tsx create mode 100644 web/app/components/ui/checkbox.tsx create mode 100644 web/app/components/ui/command.tsx create mode 100644 web/app/components/ui/dialog.tsx create mode 100644 web/app/components/ui/dropdown-menu.tsx create mode 100644 web/app/components/ui/form.tsx create mode 100644 web/app/components/ui/input.tsx create mode 100644 web/app/components/ui/label.tsx create mode 100644 web/app/components/ui/popover.tsx create mode 100644 web/app/components/ui/scroll-area.tsx create mode 100644 web/app/components/ui/select.tsx create mode 100644 web/app/components/ui/separator.tsx create mode 100644 web/app/components/ui/sheet.tsx rename web/{ => app}/components/ui/skeleton.tsx (100%) rename web/{ => app}/components/ui/switch.tsx (92%) create mode 100644 web/app/components/ui/textarea.tsx rename web/{ => app}/components/ui/toggle-group.tsx (98%) create mode 100644 web/app/components/ui/toggle.tsx create mode 100644 web/app/components/ui/tooltip.tsx delete mode 100644 web/app/custom.css create mode 100644 web/app/data/graph.json delete mode 100644 web/app/favicon.ico delete mode 100644 web/app/fonts.ts delete mode 100644 web/app/global-error.tsx delete mode 100644 web/app/globals.css create mode 100644 web/app/hooks/actions/use-link-actions.ts create mode 100644 web/app/hooks/actions/use-page-actions.ts create mode 100644 web/app/hooks/actions/use-task-actions.ts create mode 100644 web/app/hooks/use-active-item-scroll.ts create mode 100644 web/app/hooks/use-awaitable-navigate.ts create mode 100644 web/app/hooks/use-command-actions.ts create mode 100644 web/app/hooks/use-event-listener.ts create mode 100644 web/app/hooks/use-is-mounted.ts create mode 100644 web/app/hooks/use-key-down.ts create mode 100644 web/app/hooks/use-keyboard-manager.ts create mode 100644 web/app/hooks/use-media.ts create mode 100644 web/app/hooks/use-on-click-outside.ts rename web/{components/minimal-tiptap => app}/hooks/use-theme.ts (61%) create mode 100644 web/app/hooks/use-touch-sensor.ts delete mode 100644 web/app/layout.tsx create mode 100644 web/app/lib/constants.ts create mode 100644 web/app/lib/providers/clerk-provider.tsx create mode 100644 web/app/lib/providers/jazz-provider.tsx create mode 100644 web/app/lib/schema/index.ts rename web/{ => app}/lib/schema/journal.ts (52%) rename web/{ => app}/lib/schema/master/force-graph.ts (70%) rename web/{ => app}/lib/schema/master/public-group.ts (67%) rename web/{ => app}/lib/schema/master/topic.ts (53%) create mode 100644 web/app/lib/schema/personal-link.ts rename web/{ => app}/lib/schema/personal-page.ts (56%) create mode 100644 web/app/lib/schema/task.ts create mode 100644 web/app/lib/utils/env.ts create mode 100644 web/app/lib/utils/force-graph/canvas.ts create mode 100644 web/app/lib/utils/force-graph/index.ts create mode 100644 web/app/lib/utils/force-graph/schedule.ts create mode 100644 web/app/lib/utils/index.ts create mode 100644 web/app/lib/utils/keyboard.ts create mode 100644 web/app/lib/utils/schema.ts create mode 100644 web/app/lib/utils/seo.ts create mode 100644 web/app/lib/utils/slug.ts create mode 100644 web/app/lib/utils/url.ts delete mode 100644 web/app/page.tsx create mode 100644 web/app/routeTree.gen.ts create mode 100644 web/app/router.tsx create mode 100644 web/app/routes/__root.tsx create mode 100644 web/app/routes/_layout.tsx create mode 100644 web/app/routes/_layout/(auth)/_auth.sign-in.$.tsx create mode 100644 web/app/routes/_layout/(auth)/_auth.sign-up.$.tsx create mode 100644 web/app/routes/_layout/(auth)/_auth.tsx create mode 100644 web/app/routes/_layout/(landing)/-components/autocomplete.tsx create mode 100644 web/app/routes/_layout/(landing)/-components/force-graph-client.tsx create mode 100644 web/app/routes/_layout/(landing)/index.tsx create mode 100644 web/app/routes/_layout/_pages.tsx create mode 100644 web/app/routes/_layout/_pages/(topic)/$.tsx create mode 100644 web/app/routes/_layout/_pages/(topic)/-header.tsx create mode 100644 web/app/routes/_layout/_pages/(topic)/-item.tsx create mode 100644 web/app/routes/_layout/_pages/(topic)/-list.tsx create mode 100644 web/app/routes/_layout/_pages/_protected.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/community/$topicName/-list.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/community/$topicName/-thread.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/community/$topicName/-toggle.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/community/$topicName/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/journals/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-bottom-bar.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-description-input.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-header.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-item.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-link-form.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-list.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-manage.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-notes-section.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-schema.ts create mode 100644 web/app/routes/_layout/_pages/_protected/links/-title-input.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-url-badge.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/-url-input.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/links/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/onboarding/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/pages/$pageId/-header.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/pages/-header.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/pages/-item.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/pages/-list.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/pages/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/profile/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/search/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/settings/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/tasks/-form.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/tasks/-item.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/tasks/-list.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/tasks/index.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/topics/-header.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/topics/-item.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/topics/-list.tsx create mode 100644 web/app/routes/_layout/_pages/_protected/topics/index.tsx create mode 100644 web/app/ssr.tsx create mode 100644 web/app/store/any-store.ts rename web/{store/keydown-manager.ts => app/store/keyboard-manager.ts} (100%) rename web/{ => app}/store/link.ts (100%) rename web/{ => app}/store/sidebar.ts (55%) create mode 100644 web/app/styles/app.css create mode 100644 web/app/styles/command-palette.css create mode 100644 web/app/styles/custom.css delete mode 100644 web/components.json delete mode 100644 web/components/custom/GuideCommunityToggle.tsx delete mode 100644 web/components/custom/QuestionList.tsx delete mode 100644 web/components/custom/QuestionThread.tsx delete mode 100644 web/components/custom/ai-search.tsx delete mode 100644 web/components/custom/clerk/clerk-provider-client.tsx delete mode 100644 web/components/custom/clerk/sign-in-client.tsx delete mode 100644 web/components/custom/clerk/sign-up-client.tsx delete mode 100644 web/components/custom/clerk/signed-in-client.tsx delete mode 100644 web/components/custom/column.tsx delete mode 100644 web/components/custom/command-palette/command-data.ts delete mode 100644 web/components/custom/command-palette/command-items.tsx delete mode 100644 web/components/custom/command-palette/command-palette.tsx delete mode 100644 web/components/custom/command-palette/hooks/use-command-actions.ts delete mode 100644 web/components/custom/content-header.tsx delete mode 100644 web/components/custom/discordIcon.tsx delete mode 100644 web/components/custom/global-keyboard-handler.tsx delete mode 100644 web/components/custom/la-icon.tsx delete mode 100644 web/components/custom/learn-anything-onboarding.tsx delete mode 100644 web/components/custom/learning-state-selector.tsx delete mode 100644 web/components/custom/logo.tsx delete mode 100644 web/components/custom/page-loader.tsx delete mode 100644 web/components/custom/shortcut/shortcut.tsx delete mode 100644 web/components/custom/sidebar/partial/feedback.tsx delete mode 100644 web/components/custom/sidebar/partial/journal-section.tsx delete mode 100644 web/components/custom/sidebar/partial/link-section.tsx delete mode 100644 web/components/custom/sidebar/partial/page-section.tsx delete mode 100644 web/components/custom/sidebar/partial/profile-section.tsx delete mode 100644 web/components/custom/sidebar/partial/task-section.tsx delete mode 100644 web/components/custom/sidebar/partial/topic-section.tsx delete mode 100644 web/components/custom/sidebar/sidebar.tsx delete mode 100644 web/components/custom/spinner.tsx delete mode 100644 web/components/custom/text-blur-transition.tsx delete mode 100644 web/components/custom/textarea-autosize.tsx delete mode 100644 web/components/custom/topic-selector.tsx delete mode 100644 web/components/la-editor/components/bubble-menu/bubble-menu.tsx delete mode 100644 web/components/la-editor/components/ui/icon.tsx delete mode 100644 web/components/la-editor/components/ui/popover-wrapper.tsx delete mode 100644 web/components/la-editor/components/ui/shortcut.tsx delete mode 100644 web/components/la-editor/components/ui/toolbar-button.tsx delete mode 100644 web/components/la-editor/extensions/code/code.ts delete mode 100644 web/components/la-editor/extensions/heading/heading.ts delete mode 100644 web/components/la-editor/extensions/link/link.ts delete mode 100644 web/components/la-editor/extensions/selection/selection.ts delete mode 100644 web/components/la-editor/extensions/slash-command/groups.ts delete mode 100644 web/components/la-editor/extensions/slash-command/menu-list.tsx delete mode 100644 web/components/la-editor/extensions/slash-command/slash-command.ts delete mode 100644 web/components/la-editor/extensions/slash-command/types.ts delete mode 100644 web/components/la-editor/extensions/starter-kit.ts delete mode 100644 web/components/la-editor/extensions/task-item/components/task-item-view.tsx delete mode 100644 web/components/la-editor/extensions/task-item/task-item.ts delete mode 100644 web/components/la-editor/extensions/task-list/task-list.ts delete mode 100644 web/components/la-editor/hooks/use-text-menu-commands.ts delete mode 100644 web/components/la-editor/hooks/use-text-menu-states.ts delete mode 100644 web/components/la-editor/la-editor.tsx delete mode 100644 web/components/la-editor/lib/utils/isCustomNodeSelected.ts delete mode 100644 web/components/la-editor/lib/utils/isTextSelected.ts delete mode 100644 web/components/la-editor/styles/partials/code-highlight.css delete mode 100644 web/components/la-editor/styles/partials/code.css delete mode 100644 web/components/la-editor/styles/partials/lists.css delete mode 100644 web/components/la-editor/styles/partials/misc.css delete mode 100644 web/components/la-editor/styles/partials/typography.css delete mode 100644 web/components/la-editor/styles/partials/vars.css delete mode 100644 web/components/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx delete mode 100644 web/components/minimal-tiptap/components/image/image-edit-block.tsx delete mode 100644 web/components/minimal-tiptap/components/section/five.tsx delete mode 100644 web/components/minimal-tiptap/components/section/one.tsx delete mode 100644 web/components/minimal-tiptap/components/section/two.tsx delete mode 100644 web/components/minimal-tiptap/components/shortcut-key.tsx delete mode 100644 web/components/minimal-tiptap/components/toolbar-button.tsx delete mode 100644 web/components/minimal-tiptap/components/toolbar-section.tsx delete mode 100644 web/components/minimal-tiptap/extensions/code-block-lowlight/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/color/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/horizontal-rule/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/image/image.ts delete mode 100644 web/components/minimal-tiptap/extensions/image/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/link/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/reset-marks-on-enter/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts delete mode 100644 web/components/minimal-tiptap/extensions/selection/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/unset-all-marks/index.ts delete mode 100644 web/components/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts delete mode 100644 web/components/minimal-tiptap/hooks/use-minimal-tiptap.ts delete mode 100644 web/components/minimal-tiptap/index.ts delete mode 100644 web/components/minimal-tiptap/minimal-tiptap.tsx delete mode 100644 web/components/minimal-tiptap/utils.ts delete mode 100644 web/components/routes/EditProfileRoute.tsx delete mode 100644 web/components/routes/OnboardingRoute.tsx delete mode 100644 web/components/routes/SettingsRoute.tsx delete mode 100644 web/components/routes/community/CommunityTopicRoute.tsx delete mode 100644 web/components/routes/journal/JournalRoute.tsx delete mode 100644 web/components/routes/link/LinkRoute.tsx delete mode 100644 web/components/routes/link/bottom-bar.tsx delete mode 100644 web/components/routes/link/header.tsx delete mode 100644 web/components/routes/link/hooks/use-link-actions.ts delete mode 100644 web/components/routes/link/list.tsx delete mode 100644 web/components/routes/link/manage.tsx delete mode 100644 web/components/routes/link/partials/form/description-input.tsx delete mode 100644 web/components/routes/link/partials/form/link-form.tsx delete mode 100644 web/components/routes/link/partials/form/notes-section.tsx delete mode 100644 web/components/routes/link/partials/form/schema.ts delete mode 100644 web/components/routes/link/partials/form/title-input.tsx delete mode 100644 web/components/routes/link/partials/form/url-badge.tsx delete mode 100644 web/components/routes/link/partials/form/url-input.tsx delete mode 100644 web/components/routes/link/partials/link-item.tsx delete mode 100644 web/components/routes/page/PageRoute.tsx delete mode 100644 web/components/routes/page/detail/PageDetailRoute.tsx delete mode 100644 web/components/routes/page/detail/header.tsx delete mode 100644 web/components/routes/page/header.tsx delete mode 100644 web/components/routes/page/hooks/use-column-styles.ts delete mode 100644 web/components/routes/page/hooks/use-page-actions.ts delete mode 100644 web/components/routes/page/list.tsx delete mode 100644 web/components/routes/page/partials/page-item.tsx delete mode 100644 web/components/routes/public/Autocomplete.tsx delete mode 100644 web/components/routes/public/PublicHomeRoute.tsx delete mode 100644 web/components/routes/public/anim.ts delete mode 100644 web/components/routes/public/force-graph-client-lazy.tsx delete mode 100644 web/components/routes/public/graph-data.json delete mode 100644 web/components/routes/search/header.tsx delete mode 100644 web/components/routes/search/wrapper.tsx delete mode 100644 web/components/routes/task/TaskForm.tsx delete mode 100644 web/components/routes/task/TaskItem.tsx delete mode 100644 web/components/routes/task/TaskList.tsx delete mode 100644 web/components/routes/task/TaskRoute.tsx delete mode 100644 web/components/routes/task/TodayTaskRoute.tsx delete mode 100644 web/components/routes/task/UpcomingTaskRoute.tsx delete mode 100644 web/components/routes/task/new-task-actions.ts delete mode 100644 web/components/routes/tauri/TauriRoute.tsx delete mode 100644 web/components/routes/topics/TopicRoute.tsx delete mode 100644 web/components/routes/topics/detail/TopicDetailRoute.tsx delete mode 100644 web/components/routes/topics/detail/header.tsx delete mode 100644 web/components/routes/topics/detail/list.tsx delete mode 100644 web/components/routes/topics/detail/partials/link-item.tsx delete mode 100644 web/components/routes/topics/header.tsx delete mode 100644 web/components/routes/topics/hooks/use-column-styles.ts delete mode 100644 web/components/routes/topics/list.tsx delete mode 100644 web/components/routes/topics/partials/topic-item.tsx delete mode 100644 web/components/ui/Keybind.tsx delete mode 100644 web/components/ui/LearningTodoStatus.tsx delete mode 100644 web/components/ui/alert-dialog.tsx delete mode 100644 web/components/ui/badge.tsx delete mode 100644 web/components/ui/breadcrumb.tsx delete mode 100644 web/components/ui/button.tsx delete mode 100644 web/components/ui/calendar.tsx delete mode 100644 web/components/ui/checkbox.tsx delete mode 100644 web/components/ui/command.tsx delete mode 100644 web/components/ui/context-menu.tsx delete mode 100644 web/components/ui/date-picker.tsx delete mode 100644 web/components/ui/dialog.tsx delete mode 100644 web/components/ui/drawer.tsx delete mode 100644 web/components/ui/dropdown-menu.tsx delete mode 100644 web/components/ui/form.tsx delete mode 100644 web/components/ui/input.tsx delete mode 100644 web/components/ui/label.tsx delete mode 100644 web/components/ui/popover.tsx delete mode 100644 web/components/ui/scroll-area.tsx delete mode 100644 web/components/ui/select.tsx delete mode 100644 web/components/ui/separator.tsx delete mode 100644 web/components/ui/sheet.tsx delete mode 100644 web/components/ui/sonner.tsx delete mode 100644 web/components/ui/textarea.tsx delete mode 100644 web/components/ui/toggle.tsx delete mode 100644 web/components/ui/tooltip.tsx delete mode 100644 web/hooks/use-active-item-scroll.ts delete mode 100644 web/hooks/use-event-listener.ts delete mode 100644 web/hooks/use-is-mounted.ts delete mode 100644 web/hooks/use-key-down.ts delete mode 100644 web/hooks/use-keyboard-manager.ts delete mode 100644 web/hooks/use-media.ts delete mode 100644 web/hooks/use-on-click-outside.ts delete mode 100644 web/hooks/use-throttle.ts delete mode 100644 web/hooks/use-touch-sensor.ts delete mode 100644 web/instrumentation.ts delete mode 100644 web/jest.config.ts delete mode 100644 web/jest.setup.ts delete mode 100644 web/lib/constants.ts delete mode 100644 web/lib/providers/confirm-provider.tsx delete mode 100644 web/lib/providers/deep-link-provider.tsx delete mode 100644 web/lib/providers/jazz-provider.tsx delete mode 100644 web/lib/providers/jotai-provider.tsx delete mode 100644 web/lib/providers/theme-provider.tsx delete mode 100644 web/lib/schema/global-link.old.ts delete mode 100644 web/lib/schema/global-topic.old.ts delete mode 100644 web/lib/schema/index.ts delete mode 100644 web/lib/schema/personal-link.ts delete mode 100644 web/lib/schema/tasks.ts delete mode 100644 web/lib/types.ts delete mode 100644 web/lib/utils/auth-procedure.ts delete mode 100644 web/lib/utils/canvas.ts delete mode 100644 web/lib/utils/htmlLikeElementUtil.test.tsx delete mode 100644 web/lib/utils/htmlLikeElementUtil.ts delete mode 100644 web/lib/utils/index.ts delete mode 100644 web/lib/utils/keyboard.ts delete mode 100644 web/lib/utils/schedule.ts delete mode 100644 web/lib/utils/schema.test.ts delete mode 100644 web/lib/utils/schema.ts delete mode 100644 web/lib/utils/slug.test.ts delete mode 100644 web/lib/utils/slug.ts delete mode 100644 web/lib/utils/urls.ts delete mode 100644 web/lib/utils/window-size.ts delete mode 100644 web/middleware.ts delete mode 100644 web/next.config.mjs create mode 100644 web/postcss.config.cjs delete mode 100644 web/postcss.config.mjs create mode 100644 web/public/android-chrome-192x192.png create mode 100644 web/public/android-chrome-512x512.png create mode 100644 web/public/apple-touch-icon.png create mode 100644 web/public/favicon-16x16.png create mode 100644 web/public/favicon-32x32.png create mode 100644 web/public/favicon.ico create mode 100644 web/public/favicon.png create mode 100644 web/public/site.webmanifest delete mode 100644 web/sentry.client.config.ts delete mode 100644 web/sentry.server.config.ts create mode 100644 web/shared/actions.ts rename web/{components/minimal-tiptap => shared}/hooks/use-throttle.ts (84%) create mode 100644 web/shared/la-editor/components/bubble-menu/bubble-menu.tsx rename web/{components => shared}/la-editor/components/bubble-menu/index.ts (100%) create mode 100644 web/shared/la-editor/components/ui/icon.tsx create mode 100644 web/shared/la-editor/components/ui/keybind.tsx create mode 100644 web/shared/la-editor/components/ui/popover-wrapper.tsx create mode 100644 web/shared/la-editor/components/ui/shortcut.tsx create mode 100644 web/shared/la-editor/components/ui/toolbar-button.tsx rename web/{components => shared}/la-editor/extensions/blockquote/blockquote.ts (57%) rename web/{components => shared}/la-editor/extensions/blockquote/index.ts (100%) rename web/{components => shared}/la-editor/extensions/bullet-list/bullet-list.ts (55%) rename web/{components => shared}/la-editor/extensions/bullet-list/index.ts (100%) rename web/{components/minimal-tiptap => shared/la-editor}/extensions/code-block-lowlight/code-block-lowlight.ts (57%) rename web/{components => shared}/la-editor/extensions/code-block-lowlight/index.ts (100%) create mode 100644 web/shared/la-editor/extensions/code/code.ts rename web/{components => shared}/la-editor/extensions/code/index.ts (100%) rename web/{components => shared}/la-editor/extensions/dropcursor/dropcursor.ts (55%) rename web/{components => shared}/la-editor/extensions/dropcursor/index.ts (100%) create mode 100644 web/shared/la-editor/extensions/heading/heading.ts rename web/{components => shared}/la-editor/extensions/heading/index.ts (100%) rename web/{components/minimal-tiptap => shared/la-editor}/extensions/horizontal-rule/horizontal-rule.ts (65%) rename web/{components => shared}/la-editor/extensions/horizontal-rule/index.ts (100%) rename web/{components => shared}/la-editor/extensions/index.ts (63%) rename web/{components => shared}/la-editor/extensions/link/index.ts (100%) create mode 100644 web/shared/la-editor/extensions/link/link.ts rename web/{components => shared}/la-editor/extensions/ordered-list/index.ts (100%) rename web/{components => shared}/la-editor/extensions/ordered-list/ordered-list.ts (56%) rename web/{components => shared}/la-editor/extensions/paragraph/index.ts (100%) rename web/{components => shared}/la-editor/extensions/paragraph/paragraph.ts (54%) rename web/{components => shared}/la-editor/extensions/selection/index.ts (100%) rename web/{components/minimal-tiptap => shared/la-editor}/extensions/selection/selection.ts (63%) create mode 100644 web/shared/la-editor/extensions/slash-command/groups.ts rename web/{components => shared}/la-editor/extensions/slash-command/index.ts (100%) create mode 100644 web/shared/la-editor/extensions/slash-command/menu-list.tsx create mode 100644 web/shared/la-editor/extensions/slash-command/slash-command.ts create mode 100644 web/shared/la-editor/extensions/slash-command/types.ts create mode 100644 web/shared/la-editor/extensions/starter-kit.ts create mode 100644 web/shared/la-editor/extensions/task-item/components/task-item-view.tsx rename web/{components => shared}/la-editor/extensions/task-item/index.ts (100%) create mode 100644 web/shared/la-editor/extensions/task-item/task-item.ts rename web/{components => shared}/la-editor/extensions/task-list/index.ts (100%) create mode 100644 web/shared/la-editor/extensions/task-list/task-list.ts create mode 100644 web/shared/la-editor/hooks/use-text-menu-commands.ts create mode 100644 web/shared/la-editor/hooks/use-text-menu-states.ts rename web/{components => shared}/la-editor/index.ts (100%) create mode 100644 web/shared/la-editor/la-editor.tsx rename web/{components => shared}/la-editor/lib/utils/index.ts (60%) create mode 100644 web/shared/la-editor/lib/utils/isCustomNodeSelected.ts create mode 100644 web/shared/la-editor/lib/utils/isTextSelected.ts rename web/{components => shared}/la-editor/styles/index.css (100%) create mode 100644 web/shared/la-editor/styles/partials/code-highlight.css create mode 100644 web/shared/la-editor/styles/partials/code.css create mode 100644 web/shared/la-editor/styles/partials/lists.css create mode 100644 web/shared/la-editor/styles/partials/misc.css rename web/{components => shared}/la-editor/styles/partials/prosemirror-base.css (67%) create mode 100644 web/shared/la-editor/styles/partials/typography.css create mode 100644 web/shared/la-editor/styles/partials/vars.css rename web/{components => shared}/la-editor/types.ts (53%) rename web/{components => shared}/minimal-tiptap/components/bubble-menu/image-bubble-menu.tsx (64%) create mode 100644 web/shared/minimal-tiptap/components/bubble-menu/link-bubble-menu.tsx create mode 100644 web/shared/minimal-tiptap/components/image/image-edit-block.tsx rename web/{components => shared}/minimal-tiptap/components/image/image-edit-dialog.tsx (58%) rename web/{components => shared}/minimal-tiptap/components/image/image-popover-block.tsx (67%) rename web/{components => shared}/minimal-tiptap/components/link/link-edit-block.tsx (59%) rename web/{components => shared}/minimal-tiptap/components/link/link-edit-popover.tsx (60%) rename web/{components => shared}/minimal-tiptap/components/link/link-popover-block.tsx (58%) create mode 100644 web/shared/minimal-tiptap/components/section/five.tsx rename web/{components => shared}/minimal-tiptap/components/section/four.tsx (50%) create mode 100644 web/shared/minimal-tiptap/components/section/one.tsx rename web/{components => shared}/minimal-tiptap/components/section/three.tsx (52%) create mode 100644 web/shared/minimal-tiptap/components/section/two.tsx create mode 100644 web/shared/minimal-tiptap/components/shortcut-key.tsx create mode 100644 web/shared/minimal-tiptap/components/toolbar-button.tsx create mode 100644 web/shared/minimal-tiptap/components/toolbar-section.tsx rename web/{components/la-editor => shared/minimal-tiptap}/extensions/code-block-lowlight/code-block-lowlight.ts (56%) create mode 100644 web/shared/minimal-tiptap/extensions/code-block-lowlight/index.ts rename web/{components => shared}/minimal-tiptap/extensions/color/color.ts (61%) create mode 100644 web/shared/minimal-tiptap/extensions/color/index.ts rename web/{components/la-editor => shared/minimal-tiptap}/extensions/horizontal-rule/horizontal-rule.ts (65%) create mode 100644 web/shared/minimal-tiptap/extensions/horizontal-rule/index.ts rename web/{components => shared}/minimal-tiptap/extensions/image/components/image-view-block.tsx (58%) create mode 100644 web/shared/minimal-tiptap/extensions/image/image.ts create mode 100644 web/shared/minimal-tiptap/extensions/image/index.ts create mode 100644 web/shared/minimal-tiptap/extensions/index.ts create mode 100644 web/shared/minimal-tiptap/extensions/link/index.ts rename web/{components => shared}/minimal-tiptap/extensions/link/link.ts (76%) create mode 100644 web/shared/minimal-tiptap/extensions/reset-marks-on-enter/index.ts create mode 100644 web/shared/minimal-tiptap/extensions/reset-marks-on-enter/reset-marks-on-enter.ts create mode 100644 web/shared/minimal-tiptap/extensions/selection/index.ts create mode 100644 web/shared/minimal-tiptap/extensions/selection/selection.ts create mode 100644 web/shared/minimal-tiptap/extensions/unset-all-marks/index.ts create mode 100644 web/shared/minimal-tiptap/extensions/unset-all-marks/unset-all-marks.ts rename web/{components => shared}/minimal-tiptap/hooks/use-image-load.ts (91%) create mode 100644 web/shared/minimal-tiptap/hooks/use-minimal-tiptap.ts create mode 100644 web/shared/minimal-tiptap/hooks/use-theme.ts create mode 100644 web/shared/minimal-tiptap/hooks/use-throttle.ts create mode 100644 web/shared/minimal-tiptap/index.ts create mode 100644 web/shared/minimal-tiptap/minimal-tiptap.tsx rename web/{components => shared}/minimal-tiptap/styles/index.css (97%) rename web/{components => shared}/minimal-tiptap/styles/partials/code.css (100%) rename web/{components => shared}/minimal-tiptap/styles/partials/lists.css (74%) rename web/{components => shared}/minimal-tiptap/styles/partials/placeholder.css (100%) rename web/{components => shared}/minimal-tiptap/styles/partials/typography.css (100%) rename web/{components => shared}/minimal-tiptap/types.ts (76%) create mode 100644 web/shared/minimal-tiptap/utils.ts diff --git a/web/.env.example b/web/.env.example deleted file mode 100644 index bae601e6..00000000 --- a/web/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -NEXT_PUBLIC_APP_NAME="Learn Anything" -NEXT_PUBLIC_APP_URL=http://localhost:3000 - -NEXT_PUBLIC_JAZZ_GLOBAL_GROUP="" - -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= - -NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in -NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up - -NEXT_PUBLIC_JAZZ_PEER_URL="wss://" - -RONIN_TOKEN= - -NEXT_PUBLIC_SENTRY_DSN= -NEXT_PUBLIC_SENTRY_ORG= -NEXT_PUBLIC_SENTRY_PROJECT= - -# IGNORE_BUILD_ERRORS=true \ No newline at end of file diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 00000000..6c60a854 --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,14 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + ], + ignorePatterns: ["dist", ".eslintrc.cjs"], + parser: "@typescript-eslint/parser", + rules: { + "@typescript-eslint/no-explicit-any": "off", + }, +} diff --git a/web/.eslintrc.json b/web/.eslintrc.json deleted file mode 100644 index 72cc705c..00000000 --- a/web/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/web/.gitignore b/web/.gitignore index d1dc7456..6cac3e14 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,40 +1,24 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +node_modules +package-lock.json +yarn.lock -# dependencies -/node_modules -/.pnp -.pnp.js -.yarn/install-state.gz - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build - -# misc +.env .DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local - -# vercel +.cache .vercel +.output +.vinxi -# typescript -*.tsbuildinfo -next-env.d.ts - +/build/ +/api/ +/server/build +/public/build +.vinxi # Sentry Config File .env.sentry-build-plugin -.ronin +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +.ronin \ No newline at end of file diff --git a/web/.npmrc b/web/.npmrc deleted file mode 100644 index 2806c9c2..00000000 --- a/web/.npmrc +++ /dev/null @@ -1,2 +0,0 @@ -[install.scopes] -ronin = { url = "https://ronin.supply", token = "$RONIN_TOKEN" } \ No newline at end of file diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 00000000..2be5eaa6 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,4 @@ +**/build +**/public +pnpm-lock.yaml +routeTree.gen.ts \ No newline at end of file diff --git a/web/app.config.ts b/web/app.config.ts new file mode 100644 index 00000000..6e0bb830 --- /dev/null +++ b/web/app.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "@tanstack/start/config" +import tsConfigPaths from "vite-tsconfig-paths" + +export default defineConfig({ + vite: { + plugins: () => [ + tsConfigPaths({ + projects: ["./tsconfig.json"], + }), + ], + }, +}) diff --git a/web/app/(auth)/layout.tsx b/web/app/(auth)/layout.tsx deleted file mode 100644 index e99c8df2..00000000 --- a/web/app/(auth)/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function AuthLayout({ - children -}: Readonly<{ - children: React.ReactNode -}>) { - return
{children}
-} diff --git a/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx deleted file mode 100644 index 0b5feeb9..00000000 --- a/web/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SignInClient } from "@/components/custom/clerk/sign-in-client" - -export default async function Page() { - return ( -
- -
- ) -} diff --git a/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx deleted file mode 100644 index 57c4d9e3..00000000 --- a/web/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { SignUpClient } from "@/components/custom/clerk/sign-up-client" - -export default async function Page() { - return ( -
- -
- ) -} diff --git a/web/app/(pages)/(topics)/[name]/page.tsx b/web/app/(pages)/(topics)/[name]/page.tsx deleted file mode 100644 index 28e0f249..00000000 --- a/web/app/(pages)/(topics)/[name]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TopicDetailRoute } from "@/components/routes/topics/detail/TopicDetailRoute" - -export default function DetailTopicPage({ params }: { params: { name: string } }) { - return -} diff --git a/web/app/(pages)/community/[topicName]/page.tsx b/web/app/(pages)/community/[topicName]/page.tsx deleted file mode 100644 index 62e69371..00000000 --- a/web/app/(pages)/community/[topicName]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { CommunityTopicRoute } from "@/components/routes/community/CommunityTopicRoute" - -export default function CommunityTopicPage({ params }: { params: { topicName: string } }) { - return -} diff --git a/web/app/(pages)/edit-profile/page.tsx b/web/app/(pages)/edit-profile/page.tsx deleted file mode 100644 index 3e492911..00000000 --- a/web/app/(pages)/edit-profile/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import EditProfileRoute from "@/components/routes/EditProfileRoute" - -export default function EditProfilePage() { - return -} diff --git a/web/app/(pages)/journal/page.tsx b/web/app/(pages)/journal/page.tsx deleted file mode 100644 index 0c2cd038..00000000 --- a/web/app/(pages)/journal/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { JournalRoute } from "@/components/routes/journal/JournalRoute" -import { currentUser } from "@clerk/nextjs/server" -import { notFound } from "next/navigation" -import { get } from "ronin" - -export default async function JournalPage() { - const user = await currentUser() - const flag = await get.featureFlag.with.name("JOURNAL") - - if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { - notFound() - } - - return -} diff --git a/web/app/(pages)/layout.tsx b/web/app/(pages)/layout.tsx deleted file mode 100644 index 0014c84c..00000000 --- a/web/app/(pages)/layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Viewport } from "next" -import { Sidebar } from "@/components/custom/sidebar/sidebar" -import { CommandPalette } from "@/components/custom/command-palette/command-palette" -import { LearnAnythingOnboarding } from "@/components/custom/learn-anything-onboarding" -import { Shortcut } from "@/components/custom/shortcut/shortcut" -import { GlobalKeyboardHandler } from "@/components/custom/global-keyboard-handler" - -export const viewport: Viewport = { - width: "device-width, shrink-to-fit=no", - maximumScale: 1, - userScalable: false -} - -export default function PageLayout({ children }: { children: React.ReactNode }) { - return ( -
- - - - - - -
-
- {children} -
-
-
- ) -} diff --git a/web/app/(pages)/links/page.tsx b/web/app/(pages)/links/page.tsx deleted file mode 100644 index 682f9c0a..00000000 --- a/web/app/(pages)/links/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LinkRoute } from "@/components/routes/link/LinkRoute" - -export default function LinksPage() { - return -} diff --git a/web/app/(pages)/onboarding/page.tsx b/web/app/(pages)/onboarding/page.tsx deleted file mode 100644 index b286035c..00000000 --- a/web/app/(pages)/onboarding/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import OnboardingRoute from "@/components/routes/OnboardingRoute" - -export default function EditProfilePage() { - return -} diff --git a/web/app/(pages)/pages/[id]/page.tsx b/web/app/(pages)/pages/[id]/page.tsx deleted file mode 100644 index 974b86c1..00000000 --- a/web/app/(pages)/pages/[id]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PageDetailRoute } from "@/components/routes/page/detail/PageDetailRoute" - -export default function DetailPage({ params }: { params: { id: string } }) { - return -} diff --git a/web/app/(pages)/pages/page.tsx b/web/app/(pages)/pages/page.tsx deleted file mode 100644 index edc1ae2c..00000000 --- a/web/app/(pages)/pages/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { PageRoute } from "@/components/routes/page/PageRoute" - -export default function Page() { - return -} diff --git a/web/app/(pages)/profile/_components/wrapper.tsx b/web/app/(pages)/profile/_components/wrapper.tsx deleted file mode 100644 index cc7257de..00000000 --- a/web/app/(pages)/profile/_components/wrapper.tsx +++ /dev/null @@ -1,165 +0,0 @@ -"use client" - -import { useAccount } from "@/lib/providers/jazz-provider" -import { useUser } from "@clerk/nextjs" -import { useState, useRef, useCallback } from "react" -import { useParams } from "next/navigation" -import { Input } from "@/components/ui/input" -import { Button } from "@/components/ui/button" -import Link from "next/link" -import { Avatar, AvatarImage } from "@/components/ui/avatar" - -interface ProfileStatsProps { - number: number - label: string -} - -const ProfileStats: React.FC = ({ number, label }) => { - return ( -
-

{number}

-

{label}

-
- ) -} - -export const ProfileWrapper = () => { - const account = useAccount() - const params = useParams() - const username = params.username as string - const { user, isSignedIn } = useUser() - const avatarInputRef = useRef(null) - - const editAvatar = (event: React.ChangeEvent) => { - const file = event.target.files?.[0] - if (file) { - const imageUrl = URL.createObjectURL(file) - if (account.me && account.me.profile) { - account.me.profile.avatarUrl = imageUrl - } - } - } - - const [isEditing, setIsEditing] = useState(false) - const [newName, setNewName] = useState(account.me?.profile?.name || "") - const [error, setError] = useState("") - - const editProfileClicked = () => { - setIsEditing(true) - setError("") - } - - const changeName = (e: React.ChangeEvent) => { - setNewName(e.target.value) - setError("") - } - - const validateName = useCallback((name: string) => { - if (name.trim().length < 2) { - return "Name must be at least 2 characters long" - } - if (name.trim().length > 40) { - return "Name must not exceed 40 characters" - } - return "" - }, []) - - const saveProfile = () => { - const validationError = validateName(newName) - if (validationError) { - setError(validationError) - return - } - - if (account.me && account.me.profile) { - account.me.profile.name = newName.trim() - } - setIsEditing(false) - } - - const cancelEditing = () => { - setNewName(account.me?.profile?.name || "") - setIsEditing(false) - setError("") - } - - if (!account.me || !account.me.profile) { - return ( -
-
-

- Oops! This account doesn't exist. -

-

Try searching for another.

-

- The link you followed may be broken, or the page may have been removed. Go back to - - homepage - - . -

-
-
- ) - } - - return ( -
-
-

Profile

-
-

{username}

-
-
- - -
- {isEditing ? ( - <> - - {error &&

{error}

} - - ) : ( -

{account.me?.profile?.name}

- )} -
- {isEditing ? ( -
- - -
- ) : ( - - )} -
-
-
-
- - - -
-
-
-

Public profiles are coming soon

-
-
- ) -} diff --git a/web/app/(pages)/profile/page.tsx b/web/app/(pages)/profile/page.tsx deleted file mode 100644 index 8b4cb3e8..00000000 --- a/web/app/(pages)/profile/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { ProfileWrapper } from "./_components/wrapper" - -export default function ProfilePage() { - return -} diff --git a/web/app/(pages)/search/page.tsx b/web/app/(pages)/search/page.tsx deleted file mode 100644 index c6797e3a..00000000 --- a/web/app/(pages)/search/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SearchWrapper } from "@/components/routes/search/wrapper" - -export default function ProfilePage() { - return -} diff --git a/web/app/(pages)/settings/page.tsx b/web/app/(pages)/settings/page.tsx deleted file mode 100644 index a5070993..00000000 --- a/web/app/(pages)/settings/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SettingsRoute } from "@/components/routes/SettingsRoute" - -export default function SettingsPage() { - return -} diff --git a/web/app/(pages)/tasks/page.tsx b/web/app/(pages)/tasks/page.tsx deleted file mode 100644 index cabad9b4..00000000 --- a/web/app/(pages)/tasks/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { TaskRoute } from "@/components/routes/task/TaskRoute" -import { currentUser } from "@clerk/nextjs/server" -import { notFound } from "next/navigation" -import { get } from "ronin" - -export default async function TaskPage() { - const user = await currentUser() - const flag = await get.featureFlag.with.name("TASK") - - if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { - notFound() - } - - return -} diff --git a/web/app/(pages)/tasks/today/page.tsx b/web/app/(pages)/tasks/today/page.tsx deleted file mode 100644 index d2e4f8a0..00000000 --- a/web/app/(pages)/tasks/today/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { TaskRoute } from "@/components/routes/task/TaskRoute" -import { currentUser } from "@clerk/nextjs/server" -import { notFound } from "next/navigation" -import { get } from "ronin" - -export default async function TodayTasksPage() { - const user = await currentUser() - const flag = await get.featureFlag.with.name("TASK") - - if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { - notFound() - } - - return -} diff --git a/web/app/(pages)/tasks/upcoming/page.tsx b/web/app/(pages)/tasks/upcoming/page.tsx deleted file mode 100644 index 2ab33656..00000000 --- a/web/app/(pages)/tasks/upcoming/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { TaskRoute } from "@/components/routes/task/TaskRoute" -import { currentUser } from "@clerk/nextjs/server" -import { notFound } from "next/navigation" -import { get } from "ronin" - -export default async function UpcomingTasksPage() { - const user = await currentUser() - const flag = await get.featureFlag.with.name("TASK") - - if (!user?.emailAddresses.some(email => flag?.emails.includes(email.emailAddress))) { - notFound() - } - - return -} diff --git a/web/app/(pages)/tauri/page.tsx b/web/app/(pages)/tauri/page.tsx deleted file mode 100644 index 6f9b88d0..00000000 --- a/web/app/(pages)/tauri/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import TauriRoute from "@/components/routes/tauri/TauriRoute" - -export default function TauriPage() { - return -} diff --git a/web/app/(pages)/topics/page.tsx b/web/app/(pages)/topics/page.tsx deleted file mode 100644 index 6251415e..00000000 --- a/web/app/(pages)/topics/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { TopicRoute } from "@/components/routes/topics/TopicRoute" - -export default function Page() { - return -} diff --git a/web/app/(public)/layout.tsx b/web/app/(public)/layout.tsx deleted file mode 100644 index 95c7ee6d..00000000 --- a/web/app/(public)/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function PublicLayout({ - children -}: Readonly<{ - children: React.ReactNode -}>) { - return
{children}
-} diff --git a/web/app/actions.ts b/web/app/actions.ts index 42f598f6..a4fb2a19 100644 --- a/web/app/actions.ts +++ b/web/app/actions.ts @@ -1,91 +1,140 @@ -"use server" +import { clerkClient, getAuth } from "@clerk/tanstack-start/server" +import { createServerFn } from "@tanstack/start" +import { create, get } from "ronin" +import * as cheerio from "cheerio" +import { ensureUrlProtocol } from "@/lib/utils" +import { urlSchema } from "@/lib/utils/schema" -import { authedProcedure } from "@/lib/utils/auth-procedure" -import { currentUser } from "@clerk/nextjs/server" -import { get } from "ronin" -import { create } from "ronin" -import { z } from "zod" -import { ZSAError } from "zsa" - -const MAX_FILE_SIZE = 1 * 1024 * 1024 -const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"] - -export const getFeatureFlag = authedProcedure - .input( - z.object({ - name: z.string() - }) - ) - .handler(async ({ input }) => { - const { name } = input - const flag = await get.featureFlag.with.name(name) - - return { flag } - }) - -export const sendFeedback = authedProcedure - .input( - z.object({ - content: z.string() - }) - ) - .handler(async ({ input, ctx }) => { - const { clerkUser } = ctx - const { content } = input - - try { - await create.feedback.with({ - message: content, - emailFrom: clerkUser?.emailAddresses[0].emailAddress - }) - } catch (error) { - console.error(error) - throw new ZSAError("ERROR", "Failed to send feedback") - } - }) - -export const storeImage = authedProcedure - .input( - z.object({ - file: z - .any() - .refine(file => file instanceof File, { - message: "Not a file" - }) - .refine(file => ALLOWED_FILE_TYPES.includes(file.type), { - message: "Invalid file type. Only JPEG, PNG, GIF, and WebP images are allowed." - }) - .refine(file => file.size <= MAX_FILE_SIZE, { - message: "File size exceeds the maximum limit of 1 MB." - }) - }), - { type: "formData" } - ) - .handler(async ({ ctx, input }) => { - const { file } = input - const { clerkUser } = ctx - - if (!clerkUser?.id) { - throw new ZSAError("NOT_AUTHORIZED", "You are not authorized to upload files") - } - - try { - const fileModel = await create.image.with({ - content: file, - name: file.name, - type: file.type, - size: file.size - }) - - return { fileModel } - } catch (error) { - console.error(error) - throw new ZSAError("ERROR", "Failed to store image") - } - }) - -export const isExistingUser = async () => { - const clerkUser = await currentUser() - const roninUser = await get.existingStripeSubscriber.with({ email: clerkUser?.emailAddresses[0].emailAddress }) - return clerkUser?.emailAddresses[0].emailAddress === roninUser?.email +interface Metadata { + title: string + description: string + icon: string | null + url: string } + +const DEFAULT_VALUES = { + TITLE: "", + DESCRIPTION: "", + FAVICON: null, +} + +export const fetchClerkAuth = createServerFn("GET", async (_, ctx) => { + const auth = await getAuth(ctx.request) + + return { + user: auth, + } +}) + +export const getFeatureFlag = createServerFn( + "GET", + async (data: { name: string }) => { + const response = await get.featureFlag.with({ + name: data.name, + }) + + return response + }, +) + +export const sendFeedbackFn = createServerFn( + "POST", + async (data: { content: string }, { request }) => { + const auth = await getAuth(request) + if (!auth.userId) { + throw new Error("Unauthorized") + } + const user = await clerkClient({ + telemetry: { disabled: true }, + }).users.getUser(auth.userId) + await create.feedback.with({ + message: data.content, + emailFrom: user.emailAddresses[0].emailAddress, + }) + }, +) + +export const isExistingUserFn = createServerFn( + "GET", + async (_, { request }) => { + const auth = await getAuth(request) + + if (!auth.userId) { + return false + } + + const user = await clerkClient({ + telemetry: { disabled: true }, + }).users.getUser(auth.userId) + + const roninUser = await get.existingStripeSubscriber.with({ + email: user.emailAddresses[0].emailAddress, + }) + + return user.emailAddresses[0].emailAddress === roninUser?.email + }, +) + +export const getMetadata = createServerFn("GET", async (url: string) => { + if (!url) { + return new Response('Missing "url" query parameter', { + status: 400, + }) + } + + const result = urlSchema.safeParse(url) + if (!result.success) { + throw new Error( + result.error.issues.map((issue) => issue.message).join(", "), + ) + } + + url = ensureUrlProtocol(url) + + try { + const response = await fetch(url, { + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + }, + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.text() + const $ = cheerio.load(data) + + const metadata: Metadata = { + title: + $("title").text() || + $('meta[property="og:title"]').attr("content") || + DEFAULT_VALUES.TITLE, + description: + $('meta[name="description"]').attr("content") || + $('meta[property="og:description"]').attr("content") || + DEFAULT_VALUES.DESCRIPTION, + icon: + $('link[rel="icon"]').attr("href") || + $('link[rel="shortcut icon"]').attr("href") || + DEFAULT_VALUES.FAVICON, + url: url, + } + + if (metadata.icon && !metadata.icon.startsWith("http")) { + metadata.icon = new URL(metadata.icon, url).toString() + } + + return metadata + } catch (error) { + console.error("Error fetching metadata:", error) + const defaultMetadata: Metadata = { + title: DEFAULT_VALUES.TITLE, + description: DEFAULT_VALUES.DESCRIPTION, + icon: DEFAULT_VALUES.FAVICON, + url: url, + } + return defaultMetadata + } +}) diff --git a/web/app/api/metadata/route.test.ts b/web/app/api/metadata/route.test.ts deleted file mode 100644 index 9f4fc91d..00000000 --- a/web/app/api/metadata/route.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * @jest-environment node - */ -import { NextRequest } from "next/server" -import axios from "axios" -import { GET } from "./route" - -const DEFAULT_VALUES = { - TITLE: "", - DESCRIPTION: "", - FAVICON: null -} - -jest.mock("axios") -const mockedAxios = axios as jest.Mocked - -describe("Metadata Fetcher", () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it("should return metadata when URL is valid", async () => { - const mockHtml = ` - - - Test Title - - - - - ` - - mockedAxios.get.mockResolvedValue({ data: mockHtml }) - - const req = { - url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com" - } as unknown as NextRequest - - const response = await GET(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toEqual({ - title: "Test Title", - description: "Test Description", - icon: "https://example.com/icon.ico", - url: "https://example.com" - }) - }) - - it("should return an error when URL is missing", async () => { - const req = { - url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata" - } as unknown as NextRequest - - const response = await GET(req) - const data = await response.json() - - expect(response.status).toBe(400) - expect(data).toEqual({ error: "URL is required" }) - }) - - it("should return default values when fetching fails", async () => { - mockedAxios.get.mockRejectedValue(new Error("Network error")) - - const req = { - url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com" - } as unknown as NextRequest - - const response = await GET(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toEqual({ - title: DEFAULT_VALUES.TITLE, - description: DEFAULT_VALUES.DESCRIPTION, - icon: null, - url: "https://example.com" - }) - }) - - it("should handle missing metadata gracefully", async () => { - const mockHtml = ` - - - - - ` - - mockedAxios.get.mockResolvedValue({ data: mockHtml }) - - const req = { - url: process.env.NEXT_PUBLIC_APP_URL + "/api/metadata?url=https://example.com" - } as unknown as NextRequest - - const response = await GET(req) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data).toEqual({ - title: DEFAULT_VALUES.TITLE, - description: DEFAULT_VALUES.DESCRIPTION, - icon: null, - url: "https://example.com" - }) - }) -}) diff --git a/web/app/api/metadata/route.ts b/web/app/api/metadata/route.ts deleted file mode 100644 index f6209940..00000000 --- a/web/app/api/metadata/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { NextRequest, NextResponse } from "next/server" -import axios from "axios" -import * as cheerio from "cheerio" -import { ensureUrlProtocol } from "@/lib/utils" -import { urlSchema } from "@/lib/utils/schema" - -interface Metadata { - title: string - description: string - icon: string | null - url: string -} - -const DEFAULT_VALUES = { - TITLE: "", - DESCRIPTION: "", - FAVICON: null -} - -export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url) - let url = searchParams.get("url") - - await new Promise(resolve => setTimeout(resolve, 1000)) - - if (!url) { - return NextResponse.json({ error: "URL is required" }, { status: 400 }) - } - - const result = urlSchema.safeParse(url) - if (!result.success) { - throw new Error(result.error.issues.map(issue => issue.message).join(", ")) - } - - url = ensureUrlProtocol(url) - - try { - const { data } = await axios.get(url, { - timeout: 5000, - headers: { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" - } - }) - - const $ = cheerio.load(data) - - const metadata: Metadata = { - title: $("title").text() || $('meta[property="og:title"]').attr("content") || DEFAULT_VALUES.TITLE, - description: - $('meta[name="description"]').attr("content") || - $('meta[property="og:description"]').attr("content") || - DEFAULT_VALUES.DESCRIPTION, - icon: $('link[rel="icon"]').attr("href") || $('link[rel="shortcut icon"]').attr("href") || DEFAULT_VALUES.FAVICON, - url: url - } - - if (metadata.icon && !metadata.icon.startsWith("http")) { - metadata.icon = new URL(metadata.icon, url).toString() - } - - return NextResponse.json(metadata) - } catch (error) { - const defaultMetadata: Metadata = { - title: DEFAULT_VALUES.TITLE, - description: DEFAULT_VALUES.DESCRIPTION, - icon: DEFAULT_VALUES.FAVICON, - url: url - } - return NextResponse.json(defaultMetadata) - } -} diff --git a/web/app/api/search-stream/route.ts b/web/app/api/search-stream/route.ts deleted file mode 100644 index 21ca9a50..00000000 --- a/web/app/api/search-stream/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest, NextResponse } from "next/server" - -export async function POST(request: NextRequest) { - let data: unknown - try { - data = (await request.json()) as unknown - } catch (error) { - return new NextResponse("Invalid JSON", { status: 400 }) - } - - if (typeof data !== "object" || !data) { - return new NextResponse("Missing request data", { status: 400 }) - } - - if (!("question" in data) || typeof data.question !== "string") { - return new NextResponse("Missing `question` data field.", { status: 400 }) - } - - const chunks: string[] = [ - "# Hello", - " from th", - "e server", - "\n\n your question", - " was:\n\n", - "> ", - data.question, - "\n\n", - "**good bye!**" - ] - - const stream = new ReadableStream({ - async start(controller) { - for (const chunk of chunks) { - controller.enqueue(chunk) - await new Promise(resolve => setTimeout(resolve, 1000)) - } - - controller.close() - } - }) - - return new NextResponse(stream) -} diff --git a/web/app/client.tsx b/web/app/client.tsx new file mode 100644 index 00000000..caafdbd5 --- /dev/null +++ b/web/app/client.tsx @@ -0,0 +1,8 @@ +/// +import { hydrateRoot } from "react-dom/client" +import { StartClient } from "@tanstack/start" +import { createRouter } from "./router" + +const router = createRouter() + +hydrateRoot(document.getElementById("root")!, ) diff --git a/web/app/command-palette.css b/web/app/command-palette.css deleted file mode 100644 index f4e67bc7..00000000 --- a/web/app/command-palette.css +++ /dev/null @@ -1,127 +0,0 @@ -@keyframes scaleIn { - 0% { - transform: scale(0.97) translateX(-50%); - opacity: 0; - } - to { - transform: scale(1) translateX(-50%); - opacity: 1; - } -} - -@keyframes scaleOut { - 0% { - transform: scale(1) translateX(-50%); - opacity: 1; - } - to { - transform: scale(0.97) translateX(-50%); - opacity: 0; - } -} - -@keyframes fadeIn { - 0% { - opacity: 0; - } - to { - opacity: 0.8; - } -} -@keyframes fadeOut { - 0% { - opacity: 0.8; - } - to { - opacity: 0; - } -} - -:root { - --cmdk-shadow: rgba(0, 0, 0, 0.12) 0px 4px 30px, rgba(0, 0, 0, 0.04) 0px 3px 17px, rgba(0, 0, 0, 0.04) 0px 2px 8px, - rgba(0, 0, 0, 0.04) 0px 1px 1px; - --cmdk-bg: rgb(255, 255, 255); - --cmdk-border-color: rgb(216, 216, 216); - - --cmdk-input-color: rgb(48, 48, 49); - --cmdk-input-placeholder: hsl(0, 0%, 56.1%); - - --cmdk-accent: rgb(243, 243, 243); -} - -.dark { - --cmdk-shadow: rgba(0, 0, 0, 0.15) 0px 4px 40px, rgba(0, 0, 0, 0.184) 0px 3px 20px, rgba(0, 0, 0, 0.184) 0px 3px 12px, - rgba(0, 0, 0, 0.184) 0px 2px 8px, rgba(0, 0, 0, 0.184) 0px 1px 1px; - --cmdk-bg: rgb(27, 28, 31); - --cmdk-border-color: rgb(56, 59, 65); - - --cmdk-input-color: rgb(228, 229, 233); - --cmdk-input-placeholder: hsl(0, 0%, 43.9%); - - --cmdk-accent: rgb(44, 48, 57); -} - -[la-overlay][cmdk-overlay] { - animation: fadeIn 0.2s ease; - @apply fixed inset-0 z-50 opacity-80; -} - -[la-dialog][cmdk-dialog] { - top: 15%; - transform: translateX(-50%); - max-width: 640px; - background: var(--cmdk-bg); - box-shadow: var(--cmdk-shadow); - transform-origin: left; - animation: scaleIn 0.2s ease; - transition: transform 0.1s ease; - border: 0.5px solid var(--cmdk-border-color); - @apply fixed left-1/2 z-50 w-full overflow-hidden rounded-lg outline-none; -} - -[la-dialog][cmdk-dialog][data-state="closed"] { - animation: scaleOut 0.2s ease; -} - -.la [cmdk-input-wrapper] { - border-bottom: 1px solid var(--cmdk-border-color); - height: 62px; - font-size: 1.125rem; - @apply relative; -} - -.la [cmdk-input] { - font-size: inherit; - height: 62px; - color: var(--cmdk-input-color); - caret-color: rgb(110, 94, 210); - @apply m-0 w-full appearance-none border-none bg-transparent p-5 outline-none; -} - -.la [cmdk-input]::placeholder { - color: var(--cmdk-input-placeholder); -} - -.la [cmdk-list] { - max-height: 400px; - overflow: auto; - overscroll-behavior: contain; - transition: 100ms ease; - transition-property: height; - @apply p-2; -} - -.la [cmdk-group-heading] { - font-size: 13px; - height: 30px; - @apply text-muted-foreground flex items-center px-2; -} - -.la [cmdk-empty] { - @apply text-muted-foreground flex h-16 items-center justify-center whitespace-pre-wrap text-sm; -} - -.la [cmdk-item] { - scroll-margin: 8px 0; - @apply flex min-h-10 cursor-pointer items-center gap-3 rounded-md px-2 text-sm aria-selected:bg-[var(--cmdk-accent)]; -} diff --git a/web/app/components/DefaultCatchBoundary.tsx b/web/app/components/DefaultCatchBoundary.tsx new file mode 100644 index 00000000..ebb1259f --- /dev/null +++ b/web/app/components/DefaultCatchBoundary.tsx @@ -0,0 +1,53 @@ +import { + ErrorComponent, + Link, + rootRouteId, + useMatch, + useRouter, +} from "@tanstack/react-router" +import type { ErrorComponentProps } from "@tanstack/react-router" + +export function DefaultCatchBoundary({ error }: ErrorComponentProps) { + const router = useRouter() + const isRoot = useMatch({ + strict: false, + select: (state) => state.id === rootRouteId, + }) + + console.error(error) + + return ( +
+ +
+ + {isRoot ? ( + + Home + + ) : ( + { + e.preventDefault() + window.history.back() + }} + > + Go Back + + )} +
+
+ ) +} diff --git a/web/app/components/GlobalKeyboardHandler.tsx b/web/app/components/GlobalKeyboardHandler.tsx new file mode 100644 index 00000000..83861309 --- /dev/null +++ b/web/app/components/GlobalKeyboardHandler.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { isModKey, isServer } from "@/lib/utils" +import { useAtom } from "jotai" +import { usePageActions } from "~/hooks/actions/use-page-actions" +import { useAuth } from "@clerk/tanstack-start" +import { useNavigate } from "@tanstack/react-router" +import queryString from "query-string" +import { commandPaletteOpenAtom } from "~/store/any-store" + +type RegisterKeyDownProps = { + trigger: KeyFilter + handler: (event: KeyboardEvent) => void + options?: Options +} + +function RegisterKeyDown({ trigger, handler, options }: RegisterKeyDownProps) { + useKeyDown(trigger, handler, options) + return null +} + +type Sequence = { + [key: string]: string +} + +const SEQUENCES: Sequence = { + GL: "/links", + GP: "/pages", + GT: "/topics", +} + +const MAX_SEQUENCE_TIME = 1000 + +export function GlobalKeyboardHandler() { + if (isServer()) { + return null + } + + return +} + +export function KeyboardHandlerContent() { + const [, setOpenCommandPalette] = useAtom(commandPaletteOpenAtom) + const [sequence, setSequence] = React.useState([]) + const { signOut } = useAuth() + const navigate = useNavigate() + const { me } = useAccountOrGuest() + const { newPage } = usePageActions() + + const resetSequence = React.useCallback(() => { + setSequence([]) + }, []) + + const checkSequence = React.useCallback(() => { + const sequenceStr = sequence.join("") + const route = SEQUENCES[sequenceStr] + + if (route) { + navigate({ + to: route, + }) + resetSequence() + } + }, [sequence, navigate, resetSequence]) + + const goToNewLink = React.useCallback( + (event: KeyboardEvent) => { + if (event.metaKey || event.altKey) { + return + } + + navigate({ + to: `/links?${queryString.stringify({ create: true })}`, + }) + }, + [navigate], + ) + + const goToNewPage = React.useCallback( + (event: KeyboardEvent) => { + if (event.metaKey || event.altKey) { + return + } + + if (!me || me._type === "Anonymous") { + return + } + + const page = newPage(me) + + navigate({ + to: `/pages/${page.id}`, + }) + }, + [me, newPage, navigate], + ) + + useKeyDown( + (e) => e.altKey && e.shiftKey && e.code === "KeyQ", + () => { + signOut() + }, + ) + + useKeyDown( + () => true, + (e) => { + const key = e.key.toUpperCase() + setSequence((prev) => [...prev, key]) + }, + ) + + useKeyDown( + (e) => isModKey(e) && e.code === "KeyK", + (e) => { + e.preventDefault() + setOpenCommandPalette((prev) => !prev) + }, + ) + + React.useEffect(() => { + checkSequence() + + const timeoutId = setTimeout(() => { + resetSequence() + }, MAX_SEQUENCE_TIME) + + return () => clearTimeout(timeoutId) + }, [sequence, checkSequence, resetSequence]) + + return ( + me && + me._type !== "Anonymous" && ( + <> + + + + ) + ) +} diff --git a/web/app/components/NotFound.tsx b/web/app/components/NotFound.tsx new file mode 100644 index 00000000..b29bf8dc --- /dev/null +++ b/web/app/components/NotFound.tsx @@ -0,0 +1,25 @@ +import { Link } from "@tanstack/react-router" + +export function NotFound({ children }: { children?: any }) { + return ( +
+
+ {children ||

The page you are looking for does not exist.

} +
+

+ + + Start Over + +

+
+ ) +} diff --git a/web/app/components/Onboarding.tsx b/web/app/components/Onboarding.tsx new file mode 100644 index 00000000..4c9761c1 --- /dev/null +++ b/web/app/components/Onboarding.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from "react" +import { atom, useAtom } from "jotai" +import { atomWithStorage } from "jotai/utils" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { isExistingUserFn } from "~/actions" +import { useLocation } from "@tanstack/react-router" + +const hasVisitedAtom = atomWithStorage("hasVisitedLearnAnything", false) +const isDialogOpenAtom = atom(true) + +export function Onboarding() { + const { pathname } = useLocation() + const [hasVisited, setHasVisited] = useAtom(hasVisitedAtom) + const [isOpen, setIsOpen] = useAtom(isDialogOpenAtom) + const [isFetching, setIsFetching] = useState(true) + const [isExisting, setIsExisting] = useState(false) + + useEffect(() => { + const loadUser = async () => { + try { + const existingUser = await isExistingUserFn() + setIsExisting(existingUser) + setIsOpen(true) + } catch (error) { + console.error("Error loading user:", error) + } finally { + setIsFetching(false) + } + } + + if (!hasVisited && pathname !== "/") { + loadUser() + } + }, [hasVisited, pathname, setIsOpen]) + + const handleClose = () => { + setIsOpen(false) + setHasVisited(true) + } + + if (hasVisited || isFetching) return null + + return ( + + + + +

Welcome to Learn Anything!

+
+
+ + + {isExisting && ( + <> +

Existing Customer Notice

+

+ We noticed you are an existing Learn Anything customer. We + sincerely apologize for any broken experience you may have + encountered on the old website. We've been working hard on + this new version, which addresses previous issues and offers + more features. As an early customer, you're locked in at + the $3 price for our upcoming pro version. + Thank you for your support! +

+ + )} +

+ Learn Anything is a learning platform that organizes knowledge in a + social way. You can create pages, add links, track learning status + of any topic, and more things in the future. +

+

+ Try do these quick onboarding steps to get a feel for the product: +

+
    +
  • Create your first page
  • +
  • Add a link to a resource
  • +
  • Update your learning status on a topic
  • +
+

+ If you have any questions, don't hesitate to reach out. Click + on question mark button in the bottom right corner and enter your + message. +

+
+ + + Close + + Get Started + + +
+
+ ) +} + +export default Onboarding diff --git a/web/app/components/command-palette/command-data.ts b/web/app/components/command-palette/command-data.ts new file mode 100644 index 00000000..bf1e4344 --- /dev/null +++ b/web/app/components/command-palette/command-data.ts @@ -0,0 +1,142 @@ +import { icons } from "lucide-react" +import { LaAccount } from "@/lib/schema" +import { HTMLLikeElement } from "@/lib/utils" +import { useCommandActions } from "~/hooks/use-command-actions" + +export type CommandAction = string | (() => void) + +export interface CommandItemType { + id?: string + icon?: keyof typeof icons + value: string + label: HTMLLikeElement | string + action: CommandAction + payload?: any + shortcut?: string +} + +export type CommandGroupType = Array<{ + heading?: string + items: CommandItemType[] +}> + +const createNavigationItem = ( + icon: keyof typeof icons, + value: string, + path: string, + actions: ReturnType, +): CommandItemType => ({ + icon, + value: `Go to ${value}`, + label: { + tag: "span", + children: [ + "Go to ", + { + tag: "span", + attributes: { className: "font-semibold" }, + children: [value], + }, + ], + }, + action: () => actions.navigateTo(path), +}) + +export const createCommandGroups = ( + actions: ReturnType, + me: LaAccount, +): Record => ({ + home: [ + { + heading: "General", + items: [ + { + icon: "SunMoon", + value: "Change Theme...", + label: "Change Theme...", + action: "CHANGE_PAGE", + payload: "changeTheme", + }, + { + icon: "Copy", + value: "Copy Current URL", + label: "Copy Current URL", + action: actions.copyCurrentURL, + }, + ], + }, + { + heading: "Personal Links", + items: [ + { + icon: "TextSearch", + value: "Search Links...", + label: "Search Links...", + action: "CHANGE_PAGE", + payload: "searchLinks", + }, + { + icon: "Plus", + value: "Create New Link...", + label: "Create New Link...", + action: () => actions.navigateTo("/links?create=true"), + }, + ], + }, + { + heading: "Personal Pages", + items: [ + { + icon: "FileSearch", + value: "Search Pages...", + label: "Search Pages...", + action: "CHANGE_PAGE", + payload: "searchPages", + }, + { + icon: "Plus", + value: "Create New Page...", + label: "Create New Page...", + action: () => actions.createNewPage(me), + }, + ], + }, + { + heading: "Navigation", + items: [ + createNavigationItem("ArrowRight", "Links", "/links", actions), + createNavigationItem("ArrowRight", "Pages", "/pages", actions), + createNavigationItem("ArrowRight", "Search", "/search", actions), + createNavigationItem("ArrowRight", "Profile", "/profile", actions), + createNavigationItem("ArrowRight", "Settings", "/settings", actions), + ], + }, + ], + searchLinks: [], + searchPages: [], + topics: [], + changeTheme: [ + { + items: [ + { + icon: "Moon", + value: "Change Theme to Dark", + label: "Change Theme to Dark", + action: () => actions.changeTheme("dark"), + }, + { + icon: "Sun", + value: "Change Theme to Light", + label: "Change Theme to Light", + action: () => actions.changeTheme("light"), + }, + { + icon: "Monitor", + value: "Change Theme to System", + label: "Change Theme to System", + action: () => actions.changeTheme("system"), + }, + ], + }, + ], +}) diff --git a/web/app/components/command-palette/command-group.tsx b/web/app/components/command-palette/command-group.tsx new file mode 100644 index 00000000..96823cbb --- /dev/null +++ b/web/app/components/command-palette/command-group.tsx @@ -0,0 +1,73 @@ +import * as React from "react" +import { Command } from "cmdk" +import { CommandSeparator, CommandShortcut } from "@/components/ui/command" +import { LaIcon } from "@/components/custom/la-icon" +import { CommandItemType, CommandAction } from "./command-data" +import { HTMLLikeElement, renderHTMLLikeElement } from "@/lib/utils" + +export interface CommandItemProps extends Omit { + action: CommandAction + handleAction: (action: CommandAction, payload?: any) => void +} + +const HTMLLikeRenderer: React.FC<{ content: HTMLLikeElement | string }> = + React.memo(({ content }) => { + return ( + {renderHTMLLikeElement(content)} + ) + }) + +HTMLLikeRenderer.displayName = "HTMLLikeRenderer" + +export const CommandItem: React.FC = React.memo( + ({ icon, label, action, payload, shortcut, handleAction, ...item }) => ( + handleAction(action, payload)} + > + {icon && } + + {shortcut && {shortcut}} + + ), +) + +CommandItem.displayName = "CommandItem" + +export interface CommandGroupProps { + heading?: string + items: CommandItemType[] + handleAction: (action: CommandAction, payload?: any) => void + isLastGroup: boolean +} + +export const CommandGroup: React.FC = React.memo( + ({ heading, items, handleAction, isLastGroup }) => { + return ( + <> + {heading ? ( + + {items.map((item, index) => ( + + ))} + + ) : ( + items.map((item, index) => ( + + )) + )} + {!isLastGroup && } + + ) + }, +) + +CommandGroup.displayName = "CommandGroup" diff --git a/web/app/components/command-palette/command-palette.tsx b/web/app/components/command-palette/command-palette.tsx new file mode 100644 index 00000000..d9b064ea --- /dev/null +++ b/web/app/components/command-palette/command-palette.tsx @@ -0,0 +1,214 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Command } from "cmdk" +import { + Dialog, + DialogPortal, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog" +import { CommandGroup } from "./command-group" +import { CommandAction, createCommandGroups } from "./command-data" +import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { useAtom } from "jotai" +import { useCommandActions } from "~/hooks/use-command-actions" +import { + filterItems, + getTopics, + getPersonalLinks, + getPersonalPages, + handleAction, +} from "./utils" +import { searchSafeRegExp } from "~/lib/utils" +import { commandPaletteOpenAtom } from "~/store/any-store" + +export function CommandPalette() { + const { me } = useAccountOrGuest() + + if (me._type === "Anonymous") return null + + return +} + +export function RealCommandPalette() { + const { me } = useAccount({ root: { personalLinks: [], personalPages: [] } }) + const dialogRef = React.useRef(null) + const [inputValue, setInputValue] = React.useState("") + const [activePage, setActivePage] = React.useState("home") + const [open, setOpen] = useAtom(commandPaletteOpenAtom) + + const actions = useCommandActions() + const commandGroups = React.useMemo( + () => me && createCommandGroups(actions, me), + [actions, me], + ) + + const bounce = React.useCallback(() => { + if (dialogRef.current) { + dialogRef.current.style.transform = "scale(0.99) translateX(-50%)" + setTimeout(() => { + if (dialogRef.current) { + dialogRef.current.style.transform = "" + } + }, 100) + } + }, []) + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + bounce() + } + + if (activePage !== "home" && !inputValue && e.key === "Backspace") { + e.preventDefault() + setActivePage("home") + setInputValue("") + bounce() + } + }, + [activePage, inputValue, bounce], + ) + + const topics = React.useMemo(() => getTopics(actions), [actions]) + const personalLinks = React.useMemo( + () => getPersonalLinks(me?.root.personalLinks || [], actions), + [me?.root.personalLinks, actions], + ) + const personalPages = React.useMemo( + () => getPersonalPages(me?.root.personalPages || [], actions), + [me?.root.personalPages, actions], + ) + + const getFilteredCommands = React.useCallback(() => { + if (!commandGroups) return [] + + const searchRegex = searchSafeRegExp(inputValue) + + if (activePage === "home") { + if (!inputValue) { + return commandGroups.home + } + + const allGroups = [ + ...Object.values(commandGroups).flat(), + personalLinks, + personalPages, + topics, + ] + + return allGroups + .map((group) => ({ + heading: group.heading, + items: filterItems(group.items, searchRegex), + })) + .filter((group) => group.items.length > 0) + } + + switch (activePage) { + case "searchLinks": + return [ + ...commandGroups.searchLinks, + { items: filterItems(personalLinks.items, searchRegex) }, + ] + case "searchPages": + return [ + ...commandGroups.searchPages, + { items: filterItems(personalPages.items, searchRegex) }, + ] + default: { + const pageCommands = commandGroups[activePage] + if (!inputValue) return pageCommands + return pageCommands + .map((group) => ({ + heading: group.heading, + items: filterItems(group.items, searchRegex), + })) + .filter((group) => group.items.length > 0) + } + } + }, [ + inputValue, + activePage, + commandGroups, + personalLinks, + personalPages, + topics, + ]) + + const handleActionWrapper = React.useCallback( + (action: CommandAction, payload?: any) => { + handleAction(action, payload, { + setActivePage, + setInputValue, + bounce, + closeDialog: () => setOpen(false), + }) + }, + [bounce, setOpen], + ) + + const filteredCommands = React.useMemo( + () => getFilteredCommands(), + [getFilteredCommands], + ) + + const commandKey = React.useMemo(() => { + return filteredCommands + .map((group) => { + const itemsKey = group.items + .map((item) => `${item.label}-${item.value}`) + .join("|") + return `${group.heading}:${itemsKey}` + }) + .join("__") + }, [filteredCommands]) + + if (!me) return null + + return ( + + + + + + Command Palette + + Search for commands and actions + + + + +
+ +
+ + + No results found. + {filteredCommands.map((group, index, array) => ( + + ))} + +
+
+
+
+ ) +} diff --git a/web/app/components/command-palette/utils.ts b/web/app/components/command-palette/utils.ts new file mode 100644 index 00000000..68e6c433 --- /dev/null +++ b/web/app/components/command-palette/utils.ts @@ -0,0 +1,74 @@ +import { GraphData } from "~/lib/constants" +import { CommandAction, CommandItemType } from "./command-data" + +export const filterItems = (items: CommandItemType[], searchRegex: RegExp) => + items.filter((item) => searchRegex.test(item.value)).slice(0, 10) + +export const getTopics = (actions: { navigateTo: (path: string) => void }) => ({ + heading: "Topics", + items: GraphData.map((topic) => ({ + icon: "Circle" as const, + value: topic?.prettyName || "", + label: topic?.prettyName || "", + action: () => actions.navigateTo(`/${topic?.name}`), + })), +}) + +export const getPersonalLinks = ( + personalLinks: any[], + actions: { openLinkInNewTab: (url: string) => void }, +) => ({ + heading: "Personal Links", + items: personalLinks.map((link) => ({ + id: link?.id, + icon: "Link" as const, + value: link?.title || "Untitled", + label: link?.title || "Untitled", + action: () => actions.openLinkInNewTab(link?.url || "#"), + })), +}) + +export const getPersonalPages = ( + personalPages: any[], + actions: { navigateTo: (path: string) => void }, +) => ({ + heading: "Personal Pages", + items: personalPages.map((page) => ({ + id: page?.id, + icon: "FileText" as const, + value: page?.title || "Untitled", + label: page?.title || "Untitled", + action: () => actions.navigateTo(`/pages/${page?.id}`), + })), +}) + +export const handleAction = ( + action: CommandAction, + payload: any, + callbacks: { + setActivePage: (page: string) => void + setInputValue: (value: string) => void + bounce: () => void + closeDialog: () => void + }, +) => { + const { setActivePage, setInputValue, bounce, closeDialog } = callbacks + + if (typeof action === "function") { + action() + closeDialog() + return + } + + switch (action) { + case "CHANGE_PAGE": + if (payload) { + setActivePage(payload) + setInputValue("") + bounce() + } + break + default: + closeDialog() + } +} diff --git a/web/app/components/custom/ai-search.tsx b/web/app/components/custom/ai-search.tsx new file mode 100644 index 00000000..5d8e303d --- /dev/null +++ b/web/app/components/custom/ai-search.tsx @@ -0,0 +1,91 @@ +import * as React from "react" +import * as smd from "streaming-markdown" + +interface AiSearchProps { + searchQuery: string +} + +const AiSearch: React.FC = (props: { searchQuery: string }) => { + const [error, setError] = React.useState("") + + const root_el = React.useRef(null) + + const [parser, md_el] = React.useMemo(() => { + const md_el = document.createElement("div") + const renderer = smd.default_renderer(md_el) + const parser = smd.parser(renderer) + return [parser, md_el] + }, []) + + React.useEffect(() => { + if (root_el.current) { + root_el.current.appendChild(md_el) + } + }, [md_el]) + + React.useEffect(() => { + const question = props.searchQuery + + fetchData() + async function fetchData() { + let response: Response + try { + response = await fetch("/api/search-stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ question: question }), + }) + } catch (error) { + console.error("Error fetching data:", error) + setError("Error fetching data") + return + } + + if (!response.body) { + console.error("Response has no body") + setError("Response has no body") + return + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + + let done = false + while (!done) { + const res = await reader.read() + + if (res.value) { + const text = decoder.decode(res.value) + smd.parser_write(parser, text) + } + + if (res.done) { + smd.parser_end(parser) + done = true + } + } + } + }, [props.searchQuery, parser]) + + return ( +
+
+
+

✨ This is what I have found:

+
+
+
+

{error}

+ +
+ ) +} + +export default AiSearch diff --git a/web/app/components/custom/column.tsx b/web/app/components/custom/column.tsx new file mode 100644 index 00000000..16ddd045 --- /dev/null +++ b/web/app/components/custom/column.tsx @@ -0,0 +1,44 @@ +import React from "react" +import { cn } from "@/lib/utils" + +interface ColumnWrapperProps extends React.HTMLAttributes { + style?: { [key: string]: string } +} + +interface ColumnTextProps extends React.HTMLAttributes {} + +const ColumnWrapper = React.forwardRef( + ({ children, className, style, ...props }, ref) => ( +
+ {children} +
+ ), +) + +ColumnWrapper.displayName = "ColumnWrapper" + +const ColumnText = React.forwardRef( + ({ children, className, ...props }, ref) => ( + + {children} + + ), +) + +ColumnText.displayName = "ColumnText" + +export const Column = { + Wrapper: ColumnWrapper, + Text: ColumnText, +} diff --git a/web/app/components/custom/content-header.tsx b/web/app/components/custom/content-header.tsx new file mode 100644 index 00000000..d9e48dd0 --- /dev/null +++ b/web/app/components/custom/content-header.tsx @@ -0,0 +1,58 @@ +import * as React from "react" +import { Button } from "@/components/ui/button" +import { useAtom } from "jotai" +import { isCollapseAtom, toggleCollapseAtom } from "@/store/sidebar" +import { useMedia } from "@/hooks/use-media" +import { cn } from "@/lib/utils" +import { LaIcon } from "@/components/custom/la-icon" + +type ContentHeaderProps = Omit, "title"> + +export const ContentHeader = React.forwardRef< + HTMLDivElement, + ContentHeaderProps +>(({ children, className, ...props }, ref) => { + return ( +
+ {children} +
+ ) +}) + +ContentHeader.displayName = "ContentHeader" + +export const SidebarToggleButton: React.FC = () => { + const [isCollapse] = useAtom(isCollapseAtom) + const [, toggle] = useAtom(toggleCollapseAtom) + const isTablet = useMedia("(max-width: 1024px)") + + if (!isCollapse && !isTablet) return null + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + toggle() + } + + return ( +
+ +
+ ) +} diff --git a/web/app/components/custom/date-picker.tsx b/web/app/components/custom/date-picker.tsx new file mode 100644 index 00000000..0ab66425 --- /dev/null +++ b/web/app/components/custom/date-picker.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { format } from "date-fns" +import { Calendar as CalendarIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +interface DatePickerProps { + date: Date | undefined + onDateChange: (date: Date | undefined) => void + className?: string +} + +export function DatePicker({ date, onDateChange, className }: DatePickerProps) { + const [open, setOpen] = React.useState(false) + + const selectDate = (selectedDate: Date | undefined) => { + onDateChange(selectedDate) + setOpen(false) + } + + return ( + + + + + e.stopPropagation()} + > + + + + ) +} diff --git a/web/app/components/custom/la-icon.tsx b/web/app/components/custom/la-icon.tsx new file mode 100644 index 00000000..54293c5b --- /dev/null +++ b/web/app/components/custom/la-icon.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { icons } from "lucide-react" + +export type IconProps = { + name: keyof typeof icons + className?: string + strokeWidth?: number + [key: string]: any +} + +export const LaIcon = React.memo( + ({ name, className, size, strokeWidth, ...props }: IconProps) => { + const IconComponent = icons[name] + + if (!IconComponent) { + return null + } + + return ( + + ) + }, +) + +LaIcon.displayName = "LaIcon" diff --git a/web/app/components/custom/learning-state-selector.tsx b/web/app/components/custom/learning-state-selector.tsx new file mode 100644 index 00000000..3f5286d9 --- /dev/null +++ b/web/app/components/custom/learning-state-selector.tsx @@ -0,0 +1,137 @@ +import * as React from "react" +import { useAtom } from "jotai" +import { Button } from "@/components/ui/button" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { LaIcon } from "@/components/custom/la-icon" +import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" +import { linkLearningStateSelectorAtom } from "@/store/link" +import { + Command, + CommandInput, + CommandList, + CommandItem, + CommandGroup, +} from "@/components/ui/command" +import { ScrollArea } from "@/components/ui/scroll-area" +import { icons } from "lucide-react" + +interface LearningStateSelectorProps { + showSearch?: boolean + defaultLabel?: string + searchPlaceholder?: string + value?: string + onChange: (value: LearningStateValue) => void + className?: string + defaultIcon?: keyof typeof icons +} + +export const LearningStateSelector: React.FC = ({ + showSearch = true, + defaultLabel = "State", + searchPlaceholder = "Search state...", + value, + onChange, + className, + defaultIcon, +}) => { + const [isLearningStateSelectorOpen, setIsLearningStateSelectorOpen] = useAtom( + linkLearningStateSelectorAtom, + ) + const selectedLearningState = React.useMemo( + () => LEARNING_STATES.find((ls) => ls.value === value), + [value], + ) + + const handleSelect = (selectedValue: string) => { + onChange(selectedValue as LearningStateValue) + setIsLearningStateSelectorOpen(false) + } + + const iconName = selectedLearningState?.icon || defaultIcon + const labelText = selectedLearningState?.label || defaultLabel + + return ( + + + + + + + + + ) +} + +interface LearningStateSelectorContentProps { + showSearch: boolean + searchPlaceholder: string + value?: string + onSelect: (value: string) => void +} + +export const LearningStateSelectorContent: React.FC< + LearningStateSelectorContentProps +> = ({ showSearch, searchPlaceholder, value, onSelect }) => { + return ( + + {showSearch && ( + + )} + + + + {LEARNING_STATES.map((ls) => ( + + {ls.icon && ( + + )} + {ls.label} + + + ))} + + + + + ) +} diff --git a/web/app/components/custom/spinner.tsx b/web/app/components/custom/spinner.tsx new file mode 100644 index 00000000..5eeddcf2 --- /dev/null +++ b/web/app/components/custom/spinner.tsx @@ -0,0 +1,32 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +interface SpinnerProps extends React.SVGAttributes {} + +export const Spinner = React.forwardRef( + ({ className, ...props }, ref) => ( + + + + + ), +) + +Spinner.displayName = "Spinner" diff --git a/web/app/components/custom/textarea-autosize.tsx b/web/app/components/custom/textarea-autosize.tsx new file mode 100644 index 00000000..e2eed8e3 --- /dev/null +++ b/web/app/components/custom/textarea-autosize.tsx @@ -0,0 +1,25 @@ +import * as React from "react" +import BaseTextareaAutosize from "react-textarea-autosize" +import { TextareaAutosizeProps as BaseTextareaAutosizeProps } from "react-textarea-autosize" +import { cn } from "@/lib/utils" + +export interface TextareaProps extends Omit {} + +const TextareaAutosize = React.forwardRef( + ({ className, ...props }, ref) => { + return ( + + ) + }, +) + +TextareaAutosize.displayName = "TextareaAutosize" + +export { TextareaAutosize } diff --git a/web/app/components/custom/topic-selector.tsx b/web/app/components/custom/topic-selector.tsx new file mode 100644 index 00000000..62691f85 --- /dev/null +++ b/web/app/components/custom/topic-selector.tsx @@ -0,0 +1,208 @@ +import * as React from "react" +import { atom, useAtom } from "jotai" +import { useVirtualizer } from "@tanstack/react-virtual" +import { Button, buttonVariants } from "@/components/ui/button" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { LaIcon } from "@/components/custom/la-icon" +import { + Command, + CommandInput, + CommandList, + CommandItem, + CommandGroup, +} from "@/components/ui/command" +import { useCoState } from "@/lib/providers/jazz-provider" +import { PublicGlobalGroup } from "@/lib/schema/master/public-group" +import { ListOfTopics, Topic } from "@/lib/schema" +import { JAZZ_GLOBAL_GROUP_ID } from "@/lib/constants" +import { VariantProps } from "class-variance-authority" + +interface TopicSelectorProps extends VariantProps { + showSearch?: boolean + defaultLabel?: string + searchPlaceholder?: string + value?: string | null + onChange?: (value: string) => void + onTopicChange?: (value: Topic) => void + className?: string + renderSelectedText?: (value?: string | null) => React.ReactNode + side?: "bottom" | "top" | "right" | "left" + align?: "center" | "end" | "start" +} + +export const topicSelectorAtom = atom(false) + +export const TopicSelector = React.forwardRef< + HTMLButtonElement, + TopicSelectorProps +>( + ( + { + showSearch = true, + defaultLabel = "Select topic", + searchPlaceholder = "Search topic...", + value, + onChange, + onTopicChange, + className, + renderSelectedText, + side = "bottom", + align = "end", + ...props + }, + ref, + ) => { + const [isTopicSelectorOpen, setIsTopicSelectorOpen] = + useAtom(topicSelectorAtom) + const group = useCoState(PublicGlobalGroup, JAZZ_GLOBAL_GROUP_ID, { + root: { topics: [] }, + }) + + const handleSelect = React.useCallback( + (selectedTopicName: string, topic: Topic) => { + onChange?.(selectedTopicName) + onTopicChange?.(topic) + setIsTopicSelectorOpen(false) + }, + [onChange, setIsTopicSelectorOpen, onTopicChange], + ) + + const displaySelectedText = React.useMemo(() => { + if (renderSelectedText) { + return renderSelectedText(value) + } + return {value || defaultLabel} + }, [value, defaultLabel, renderSelectedText]) + + return ( + + + + + + {group?.root.topics && ( + + )} + + + ) + }, +) + +TopicSelector.displayName = "TopicSelector" + +interface TopicSelectorContentProps + extends Omit { + onSelect: (value: string, topic: Topic) => void + topics: ListOfTopics +} + +const TopicSelectorContent: React.FC = React.memo( + ({ showSearch, searchPlaceholder, value, onSelect, topics }) => { + const [search, setSearch] = React.useState("") + const filteredTopics = React.useMemo( + () => + topics.filter((topic) => + topic?.prettyName.toLowerCase().includes(search.toLowerCase()), + ), + [topics, search], + ) + + const parentRef = React.useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: filteredTopics.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, + overscan: 5, + }) + + return ( + + {showSearch && ( + + )} + +
+
+ + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const topic = filteredTopics[virtualRow.index] + return ( + topic && ( + onSelect(value, topic)} + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: `${virtualRow.size}px`, + transform: `translateY(${virtualRow.start}px)`, + }} + > + {topic.prettyName} + + + ) + ) + })} + +
+
+
+
+ ) + }, +) + +TopicSelectorContent.displayName = "TopicSelectorContent" + +export default TopicSelector diff --git a/web/app/components/icons/discord-icon.tsx b/web/app/components/icons/discord-icon.tsx new file mode 100644 index 00000000..927b14a3 --- /dev/null +++ b/web/app/components/icons/discord-icon.tsx @@ -0,0 +1,29 @@ +export const DiscordIcon = () => ( + + + + + +) diff --git a/web/app/components/icons/logo-icon.tsx b/web/app/components/icons/logo-icon.tsx new file mode 100644 index 00000000..76f8865a --- /dev/null +++ b/web/app/components/icons/logo-icon.tsx @@ -0,0 +1,64 @@ +import * as React from "react" + +interface LogoIconProps extends React.SVGProps {} + +export const LogoIcon = ({ className, ...props }: LogoIconProps) => { + return ( + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/web/app/components/shortcut/shortcut.tsx b/web/app/components/shortcut/shortcut.tsx new file mode 100644 index 00000000..f7b19e25 --- /dev/null +++ b/web/app/components/shortcut/shortcut.tsx @@ -0,0 +1,190 @@ +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { atom, useAtom } from "jotai" +import { + Sheet, + SheetPortal, + SheetOverlay, + SheetTitle, + sheetVariants, + SheetDescription, +} from "@/components/ui/sheet" +import { LaIcon } from "@/components/custom/la-icon" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" + +export const showShortcutAtom = atom(false) + +type ShortcutItem = { + label: string + keys: string[] + then?: string[] +} + +type ShortcutSection = { + title: string + shortcuts: ShortcutItem[] +} + +const SHORTCUTS: ShortcutSection[] = [ + { + title: "General", + shortcuts: [ + { label: "Open command menu", keys: ["⌘", "k"] }, + { label: "Log out", keys: ["⌥", "⇧", "q"] }, + ], + }, + { + title: "Navigation", + shortcuts: [ + { label: "Go to link", keys: ["G"], then: ["L"] }, + { label: "Go to page", keys: ["G"], then: ["P"] }, + { label: "Go to topic", keys: ["G"], then: ["T"] }, + ], + }, + { + title: "Links", + shortcuts: [{ label: "Create new link", keys: ["c"] }], + }, + { + title: "Pages", + shortcuts: [{ label: "Create new page", keys: ["p"] }], + }, +] + +const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => ( + +) + +const ShortcutItem: React.FC = ({ label, keys, then }) => ( +
+
+ {label} +
+
+ + + {keys.map((key, index) => ( + + ))} + {then && ( + <> + then + {then.map((key, index) => ( + + ))} + + )} + + +
+
+) + +const ShortcutSection: React.FC = ({ title, shortcuts }) => ( +
+

{title}

+
+ {shortcuts.map((shortcut, index) => ( + + ))} +
+
+) + +export function Shortcut() { + const [showShortcut, setShowShortcut] = useAtom(showShortcutAtom) + const [searchQuery, setSearchQuery] = React.useState("") + + const { disableKeydown } = useKeyboardManager("shortcutSection") + + React.useEffect(() => { + disableKeydown(showShortcut) + }, [showShortcut, disableKeydown]) + + const filteredShortcuts = React.useMemo(() => { + if (!searchQuery) return SHORTCUTS + + return SHORTCUTS.map((section) => ({ + ...section, + shortcuts: section.shortcuts.filter((shortcut) => + shortcut.label.toLowerCase().includes(searchQuery.toLowerCase()), + ), + })).filter((section) => section.shortcuts.length > 0) + }, [searchQuery]) + + return ( + + + + +
+ + Keyboard Shortcuts + + + Quickly navigate around the app + + +
+ + + + Close + +
+ +
+
+ + setSearchQuery(e.target.value)} + /> + +
+ +
+
+
+ {filteredShortcuts.map((section, index) => ( + + ))} +
+
+
+
+
+
+ ) +} diff --git a/web/app/components/sidebar/partials/feedback.tsx b/web/app/components/sidebar/partials/feedback.tsx new file mode 100644 index 00000000..ebdb093e --- /dev/null +++ b/web/app/components/sidebar/partials/feedback.tsx @@ -0,0 +1,158 @@ +import * as React from "react" +import { Button, buttonVariants } from "@/components/ui/button" +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogPortal, + DialogOverlay, + DialogPrimitive, +} from "@/components/ui/dialog" +import { LaIcon } from "@/components/custom/la-icon" +import { MinimalTiptapEditor } from "@shared/minimal-tiptap" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { useRef, useState } from "react" +import { cn } from "@/lib/utils" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { toast } from "sonner" +import { Spinner } from "@/components/custom/spinner" +import { Editor } from "@tiptap/react" +import { sendFeedbackFn } from "~/actions" + +const formSchema = z.object({ + content: z.string().min(1, { + message: "Feedback cannot be empty", + }), +}) + +export function Feedback() { + const [open, setOpen] = useState(false) + const editorRef = useRef(null) + const [isPending, setIsPending] = useState(false) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + content: "", + }, + }) + + const handleCreate = React.useCallback( + ({ editor }: { editor: Editor }) => { + if (form.getValues("content") && editor.isEmpty) { + editor.commands.setContent(form.getValues("content")) + } + editorRef.current = editor + }, + [form], + ) + + async function onSubmit(values: z.infer) { + try { + setIsPending(true) + await sendFeedbackFn(values) + + form.reset({ content: "" }) + editorRef.current?.commands.clearContent() + + setOpen(false) + toast.success("Feedback sent") + } catch (error) { + toast.error("Failed to send feedback") + } finally { + setIsPending(false) + } + } + + return ( + + + + + + + + +
+ + + Share feedback + + Your feedback helps us improve. Please share your thoughts, + ideas, and suggestions + + + + ( + + Content + + + + + + )} + /> + + + + Cancel + + + + + +
+
+
+ ) +} diff --git a/web/app/components/sidebar/partials/journal-section.tsx b/web/app/components/sidebar/partials/journal-section.tsx new file mode 100644 index 00000000..acedfe6b --- /dev/null +++ b/web/app/components/sidebar/partials/journal-section.tsx @@ -0,0 +1,120 @@ +import { useAccount } from "@/lib/providers/jazz-provider" +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" +import { useAuth, useUser } from "@clerk/tanstack-start" +import { LaIcon } from "~/components/custom/la-icon" +import { Link } from "@tanstack/react-router" +import { getFeatureFlag } from "~/actions" + +export const JournalSection: React.FC = () => { + const { me } = useAccount() + const journalEntries = me?.root?.journalEntries + + const [, setIsFetching] = useState(false) + const [isFeatureActive, setIsFeatureActive] = useState(false) + const { isLoaded, isSignedIn } = useAuth() + const { user } = useUser() + + useEffect(() => { + async function checkFeatureFlag() { + setIsFetching(true) + + if (isLoaded && isSignedIn) { + const response = await getFeatureFlag({ name: "JOURNAL" }) + + if ( + user?.emailAddresses.some((email) => + response?.emails.includes(email.emailAddress), + ) + ) { + setIsFeatureActive(true) + } + setIsFetching(false) + } + } + + checkFeatureFlag() + }, [isLoaded, isSignedIn, user]) + + if (!isLoaded || !isSignedIn) { + return
Loading...
+ } + + if (!me) return null + + if (!isFeatureActive) { + return null + } + + return ( +
+ + {journalEntries && journalEntries.length > 0 && ( + + )} +
+ ) +} + +interface JournalHeaderProps { + entriesCount: number +} + +const JournalSectionHeader: React.FC = ({ + entriesCount, +}) => ( + +

+ Journal + {entriesCount > 0 && ( + ({entriesCount}) + )} +

+ +) + +interface JournalEntryListProps { + entries: any[] +} + +const JournalEntryList: React.FC = ({ entries }) => { + return ( +
+ {entries.map((entry, index) => ( + + ))} +
+ ) +} + +interface JournalEntryItemProps { + entry: any +} + +const JournalEntryItem: React.FC = ({ entry }) => ( + +
+
+ +

+ {entry.title} +

+
+
+ +) diff --git a/web/app/components/sidebar/partials/link-section.tsx b/web/app/components/sidebar/partials/link-section.tsx new file mode 100644 index 00000000..a100210a --- /dev/null +++ b/web/app/components/sidebar/partials/link-section.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Link } from "@tanstack/react-router" +import { useAccount } from "@/lib/providers/jazz-provider" +import { cn } from "@/lib/utils" +import { PersonalLinkLists } from "@/lib/schema/personal-link" +import { LearningStateValue } from "~/lib/constants" + +export const LinkSection: React.FC = () => { + const { me } = useAccount({ root: { personalLinks: [] } }) + + if (!me) return null + + const linkCount = me.root.personalLinks?.length || 0 + + return ( +
+ + +
+ ) +} + +interface LinkSectionHeaderProps { + linkCount: number +} + +const LinkSectionHeader: React.FC = ({ linkCount }) => ( + + Links + {linkCount > 0 && ( + {linkCount} + )} + +) + +interface LinkListProps { + personalLinks: PersonalLinkLists +} + +const LinkList: React.FC = ({ personalLinks }) => { + const linkStates: LearningStateValue[] = [ + "wantToLearn", + "learning", + "learned", + ] + const linkLabels: Record = { + wantToLearn: "To Learn", + learning: "Learning", + learned: "Learned", + } + + const linkCounts = linkStates.reduce( + (acc, state) => ({ + ...acc, + [state]: personalLinks.filter((link) => link?.learningState === state) + .length, + }), + {} as Record, + ) + + return ( +
+ {linkStates.map((state) => ( + + ))} +
+ ) +} + +interface LinkListItemProps { + label: string + state: LearningStateValue + count: number +} + +const LinkListItem: React.FC = ({ label, state, count }) => ( +
+
+ +
+

+ {label} +

+
+ + {count > 0 && ( + + {count} + + )} +
+
+) diff --git a/web/app/components/sidebar/partials/page-section.tsx b/web/app/components/sidebar/partials/page-section.tsx new file mode 100644 index 00000000..3e92f2c3 --- /dev/null +++ b/web/app/components/sidebar/partials/page-section.tsx @@ -0,0 +1,284 @@ +import * as React from "react" +import { useAtom } from "jotai" +import { atomWithStorage } from "jotai/utils" +import { Link, useNavigate } from "@tanstack/react-router" +import { useAccount } from "@/lib/providers/jazz-provider" +import { cn } from "@/lib/utils" +import { PersonalPage, PersonalPageLists } from "@/lib/schema/personal-page" +import { Button } from "@/components/ui/button" +import { LaIcon } from "@/components/custom/la-icon" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { usePageActions } from "~/hooks/actions/use-page-actions" +import { icons } from "lucide-react" + +type SortOption = "title" | "recent" +type ShowOption = 5 | 10 | 15 | 20 | 0 + +interface Option { + label: string + value: T +} + +const SORTS: Option[] = [ + { label: "Title", value: "title" }, + { label: "Last edited", value: "recent" }, +] + +const SHOWS: Option[] = [ + { label: "5 items", value: 5 }, + { label: "10 items", value: 10 }, + { label: "15 items", value: 15 }, + { label: "20 items", value: 20 }, + { label: "All", value: 0 }, +] + +const pageSortAtom = atomWithStorage("pageSort", "title") +const pageShowAtom = atomWithStorage("pageShow", 5) + +export const PageSection: React.FC = () => { + const { me } = useAccount({ + root: { + personalPages: [], + }, + }) + const [sort] = useAtom(pageSortAtom) + const [show] = useAtom(pageShowAtom) + + if (!me) return null + + const pageCount = me.root.personalPages?.length || 0 + + return ( +
+ + +
+ ) +} + +interface PageSectionHeaderProps { + pageCount: number +} + +const PageSectionHeader: React.FC = ({ pageCount }) => ( + +
+

+ Pages + {pageCount > 0 && ( + {pageCount} + )} +

+
+ + +
+
+ +) + +const NewPageButton: React.FC = () => { + const { me } = useAccount() + const navigate = useNavigate() + const { newPage } = usePageActions() + + const handleClick = async (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + const page = newPage(me) + + if (page.id) { + navigate({ + to: "/pages/$pageId", + params: { pageId: page.id }, + replace: true, + }) + } + } + + return ( + + ) +} + +interface PageListProps { + personalPages: PersonalPageLists + sort: SortOption + show: ShowOption +} + +const PageList: React.FC = ({ personalPages, sort, show }) => { + const sortedPages = React.useMemo(() => { + return [...personalPages] + .sort((a, b) => { + if (sort === "title") { + return (a?.title ?? "").localeCompare(b?.title ?? "") + } + return (b?.updatedAt?.getTime() ?? 0) - (a?.updatedAt?.getTime() ?? 0) + }) + .slice(0, show === 0 ? personalPages.length : show) + }, [personalPages, sort, show]) + + return ( +
+ {sortedPages.map( + (page) => page?.id && , + )} +
+ ) +} + +interface PageListItemProps { + page: PersonalPage +} + +const PageListItem: React.FC = ({ page }) => { + return ( +
+
+ +
+ +

+ {page.title || "Untitled"} +

+
+ +
+
+ ) +} + +interface SubMenuProps { + icon: keyof typeof icons + label: string + options: Option[] + currentValue: T + onSelect: (value: T) => void +} + +const SubMenu = ({ + icon, + label, + options, + currentValue, + onSelect, +}: SubMenuProps) => ( + + + + + {label} + + + + {options.find((option) => option.value === currentValue)?.label} + + + + + + + {options.map((option) => ( + onSelect(option.value)} + > + {option.label} + {currentValue === option.value && ( + + )} + + ))} + + + +) + +const ShowAllForm: React.FC = () => { + const [pagesSorted, setPagesSorted] = useAtom(pageSortAtom) + const [pagesShow, setPagesShow] = useAtom(pageShowAtom) + + return ( + + + + + + + + + + + + ) +} diff --git a/web/app/components/sidebar/partials/profile-section.tsx b/web/app/components/sidebar/partials/profile-section.tsx new file mode 100644 index 00000000..269b3de7 --- /dev/null +++ b/web/app/components/sidebar/partials/profile-section.tsx @@ -0,0 +1,186 @@ +import * as React from "react" +import { useAtom } from "jotai" +import { icons } from "lucide-react" +import { LaIcon } from "@/components/custom/la-icon" +import { DiscordIcon } from "@/components/icons/discord-icon" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Avatar, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { showShortcutAtom } from "@/components/shortcut/shortcut" +import { useKeyboardManager } from "@/hooks/use-keyboard-manager" +import { SignInButton, useAuth, useUser } from "@clerk/tanstack-start" +import { Link, useLocation } from "@tanstack/react-router" +import { ShortcutKey } from "@shared/minimal-tiptap/components/shortcut-key" +import { Feedback } from "./feedback" + +export const ProfileSection: React.FC = () => { + const { user, isSignedIn } = useUser() + const { signOut } = useAuth() + const [menuOpen, setMenuOpen] = React.useState(false) + const { pathname } = useLocation() + const [, setShowShortcut] = useAtom(showShortcutAtom) + + const { disableKeydown } = useKeyboardManager("profileSection") + + React.useEffect(() => { + disableKeydown(menuOpen) + }, [menuOpen, disableKeydown]) + + if (!isSignedIn) { + return ( +
+ + + +
+ ) + } + + return ( +
+
+ + +
+
+ ) +} + +interface ProfileDropdownProps { + user: any + menuOpen: boolean + setMenuOpen: (open: boolean) => void + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const ProfileDropdown: React.FC = ({ + user, + menuOpen, + setMenuOpen, + signOut, + setShowShortcut, +}) => ( +
+ + + + + + + + +
+) + +interface DropdownMenuItemsProps { + signOut: () => void + setShowShortcut: (show: boolean) => void +} + +const DropdownMenuItems: React.FC = ({ + signOut, + setShowShortcut, +}) => ( + <> + + setShowShortcut(true)}> + + Shortcut + + + + + + + + +
+ + Log out +
+ +
+
+
+ +) + +interface MenuLinkProps { + href: string + icon: keyof typeof icons | React.FC + text: string + iconClass?: string +} + +const MenuLink: React.FC = ({ + href, + icon, + text, + iconClass = "", +}) => { + const IconComponent = typeof icon === "string" ? icons[icon] : icon + return ( + + +
+ + {text} +
+ +
+ ) +} + +export default ProfileSection diff --git a/web/app/components/sidebar/partials/task-section.tsx b/web/app/components/sidebar/partials/task-section.tsx new file mode 100644 index 00000000..62266b92 --- /dev/null +++ b/web/app/components/sidebar/partials/task-section.tsx @@ -0,0 +1,110 @@ +import { cn } from "@/lib/utils" +import { useEffect, useState } from "react" +import { isToday, isFuture } from "date-fns" +import { useAccount } from "@/lib/providers/jazz-provider" +import { useAuth, useUser } from "@clerk/tanstack-start" +import { getFeatureFlag } from "~/actions" +import { LaIcon } from "~/components/custom/la-icon" +import { Link } from "@tanstack/react-router" + +export const TaskSection: React.FC = () => { + const { me } = useAccount({ root: { tasks: [] } }) + + const taskCount = me?.root?.tasks?.length || 0 + const todayTasks = + me?.root?.tasks?.filter( + (task) => + task?.status !== "done" && task?.dueDate && isToday(task.dueDate), + ) || [] + const upcomingTasks = + me?.root?.tasks?.filter( + (task) => + task?.status !== "done" && task?.dueDate && isFuture(task.dueDate), + ) || [] + + const [, setIsFetching] = useState(false) + const [isFeatureActive, setIsFeatureActive] = useState(false) + const { isLoaded, isSignedIn } = useAuth() + const { user } = useUser() + + useEffect(() => { + async function checkFeatureFlag() { + setIsFetching(true) + + if (isLoaded && isSignedIn) { + const response = await getFeatureFlag({ name: "TASK" }) + + if ( + user?.emailAddresses.some((email) => + response?.emails.includes(email.emailAddress), + ) + ) { + setIsFeatureActive(true) + } + setIsFetching(false) + } + } + + checkFeatureFlag() + }, [isLoaded, isSignedIn, user]) + + if (!isLoaded || !isSignedIn) { + return
Loading...
+ } + + if (!me) return null + + if (!isFeatureActive) { + return null + } + + return ( +
+ + + +
+ ) +} + +interface TaskSectionHeaderProps { + title: string + filter?: "today" | "upcoming" + count: number + iconName?: "BookOpenCheck" | "History" +} + +const TaskSectionHeader: React.FC = ({ + title, + filter, + count, + iconName, +}) => ( + + {iconName && } + +

+ {title} + {count > 0 && {count}} +

+ +) diff --git a/web/app/components/sidebar/partials/topic-section.tsx b/web/app/components/sidebar/partials/topic-section.tsx new file mode 100644 index 00000000..4ccd3867 --- /dev/null +++ b/web/app/components/sidebar/partials/topic-section.tsx @@ -0,0 +1,142 @@ +import * as React from "react" +import { useAccount } from "@/lib/providers/jazz-provider" +import { cn } from "@/lib/utils" +import { LaIcon } from "@/components/custom/la-icon" +import { ListOfTopics } from "@/lib/schema" +import { LEARNING_STATES, LearningStateValue } from "@/lib/constants" +import { Link } from "@tanstack/react-router" + +export const TopicSection: React.FC = () => { + const { me } = useAccount({ + root: { + topicsWantToLearn: [], + topicsLearning: [], + topicsLearned: [], + }, + }) + + const topicCount = + (me?.root.topicsWantToLearn?.length || 0) + + (me?.root.topicsLearning?.length || 0) + + (me?.root.topicsLearned?.length || 0) + + if (!me) return null + + return ( +
+ + +
+ ) +} + +interface TopicSectionHeaderProps { + topicCount: number +} + +const TopicSectionHeader: React.FC = ({ + topicCount, +}) => ( + +

+ Topics + {topicCount > 0 && ( + {topicCount} + )} +

+ +) + +interface ListProps { + topicsWantToLearn: ListOfTopics + topicsLearning: ListOfTopics + topicsLearned: ListOfTopics +} + +const List: React.FC = ({ + topicsWantToLearn, + topicsLearning, + topicsLearned, +}) => { + return ( +
+ + + +
+ ) +} + +interface ListItemProps { + label: string + value: LearningStateValue + count: number +} + +const ListItem: React.FC = ({ label, value, count }) => { + const le = LEARNING_STATES.find((l) => l.value === value) + + if (!le) return null + + return ( +
+
+ +
+ +

+ {label} +

+
+ + + {count > 0 && ( + + {count} + + )} +
+
+ ) +} diff --git a/web/app/components/sidebar/sidebar.tsx b/web/app/components/sidebar/sidebar.tsx new file mode 100644 index 00000000..fd40985e --- /dev/null +++ b/web/app/components/sidebar/sidebar.tsx @@ -0,0 +1,211 @@ +import * as React from "react" +import { useMedia } from "@/hooks/use-media" +import { useAtom } from "jotai" +import { LogoIcon } from "@/components/icons/logo-icon" +import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { isCollapseAtom } from "@/store/sidebar" +import { useAccountOrGuest } from "@/lib/providers/jazz-provider" +import { LaIcon } from "@/components/custom/la-icon" +import { Link, useLocation } from "@tanstack/react-router" + +import { LinkSection } from "./partials/link-section" +import { PageSection } from "./partials/page-section" +import { TopicSection } from "./partials/topic-section" +import { ProfileSection } from "./partials/profile-section" +import { JournalSection } from "./partials/journal-section" +import { TaskSection } from "./partials/task-section" + +interface SidebarContextType { + isCollapsed: boolean + setIsCollapsed: React.Dispatch> +} + +const SidebarContext = React.createContext({ + isCollapsed: false, + setIsCollapsed: () => {}, +}) + +const useSidebarCollapse = ( + isTablet: boolean, +): [boolean, React.Dispatch>] => { + const [isCollapsed, setIsCollapsed] = useAtom(isCollapseAtom) + const location = useLocation() + + React.useEffect(() => { + if (isTablet) setIsCollapsed(true) + }, [location.pathname, setIsCollapsed, isTablet]) + + React.useEffect(() => { + setIsCollapsed(isTablet) + }, [isTablet, setIsCollapsed]) + + return [isCollapsed, setIsCollapsed] +} + +interface SidebarItemProps { + label: string + url: string + icon?: React.ReactNode + onClick?: () => void + children?: React.ReactNode +} + +const SidebarItem: React.FC = React.memo( + ({ label, url, icon, onClick, children }) => { + const { pathname } = useLocation() + const isActive = pathname === url + + return ( +
+ + {icon && ( + + {icon} + + )} + {label} + {children} + +
+ ) + }, +) + +SidebarItem.displayName = "SidebarItem" + +const LogoAndSearch: React.FC = React.memo(() => { + const { pathname } = useLocation() + + return ( +
+
+ + + +
+ + {pathname === "/search" ? ( + "← Back" + ) : ( + + )} + +
+
+ ) +}) + +LogoAndSearch.displayName = "LogoAndSearch" + +const SidebarContent: React.FC = React.memo(() => { + const { me } = useAccountOrGuest() + + return ( + + ) +}) + +SidebarContent.displayName = "SidebarContent" + +const Sidebar: React.FC = () => { + const isTablet = useMedia("(max-width: 1024px)") + const [isCollapsed, setIsCollapsed] = useSidebarCollapse(isTablet) + + const sidebarClasses = cn( + "h-full overflow-hidden transition-all duration-300 ease-in-out", + isCollapsed ? "w-0" : "w-auto min-w-56", + ) + + const sidebarInnerClasses = cn( + "h-full w-56 min-w-56 transition-transform duration-300 ease-in-out", + isCollapsed ? "-translate-x-full" : "translate-x-0", + ) + + const contextValue = React.useMemo( + () => ({ isCollapsed, setIsCollapsed }), + [isCollapsed, setIsCollapsed], + ) + + if (isTablet) { + return ( + <> +
setIsCollapsed(true)} + /> +
+
+ + + +
+
+ + ) + } + + return ( +
+
+ + + +
+
+ ) +} + +Sidebar.displayName = "Sidebar" + +export { Sidebar, SidebarItem, SidebarContext } diff --git a/web/app/components/ui/alert-dialog.tsx b/web/app/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..bcc66f84 --- /dev/null +++ b/web/app/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +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< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/web/components/ui/avatar.tsx b/web/app/components/ui/avatar.tsx similarity index 96% rename from web/components/ui/avatar.tsx rename to web/app/components/ui/avatar.tsx index 51e507ba..d6e683ca 100644 --- a/web/components/ui/avatar.tsx +++ b/web/app/components/ui/avatar.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import * as AvatarPrimitive from "@radix-ui/react-avatar" @@ -13,7 +11,7 @@ const Avatar = React.forwardRef< ref={ref} className={cn( "relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", - className + className, )} {...props} /> @@ -40,7 +38,7 @@ const AvatarFallback = React.forwardRef< ref={ref} className={cn( "flex h-full w-full items-center justify-center rounded-full bg-muted", - className + className, )} {...props} /> diff --git a/web/app/components/ui/badge.tsx b/web/app/components/ui/badge.tsx new file mode 100644 index 00000000..64081126 --- /dev/null +++ b/web/app/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } 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", + }, + }, +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/web/app/components/ui/button.tsx b/web/app/components/ui/button.tsx new file mode 100644 index 00000000..8de34f43 --- /dev/null +++ b/web/app/components/ui/button.tsx @@ -0,0 +1,57 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center 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", + { + 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", + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + }, +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/web/app/components/ui/calendar.tsx b/web/app/components/ui/calendar.tsx new file mode 100644 index 00000000..5e0c825f --- /dev/null +++ b/web/app/components/ui/calendar.tsx @@ -0,0 +1,70 @@ +import * as React from "react" +import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md", + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100", + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: () => , + IconRight: () => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/web/app/components/ui/checkbox.tsx b/web/app/components/ui/checkbox.tsx new file mode 100644 index 00000000..5bffe19c --- /dev/null +++ b/web/app/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "@radix-ui/react-icons" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/web/app/components/ui/command.tsx b/web/app/components/ui/command.tsx new file mode 100644 index 00000000..568e4fd8 --- /dev/null +++ b/web/app/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { MagnifyingGlassIcon } from "@radix-ui/react-icons" +import { Command as CommandPrimitive } from "cmdk" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/web/app/components/ui/dialog.tsx b/web/app/components/ui/dialog.tsx new file mode 100644 index 00000000..ba019955 --- /dev/null +++ b/web/app/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { Cross2Icon } from "@radix-ui/react-icons" + +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< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + DialogPrimitive, +} diff --git a/web/app/components/ui/dropdown-menu.tsx b/web/app/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..96315959 --- /dev/null +++ b/web/app/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, DotFilledIcon } from "@radix-ui/react-icons" + +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< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/web/app/components/ui/form.tsx b/web/app/components/ui/form.tsx new file mode 100644 index 00000000..2aa31017 --- /dev/null +++ b/web/app/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +