From a3924100dd71606d9e4876a04cf0059470423600 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Mon, 24 Nov 2025 01:00:50 +0100 Subject: [PATCH] feat: migrate auth from supabase to better auth (#596) * feat: add README for @cio/db package and remove existingAccounts.json * load session, load profile and organization, add create profile hook * chore: cache session request The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies * feat: login, logout, onboarding, verify email, reset password. * feat: complete change email and email verification * feat: standardise how we make rpc calls and create org endpoints * fix ui errors * fix: handle potential undefined customerId and standardize avatar URL property * refactor: standardize custom domain handling and update telegram chat ID references * chore: add tailwind prefix to @cio/ui this fixed the media query issues because @cio/ui classes were overriding this dashboard tailwind build * chore: clean up org sidebar * chore: persist mode state * feat: implement order breadcrumbs * chore: upgrade to better auth 1.4 * chore: address common auth flow bugs and setup app for others to setup locally. --- .cursorignore | 45 + .gitignore | 2 + .markdownlint.json | 4 + .prettierignore | 3 +- .vscode/settings.json | 27 +- ARCHITECHTURE.md | 1626 +++ apps/api/.gitignore | 5 +- apps/api/fly.toml | 18 - apps/api/package.json | 13 +- apps/api/src/app.ts | 48 +- apps/api/src/config/env.ts | 6 +- apps/api/src/constants/index.ts | 6 + apps/api/src/index.ts | 8 +- apps/api/src/middlewares/auth.ts | 29 +- apps/api/src/middlewares/rate-limiter.ts | 2 +- apps/api/src/routes/account/account.ts | 49 + apps/api/src/routes/account/index.ts | 1 + apps/api/src/routes/course/clone.ts | 62 +- apps/api/src/routes/course/course.ts | 15 +- apps/api/src/routes/course/index.ts | 1 + apps/api/src/routes/course/katex.ts | 4 +- apps/api/src/routes/course/lesson.ts | 34 +- apps/api/src/routes/course/presign.ts | 4 +- apps/api/src/routes/mail.ts | 48 - apps/api/src/routes/mail/index.ts | 1 + apps/api/src/routes/mail/mail.ts | 31 + apps/api/src/routes/onboarding/index.ts | 1 + apps/api/src/routes/onboarding/onboarding.ts | 44 + apps/api/src/routes/organization/index.ts | 1 + .../src/routes/organization/organization.ts | 172 + apps/api/src/rpc-types.ts | 4 +- apps/api/src/services/account.ts | 59 + apps/api/src/services/index.ts | 3 + apps/api/src/services/mail.ts | 14 +- apps/api/src/services/onboarding.ts | 99 + apps/api/src/services/organization.ts | 165 + apps/api/src/types/auth.ts | 8 + apps/api/src/types/course/index.ts | 60 - apps/api/src/types/course/lesson.ts | 25 - apps/api/src/types/index.ts | 3 - apps/api/src/types/mail.ts | 13 - apps/api/src/utils/certificate.ts | 12 +- apps/api/src/utils/course.ts | 15 +- apps/api/src/utils/email.ts | 35 - apps/api/src/utils/errors.ts | 108 + apps/api/src/utils/hono.ts | 4 + apps/api/src/utils/lesson.ts | 17 +- apps/api/src/utils/mail.ts | 36 + apps/api/src/utils/openapi/index.ts | 3 +- apps/api/tsconfig.json | 28 +- apps/course-app/package.json | 2 +- .../components/navigation/navigation.svelte | 14 +- apps/course-app/tsconfig.json | 3 +- apps/dashboard/AUTH_FLOW.md | 81 + apps/dashboard/package.json | 11 +- apps/dashboard/postcss.config.js | 5 - apps/dashboard/src/app.css | 22 +- apps/dashboard/src/app.d.ts | 27 + apps/dashboard/src/hooks.server.ts | 75 +- .../components/Analytics/ActivityCard.svelte | 4 +- .../src/lib/components/AuthUI/index.svelte | 17 +- .../lib/components/Buttons/Close/index.svelte | 11 +- .../components/Lesson/Exercise/index.svelte | 7 +- .../Lesson/Exercises/NewExerciseModal.svelte | 2 +- .../components/Lesson/Exercises/index.svelte | 5 +- .../Lesson/LessonVersionHistory.svelte | 6 +- .../components/Lesson/Materials/index.svelte | 12 +- .../Course/components/Lesson/store/lessons.ts | 14 +- .../Course/components/Navigation/index.svelte | 16 +- .../components/People/InvitationModal.svelte | 2 +- .../Course/components/Settings/index.svelte | 4 +- .../src/lib/components/Course/store.ts | 2 +- .../components/CourseContainer/index.svelte | 4 +- .../components/PricingSection.svelte | 8 +- .../Courses/components/Card/Loader.svelte | 25 +- .../components/NewCourseModal/index.svelte | 6 +- .../src/lib/components/LMS/SideBar.svelte | 16 +- .../components/LMS/components/Profile.svelte | 4 +- .../src/lib/components/Modal/index.svelte | 12 +- .../src/lib/components/Modal/index.ts | 1 + .../components/Navigation/MobileMenu.svelte | 4 +- .../src/lib/components/Navigation/app.svelte | 43 +- .../lib/components/Navigation/index.svelte | 8 +- .../src/lib/components/Navigation/lms.svelte | 40 - .../lib/components/Org/Community/index.svelte | 2 +- .../lib/components/Org/FooterMenu/Menu.svelte | 14 - .../FooterMenu/components/LogoutButton.svelte | 40 - .../FooterMenu/components/NewsSection.svelte | 38 - .../components/Org/LandingPage/index.svelte | 4 +- .../components/Org/OrgFooter/OrgFooter.svelte | 43 - .../components/Org/Settings/Billing.svelte | 6 +- .../Org/Settings/CustomizeLms.svelte | 4 +- .../components/Org/Settings/Domains.svelte | 8 +- .../Org/Settings/Integrations.svelte | 10 +- .../Org/Settings/LandingpageSettings.svelte | 7 +- .../Org/Settings/LanguagePicker.svelte | 8 +- .../components/Org/Settings/Layout/index.ts | 3 + .../Org/Settings/OrgSettings.svelte | 10 +- .../components/Org/Settings/Profile.svelte | 176 +- .../Org/Settings/TeamMembers.svelte | 13 +- .../dashboard/src/lib/components/Org/store.ts | 4 - .../Sidebar/components/app-sidebar.svelte | 60 - .../Sidebar/components/nav-main.svelte | 52 - .../Sidebar/components/nav-projects.svelte | 82 - .../Sidebar/components/nav-user.svelte | 99 - .../Sidebar/components/team-switcher.svelte | 132 - .../components/Sidebar/components/types.ts | 8 - .../src/lib/components/Sidebar/index.svelte | 14 - .../src/lib/components/Upgrade/Modal.svelte | 20 +- .../lib/components/UploadImage/index.svelte | 2 +- .../src/lib/components/WelcomeModal/store.ts | 5 - .../src/lib/features/app/init.svelte.ts | 162 + .../src/lib/features/app/layout-setup.ts | 120 + apps/dashboard/src/lib/features/app/types.ts | 7 + .../lib/features/auth/api/forgot.svelte.ts | 37 + .../lib/features/auth/api/profile.svelte.ts | 207 + .../src/lib/features/auth/api/reset.svelte.ts | 47 + .../src/lib/features/auth/api}/types.ts | 0 .../src/lib/features/auth/utils/types.ts | 13 + .../src/lib/features/auth/utils/validation.ts | 19 + .../onboarding/api/onboarding.svelte.ts | 108 + .../features/onboarding/components/index.ts | 2 + .../components/verify-email-modal.svelte} | 33 +- .../components/welcome-modal.svelte} | 27 +- .../features/onboarding/utils/constants.ts | 59 + .../lib/features/onboarding/utils/types.ts | 19 + .../features/onboarding/utils/validations.ts | 26 + .../src/lib/features/org/api/index.ts | 2 + .../lib/features/org/api/org-plan.svelte.ts | 46 + .../src/lib/features/org/api/org.svelte.ts | 108 + .../src/lib/features/org/utils/types.ts | 25 + .../ui/coming-soon.svelte} | 12 +- apps/dashboard/src/lib/features/ui/index.ts | 3 + .../ui/navigation/app-breadcrumbs.svelte | 104 + .../features/ui/navigation/app-header.svelte | 42 + .../lib/features/ui/navigation/breadcrumb.ts | 108 + .../features/ui/navigation/org-navigation.ts | 162 + .../ui/page-load-progress.svelte} | 0 .../src/lib/features/ui/search.svelte | 16 + .../lib/features/ui/sidebar/footer/index.ts | 1 + .../features/ui/sidebar/footer/menu.svelte | 141 + .../ui/sidebar/footer/theme-toggle.svelte | 46 + .../ui/sidebar/org-sidebar/app-logo.svelte | 35 + .../features/ui/sidebar/org-sidebar/index.ts | 1 + .../ui/sidebar/org-sidebar/nav-main.svelte | 64 + .../ui/sidebar/org-sidebar/org-sidebar.svelte | 34 + .../sidebar/org-sidebar/org-switcher.svelte | 156 + .../org-sidebar/upgrade-trigger.svelte | 50 + .../ui/sidebar/sidebar-skeleton.svelte | 49 + .../src/lib/utils/constants/authentication.ts | 9 - .../src/lib/utils/constants/roles.ts | 6 +- .../src/lib/utils/constants/routes.ts | 14 +- .../src/lib/utils/constants/translation.ts | 24 +- apps/dashboard/src/lib/utils/functions/app.ts | 17 +- .../src/lib/utils/functions/appSetup.ts | 209 +- .../src/lib/utils/functions/logout.ts | 14 +- apps/dashboard/src/lib/utils/functions/org.ts | 7 +- .../utils/functions/routes/isPublicRoute.ts | 10 +- .../functions/routes/shouldRedirectOnAuth.ts | 2 +- .../src/lib/utils/functions/supabase.ts | 2 + .../src/lib/utils/functions/translations.ts | 8 +- .../src/lib/utils/functions/validator.ts | 81 +- .../src/lib/utils/services/api/base.svelte.ts | 141 + .../src/lib/utils/services/api/index.ts | 37 +- .../src/lib/utils/services/api/types.ts | 10 +- .../src/lib/utils/services/api/utils.ts | 17 + .../src/lib/utils/services/auth/client.ts | 9 + .../src/lib/utils/services/auth/session.ts | 50 + .../services/notification/notification.ts | 4 - .../src/lib/utils/services/org/index.ts | 3 +- apps/dashboard/src/lib/utils/store/org.ts | 58 +- apps/dashboard/src/lib/utils/store/user.ts | 54 +- .../src/lib/utils/translations/da.json | 15 + .../src/lib/utils/translations/de.json | 15 + .../src/lib/utils/translations/en.json | 23 +- .../src/lib/utils/translations/es.json | 15 + .../src/lib/utils/translations/fr.json | 15 + .../src/lib/utils/translations/hi.json | 15 + .../src/lib/utils/translations/pl.json | 15 + .../src/lib/utils/translations/pt.json | 15 + .../src/lib/utils/translations/ru.json | 15 + .../src/lib/utils/translations/vi.json | 15 + apps/dashboard/src/lib/utils/types/index.ts | 15 +- apps/dashboard/src/lib/utils/types/sidebar.ts | 22 - .../src/lib/utils/validation/USAGE.md | 287 + .../src/lib/utils/validation/index.ts | 7 + .../lib/utils/validation/validation.test.ts | 299 + .../src/lib/utils/validation/validation.ts | 206 + apps/dashboard/src/mail/sendEmail.ts | 2 +- apps/dashboard/src/routes/+layout.server.ts | 128 +- apps/dashboard/src/routes/+layout.svelte | 160 +- apps/dashboard/src/routes/+layout.ts | 6 +- apps/dashboard/src/routes/+page.svelte | 116 +- .../routes/api/email/verify_email/+server.ts | 42 - .../src/routes/api/email/welcome/+server.ts | 42 - .../src/routes/api/verify/+server.ts | 29 - .../src/routes/courses/[id]/+layout.svelte | 23 - .../src/routes/courses/[id]/+page.svelte | 2 +- .../courses/[id]/attendance/+page.svelte | 2 +- .../lessons/[...lessonParams]/+page.svelte | 4 +- .../routes/courses/[id]/people/+page.svelte | 2 +- .../[id]/people/[personId]/+page.svelte | 2 +- apps/dashboard/src/routes/forgot/+page.svelte | 56 +- .../src/routes/invite/s/[hash]/+page.svelte | 10 +- apps/dashboard/src/routes/login/+page.svelte | 46 +- .../src/routes/onboarding/+page.svelte | 249 +- .../src/routes/org/[slug]/+layout.server.ts | 16 +- .../src/routes/org/[slug]/+layout.svelte | 163 +- .../src/routes/org/[slug]/+page.svelte | 8 +- .../routes/org/[slug]/audience/+page.svelte | 4 +- .../[slug]/audience/[...params]/+page.svelte | 8 +- .../routes/org/[slug]/community/+page.svelte | 24 +- .../routes/org/[slug]/courses/+page.svelte | 88 +- .../src/routes/org/[slug]/setup/+page.svelte | 12 +- apps/dashboard/src/routes/reset/+page.svelte | 92 +- apps/dashboard/src/routes/signup/+page.svelte | 102 +- apps/dashboard/svelte.config.js | 15 +- apps/dashboard/tsconfig.json | 4 +- apps/dashboard/vite.config.ts | 5 +- apps/dashboard/vitest.config.ts | 20 + apps/docs/tsconfig.json | 3 +- .../src/lib/Navigation/Navigation.svelte | 6 +- apps/website/src/routes/+layout.svelte | 6 +- cypress/e2e/dashboard/authentication.cy.js | 32 - cypress/fixtures/example.json | 5 - cypress/support/commands.js | 25 - cypress/support/e2e.js | 20 - package.json | 11 +- .../ui/_custom/SideBarExpandable.svelte | 4 +- .../components/ui/_custom/Transition.svelte | 6 +- .../src/template/src/routes/+layout.svelte | 6 +- .../[slug]/[section]/[filename]/+page.svelte | 11 +- packages/course-app/tsconfig.json | 1 + packages/db/.gitignore | 8 + packages/db/README.md | 35 + packages/db/existingAccounts.json | 1 - packages/db/package.json | 24 +- packages/db/scripts/generate-types.ts | 127 + packages/db/src/auth-schema.ts | 67 - packages/db/src/auth.ts | 63 +- packages/db/src/auth/email-password.ts | 68 + packages/db/src/auth/email-verification.ts | 64 + packages/db/src/auth/hooks/create-profile.ts | 51 + packages/db/src/auth/hooks/index.ts | 26 + packages/db/src/auth/hooks/sync-user.ts | 26 + packages/db/src/constants.ts | 7 + packages/db/src/drizzle.ts | 10 +- packages/db/src/index.ts | 6 + .../db/src/migrations/0002_many_revanche.sql | 22 + .../db/src/migrations/meta/0000_snapshot.json | 1005 +- .../db/src/migrations/meta/0001_snapshot.json | 1029 +- .../db/src/migrations/meta/0002_snapshot.json | 4511 +++++++ packages/db/src/migrations/meta/_journal.json | 9 +- packages/db/src/queries/auth/auth.ts | 21 + packages/db/src/queries/auth/index.ts | 2 + packages/db/src/queries/auth/profile.ts | 17 + .../db/src/queries/course/.gitkeep | 0 packages/db/src/queries/course/course.ts | 50 + packages/db/src/queries/course/index.ts | 1 + packages/db/src/queries/index.ts | 3 + packages/db/src/queries/organization/index.ts | 1 + .../src/queries/organization/organization.ts | 307 + packages/db/src/queries/organization/types.ts | 14 + packages/db/src/relations.ts | 4 +- packages/db/src/schema.ts | 107 +- packages/db/src/scripts/seed.ts | 104 + packages/db/src/types.ts | 139 + packages/db/tsconfig.json | 19 +- packages/email/.gitignore | 8 + packages/email/README.md | 181 + packages/email/package.json | 42 + packages/email/src/config/env.ts | 12 + packages/email/src/core/base.ts | 25 + packages/email/src/core/registry.ts | 43 + packages/email/src/core/types.ts | 51 + packages/email/src/emails/forgot-password.ts | 28 + packages/email/src/emails/index.ts | 31 + .../email/src/emails/on-password-reset.ts | 17 + packages/email/src/emails/verify-email.ts | 40 + packages/email/src/emails/welcome.ts | 32 + packages/email/src/index.ts | 36 + packages/email/src/send.ts | 141 + packages/email/src/templates/default.ts | 191 + packages/email/src/templates/index.ts | 1 + packages/email/src/utils/constants.ts | 4 + .../src/utils/functions/email-helpers.ts | 18 + .../email/src/utils/services/nodemailer.ts | 65 + .../email/src/utils/services/zeptomail.ts | 69 + packages/email/src/utils/types.ts | 24 + packages/email/tsconfig.json | 25 + packages/storybook/tsconfig.json | 4 +- packages/ui/add-ui-prefix.cjs | 333 + packages/ui/components.json | 2 +- packages/ui/package.json | 12 +- .../base/accordion/accordion-content.svelte | 30 +- .../src/base/accordion/accordion-item.svelte | 18 +- .../base/accordion/accordion-trigger.svelte | 54 +- .../ui/src/base/accordion/accordion.svelte | 15 +- packages/ui/src/base/accordion/index.ts | 26 +- .../ui/src/base/avatar/avatar-fallback.svelte | 2 +- .../ui/src/base/avatar/avatar-image.svelte | 2 +- packages/ui/src/base/avatar/avatar.svelte | 2 +- packages/ui/src/base/avatar/index.ts | 20 +- packages/ui/src/base/badge/badge.svelte | 10 +- packages/ui/src/base/badge/index.ts | 4 +- .../breadcrumb/breadcrumb-ellipsis.svelte | 4 +- .../base/breadcrumb/breadcrumb-item.svelte | 21 +- .../base/breadcrumb/breadcrumb-link.svelte | 2 +- .../base/breadcrumb/breadcrumb-list.svelte | 5 +- .../base/breadcrumb/breadcrumb-page.svelte | 2 +- .../breadcrumb/breadcrumb-separator.svelte | 2 +- packages/ui/src/base/breadcrumb/index.ts | 44 +- packages/ui/src/base/button/button.svelte | 26 +- .../ui/src/base/chart/chart-container.svelte | 2 +- packages/ui/src/base/chart/chart-style.svelte | 50 +- .../ui/src/base/chart/chart-tooltip.svelte | 29 +- packages/ui/src/base/chart/chart-utils.ts | 75 +- packages/ui/src/base/checkbox/checkbox.svelte | 8 +- packages/ui/src/base/checkbox/index.ts | 8 +- .../collapsible/collapsible-content.svelte | 4 +- .../collapsible/collapsible-trigger.svelte | 4 +- .../src/base/collapsible/collapsible.svelte | 8 +- packages/ui/src/base/collapsible/index.ts | 20 +- .../ui/src/base/command/command-dialog.svelte | 4 +- .../ui/src/base/command/command-empty.svelte | 2 +- .../ui/src/base/command/command-group.svelte | 4 +- .../ui/src/base/command/command-input.svelte | 6 +- .../ui/src/base/command/command-item.svelte | 2 +- .../src/base/command/command-link-item.svelte | 2 +- .../ui/src/base/command/command-list.svelte | 2 +- .../src/base/command/command-separator.svelte | 2 +- .../src/base/command/command-shortcut.svelte | 2 +- packages/ui/src/base/command/command.svelte | 5 +- packages/ui/src/base/command/index.ts | 68 +- .../ui/src/base/dark-mode/dark-mode.svelte | 8 +- packages/ui/src/base/dark-mode/index.ts | 2 +- .../ui/src/base/dialog/dialog-close.svelte | 4 +- .../ui/src/base/dialog/dialog-content.svelte | 4 +- .../src/base/dialog/dialog-description.svelte | 2 +- .../ui/src/base/dialog/dialog-footer.svelte | 2 +- .../ui/src/base/dialog/dialog-header.svelte | 2 +- .../ui/src/base/dialog/dialog-overlay.svelte | 2 +- .../ui/src/base/dialog/dialog-title.svelte | 2 +- .../ui/src/base/dialog/dialog-trigger.svelte | 4 +- packages/ui/src/base/dialog/index.ts | 60 +- .../ui/src/base/drawer/drawer-close.svelte | 7 + .../ui/src/base/drawer/drawer-content.svelte | 37 + .../src/base/drawer/drawer-description.svelte | 13 + .../ui/src/base/drawer/drawer-footer.svelte | 20 + .../ui/src/base/drawer/drawer-header.svelte | 20 + .../ui/src/base/drawer/drawer-nested.svelte | 12 + .../ui/src/base/drawer/drawer-overlay.svelte | 16 + .../ui/src/base/drawer/drawer-title.svelte | 13 + .../ui/src/base/drawer/drawer-trigger.svelte | 7 + packages/ui/src/base/drawer/drawer.svelte | 12 + packages/ui/src/base/drawer/index.ts | 41 + .../dropdown-menu-checkbox-item.svelte | 8 +- .../dropdown-menu-content.svelte | 2 +- .../dropdown-menu-group-heading.svelte | 2 +- .../dropdown-menu/dropdown-menu-group.svelte | 4 +- .../dropdown-menu/dropdown-menu-item.svelte | 2 +- .../dropdown-menu/dropdown-menu-label.svelte | 2 +- .../dropdown-menu-radio-group.svelte | 15 +- .../dropdown-menu-radio-item.svelte | 6 +- .../dropdown-menu-separator.svelte | 2 +- .../dropdown-menu-shortcut.svelte | 2 +- .../dropdown-menu-sub-content.svelte | 2 +- .../dropdown-menu-sub-trigger.svelte | 4 +- .../dropdown-menu-trigger.svelte | 4 +- packages/ui/src/base/dropdown-menu/index.ts | 88 +- packages/ui/src/base/input-group/index.ts | 22 + .../base/input-group/input-group-addon.svelte | 54 + .../input-group/input-group-button.svelte | 49 + .../base/input-group/input-group-input.svelte | 23 + .../base/input-group/input-group-text.svelte | 22 + .../input-group/input-group-textarea.svelte | 23 + .../src/base/input-group/input-group.svelte | 38 + packages/ui/src/base/input/input.svelte | 6 +- packages/ui/src/base/item/index.ts | 34 + packages/ui/src/base/item/item-actions.svelte | 15 + packages/ui/src/base/item/item-content.svelte | 20 + .../ui/src/base/item/item-description.svelte | 24 + packages/ui/src/base/item/item-footer.svelte | 20 + packages/ui/src/base/item/item-group.svelte | 21 + packages/ui/src/base/item/item-header.svelte | 20 + packages/ui/src/base/item/item-media.svelte | 42 + .../ui/src/base/item/item-separator.svelte | 15 + packages/ui/src/base/item/item-title.svelte | 20 + packages/ui/src/base/item/item.svelte | 60 + packages/ui/src/base/kbd/index.ts | 10 + packages/ui/src/base/kbd/kbd-group.svelte | 20 + packages/ui/src/base/kbd/kbd.svelte | 25 + packages/ui/src/base/label/index.ts | 8 +- packages/ui/src/base/label/label.svelte | 2 +- packages/ui/src/base/menubar/index.ts | 94 +- .../base/menubar/menubar-checkbox-item.svelte | 8 +- .../src/base/menubar/menubar-content.svelte | 2 +- .../base/menubar/menubar-group-heading.svelte | 2 +- .../ui/src/base/menubar/menubar-group.svelte | 14 +- .../ui/src/base/menubar/menubar-item.svelte | 2 +- .../ui/src/base/menubar/menubar-label.svelte | 2 +- .../base/menubar/menubar-radio-item.svelte | 6 +- .../src/base/menubar/menubar-separator.svelte | 2 +- .../src/base/menubar/menubar-shortcut.svelte | 2 +- .../base/menubar/menubar-sub-content.svelte | 2 +- .../base/menubar/menubar-sub-trigger.svelte | 4 +- .../src/base/menubar/menubar-trigger.svelte | 2 +- packages/ui/src/base/menubar/menubar.svelte | 5 +- packages/ui/src/base/pagination/index.ts | 44 +- .../base/pagination/pagination-content.svelte | 2 +- .../pagination/pagination-ellipsis.svelte | 4 +- .../pagination/pagination-next-button.svelte | 4 +- .../pagination/pagination-prev-button.svelte | 4 +- .../ui/src/base/pagination/pagination.svelte | 2 +- packages/ui/src/base/popover/index.ts | 24 +- .../src/base/popover/popover-content.svelte | 2 +- packages/ui/src/base/progress/index.ts | 8 +- packages/ui/src/base/progress/progress.svelte | 4 +- packages/ui/src/base/radio-group/index.ts | 14 +- .../base/radio-group/radio-group-item.svelte | 8 +- .../src/base/radio-group/radio-group.svelte | 2 +- packages/ui/src/base/select/index.ts | 62 +- .../ui/src/base/select/select-content.svelte | 6 +- .../base/select/select-group-heading.svelte | 2 +- .../ui/src/base/select/select-group.svelte | 4 +- .../ui/src/base/select/select-item.svelte | 6 +- .../ui/src/base/select/select-label.svelte | 2 +- .../select/select-scroll-down-button.svelte | 4 +- .../select/select-scroll-up-button.svelte | 4 +- .../src/base/select/select-separator.svelte | 2 +- .../ui/src/base/select/select-trigger.svelte | 4 +- .../ui/src/base/separator/separator.svelte | 2 +- packages/ui/src/base/sheet/index.ts | 60 +- packages/ui/src/base/sheet/sheet-close.svelte | 4 +- .../ui/src/base/sheet/sheet-content.svelte | 14 +- .../src/base/sheet/sheet-description.svelte | 2 +- .../ui/src/base/sheet/sheet-footer.svelte | 7 +- .../ui/src/base/sheet/sheet-header.svelte | 7 +- .../ui/src/base/sheet/sheet-overlay.svelte | 2 +- packages/ui/src/base/sheet/sheet-title.svelte | 2 +- .../ui/src/base/sheet/sheet-trigger.svelte | 4 +- packages/ui/src/base/sidebar/constants.ts | 10 +- packages/ui/src/base/sidebar/index.ts | 144 +- .../src/base/sidebar/sidebar-content.svelte | 2 +- .../ui/src/base/sidebar/sidebar-footer.svelte | 2 +- .../base/sidebar/sidebar-group-action.svelte | 6 +- .../base/sidebar/sidebar-group-content.svelte | 2 +- .../base/sidebar/sidebar-group-label.svelte | 4 +- .../ui/src/base/sidebar/sidebar-group.svelte | 2 +- .../ui/src/base/sidebar/sidebar-header.svelte | 2 +- .../ui/src/base/sidebar/sidebar-input.svelte | 2 +- .../ui/src/base/sidebar/sidebar-inset.svelte | 4 +- .../base/sidebar/sidebar-menu-action.svelte | 14 +- .../base/sidebar/sidebar-menu-badge.svelte | 12 +- .../base/sidebar/sidebar-menu-button.svelte | 14 +- .../src/base/sidebar/sidebar-menu-item.svelte | 2 +- .../base/sidebar/sidebar-menu-skeleton.svelte | 6 +- .../sidebar/sidebar-menu-sub-button.svelte | 10 +- .../base/sidebar/sidebar-menu-sub-item.svelte | 2 +- .../src/base/sidebar/sidebar-menu-sub.svelte | 4 +- .../ui/src/base/sidebar/sidebar-menu.svelte | 2 +- .../src/base/sidebar/sidebar-provider.svelte | 7 +- .../ui/src/base/sidebar/sidebar-rail.svelte | 14 +- .../src/base/sidebar/sidebar-separator.svelte | 2 +- .../src/base/sidebar/sidebar-trigger.svelte | 4 +- packages/ui/src/base/sidebar/sidebar.svelte | 35 +- packages/ui/src/base/skeleton/index.ts | 8 +- packages/ui/src/base/skeleton/skeleton.svelte | 2 +- packages/ui/src/base/sonner/sonner.svelte | 14 +- packages/ui/src/base/spinner/index.ts | 2 +- packages/ui/src/base/spinner/spinner.svelte | 2 +- packages/ui/src/base/switch/index.ts | 8 +- packages/ui/src/base/switch/switch.svelte | 4 +- packages/ui/src/base/table/index.ts | 50 +- packages/ui/src/base/table/table-body.svelte | 2 +- .../ui/src/base/table/table-caption.svelte | 2 +- packages/ui/src/base/table/table-cell.svelte | 2 +- .../ui/src/base/table/table-footer.svelte | 2 +- packages/ui/src/base/table/table-head.svelte | 2 +- .../ui/src/base/table/table-header.svelte | 2 +- packages/ui/src/base/table/table-row.svelte | 2 +- packages/ui/src/base/table/table.svelte | 4 +- packages/ui/src/base/tabs/index.ts | 26 +- packages/ui/src/base/tabs/tabs-content.svelte | 18 +- packages/ui/src/base/tabs/tabs-list.svelte | 24 +- packages/ui/src/base/tabs/tabs-trigger.svelte | 24 +- packages/ui/src/base/tabs/tabs.svelte | 26 +- packages/ui/src/base/textarea/index.ts | 7 + packages/ui/src/base/textarea/textarea.svelte | 23 + packages/ui/src/base/tooltip/index.ts | 28 +- .../src/base/tooltip/tooltip-content.svelte | 12 +- .../src/base/tooltip/tooltip-trigger.svelte | 4 +- packages/ui/src/custom/editor/USAGE.md | 63 +- .../ui/src/custom/editor/commands/types.ts | 14 +- .../editor/components/BubbleMenu.svelte | 103 +- .../editor/components/MediaPlaceHolder.svelte | 44 +- packages/ui/src/custom/editor/editor.css | 446 +- packages/ui/src/custom/editor/editor.svelte | 11 +- .../editor/extensions/FindAndReplace.ts | 565 +- .../editor/extensions/audio/AudioExtension.ts | 229 +- .../extensions/audio/AudioPlaceholder.ts | 102 +- .../drag-handle/ClipboardSerializer.ts | 32 +- .../custom/editor/extensions/iframe/IFrame.ts | 132 +- .../extensions/iframe/IFramePlaceholder.ts | 86 +- .../editor/extensions/image/ImageExtended.ts | 58 +- .../extensions/image/ImagePlaceholder.ts | 102 +- .../editor/extensions/table/table-row.ts | 4 +- .../custom/editor/extensions/table/table.ts | 6 +- .../editor/extensions/video/VideoExtension.ts | 229 +- .../extensions/video/VideoPlaceholder.ts | 98 +- packages/ui/src/custom/editor/onedark.css | 172 +- .../ui/src/custom/editor/svelte-renderer.ts | 110 +- .../editor/ui/components/AudioExtended.svelte | 19 +- .../editor/ui/components/CodeBlock.svelte | 12 +- .../editor/ui/components/EdraToolTip.svelte | 2 +- .../ui/components/IFrameExtended.svelte | 12 +- .../editor/ui/components/ImageExtended.svelte | 24 +- .../editor/ui/components/MediaExtended.svelte | 40 +- .../ui/components/SlashCommandList.svelte | 6 +- .../editor/ui/components/VideoExtended.svelte | 22 +- .../ui/components/toolbar/Alignment.svelte | 4 +- .../ui/components/toolbar/FontSize.svelte | 6 +- .../ui/components/toolbar/Headings.svelte | 4 +- .../ui/components/toolbar/QuickColors.svelte | 14 +- .../toolbar/SearchAndReplace.svelte | 23 +- .../editor/ui/drag-handle-extended.svelte | 4 +- .../ui/src/custom/editor/ui/editor.svelte | 2 +- .../ui/src/custom/editor/ui/menus/Link.svelte | 8 +- .../ui/src/custom/editor/ui/menus/Menu.svelte | 5 +- .../custom/editor/ui/menus/TableCol.svelte | 2 +- .../custom/editor/ui/menus/TableRow.svelte | 2 +- packages/ui/src/custom/editor/ui/style.css | 34 +- packages/ui/src/hooks/is-mobile.svelte.ts | 8 +- packages/ui/src/index.css | 99 +- packages/ui/src/index.ts | 39 +- packages/ui/src/output.css | 2 +- packages/ui/src/tools/index.ts | 10 +- packages/ui/tsconfig.json | 4 +- packages/utils/.prettierignore | 4 + packages/utils/README.md | 3 + packages/utils/package.json | 45 +- packages/utils/src/constants/index.ts | 2 + packages/utils/src/constants/org.ts | 34 + packages/utils/src/constants/roles.ts | 5 + packages/utils/src/index.ts | 5 + packages/utils/src/plans/constants.ts | 4 +- packages/utils/src/plans/data.json | 7 +- packages/utils/src/plans/index.ts | 2 +- packages/utils/src/validation/README.md | 356 + .../utils/src/validation/account/account.ts | 25 + .../utils/src/validation/account/index.ts | 1 + packages/utils/src/validation/constants.ts | 14 + .../utils/src/validation/course/course.ts | 67 + packages/utils/src/validation/course/index.ts | 8 + packages/utils/src/validation/index.ts | 7 + packages/utils/src/validation/mail/index.ts | 8 + packages/utils/src/validation/mail/mail.ts | 13 + .../utils/src/validation/onboarding/index.ts | 1 + .../src/validation/onboarding/onboarding.ts | 27 + .../src/validation/organization/index.ts | 1 + .../validation/organization/organization.ts | 51 + packages/utils/tsconfig.json | 15 +- pnpm-lock.yaml | 10048 ++++++---------- todo/api.md | 22 - todo/v1.md | 29 - tsconfig.json | 13 +- turbo.json | 2 +- 567 files changed, 22364 insertions(+), 13437 deletions(-) create mode 100644 .cursorignore create mode 100644 .markdownlint.json create mode 100644 ARCHITECHTURE.md delete mode 100644 apps/api/fly.toml create mode 100644 apps/api/src/routes/account/account.ts create mode 100644 apps/api/src/routes/account/index.ts create mode 100644 apps/api/src/routes/course/index.ts delete mode 100644 apps/api/src/routes/mail.ts create mode 100644 apps/api/src/routes/mail/index.ts create mode 100644 apps/api/src/routes/mail/mail.ts create mode 100644 apps/api/src/routes/onboarding/index.ts create mode 100644 apps/api/src/routes/onboarding/onboarding.ts create mode 100644 apps/api/src/routes/organization/index.ts create mode 100644 apps/api/src/routes/organization/organization.ts create mode 100644 apps/api/src/services/account.ts create mode 100644 apps/api/src/services/index.ts create mode 100644 apps/api/src/services/onboarding.ts create mode 100644 apps/api/src/services/organization.ts create mode 100644 apps/api/src/types/auth.ts delete mode 100644 apps/api/src/types/course/index.ts delete mode 100644 apps/api/src/types/course/lesson.ts delete mode 100644 apps/api/src/types/mail.ts delete mode 100644 apps/api/src/utils/email.ts create mode 100644 apps/api/src/utils/errors.ts create mode 100644 apps/api/src/utils/hono.ts create mode 100644 apps/dashboard/AUTH_FLOW.md delete mode 100644 apps/dashboard/postcss.config.js create mode 100644 apps/dashboard/src/app.d.ts create mode 100644 apps/dashboard/src/lib/components/Modal/index.ts delete mode 100644 apps/dashboard/src/lib/components/Org/FooterMenu/Menu.svelte delete mode 100644 apps/dashboard/src/lib/components/Org/FooterMenu/components/LogoutButton.svelte delete mode 100644 apps/dashboard/src/lib/components/Org/FooterMenu/components/NewsSection.svelte delete mode 100644 apps/dashboard/src/lib/components/Org/OrgFooter/OrgFooter.svelte create mode 100644 apps/dashboard/src/lib/components/Org/Settings/Layout/index.ts delete mode 100644 apps/dashboard/src/lib/components/Sidebar/components/app-sidebar.svelte delete mode 100644 apps/dashboard/src/lib/components/Sidebar/components/nav-main.svelte delete mode 100644 apps/dashboard/src/lib/components/Sidebar/components/nav-projects.svelte delete mode 100644 apps/dashboard/src/lib/components/Sidebar/components/nav-user.svelte delete mode 100644 apps/dashboard/src/lib/components/Sidebar/components/team-switcher.svelte delete mode 100644 apps/dashboard/src/lib/components/Sidebar/components/types.ts delete mode 100644 apps/dashboard/src/lib/components/Sidebar/index.svelte delete mode 100644 apps/dashboard/src/lib/components/WelcomeModal/store.ts create mode 100644 apps/dashboard/src/lib/features/app/init.svelte.ts create mode 100644 apps/dashboard/src/lib/features/app/layout-setup.ts create mode 100644 apps/dashboard/src/lib/features/app/types.ts create mode 100644 apps/dashboard/src/lib/features/auth/api/forgot.svelte.ts create mode 100644 apps/dashboard/src/lib/features/auth/api/profile.svelte.ts create mode 100644 apps/dashboard/src/lib/features/auth/api/reset.svelte.ts rename {packages/utils/src/plans => apps/dashboard/src/lib/features/auth/api}/types.ts (100%) create mode 100644 apps/dashboard/src/lib/features/auth/utils/types.ts create mode 100644 apps/dashboard/src/lib/features/auth/utils/validation.ts create mode 100644 apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts create mode 100644 apps/dashboard/src/lib/features/onboarding/components/index.ts rename apps/dashboard/src/lib/{components/Org/VerifyEmail/VerifyEmailModal.svelte => features/onboarding/components/verify-email-modal.svelte} (71%) rename apps/dashboard/src/lib/{components/WelcomeModal/WelcomeModal.svelte => features/onboarding/components/welcome-modal.svelte} (58%) create mode 100644 apps/dashboard/src/lib/features/onboarding/utils/constants.ts create mode 100644 apps/dashboard/src/lib/features/onboarding/utils/types.ts create mode 100644 apps/dashboard/src/lib/features/onboarding/utils/validations.ts create mode 100644 apps/dashboard/src/lib/features/org/api/index.ts create mode 100644 apps/dashboard/src/lib/features/org/api/org-plan.svelte.ts create mode 100644 apps/dashboard/src/lib/features/org/api/org.svelte.ts create mode 100644 apps/dashboard/src/lib/features/org/utils/types.ts rename apps/dashboard/src/lib/{components/ComingSoon/index.svelte => features/ui/coming-soon.svelte} (53%) create mode 100644 apps/dashboard/src/lib/features/ui/index.ts create mode 100644 apps/dashboard/src/lib/features/ui/navigation/app-breadcrumbs.svelte create mode 100644 apps/dashboard/src/lib/features/ui/navigation/app-header.svelte create mode 100644 apps/dashboard/src/lib/features/ui/navigation/breadcrumb.ts create mode 100644 apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts rename apps/dashboard/src/lib/{components/Progress/PageLoadProgressBar.svelte => features/ui/page-load-progress.svelte} (100%) create mode 100644 apps/dashboard/src/lib/features/ui/search.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/footer/index.ts create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/app-logo.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/index.ts create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/nav-main.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-sidebar.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/upgrade-trigger.svelte create mode 100644 apps/dashboard/src/lib/features/ui/sidebar/sidebar-skeleton.svelte create mode 100644 apps/dashboard/src/lib/utils/services/api/base.svelte.ts create mode 100644 apps/dashboard/src/lib/utils/services/auth/client.ts create mode 100644 apps/dashboard/src/lib/utils/services/auth/session.ts delete mode 100644 apps/dashboard/src/lib/utils/types/sidebar.ts create mode 100644 apps/dashboard/src/lib/utils/validation/USAGE.md create mode 100644 apps/dashboard/src/lib/utils/validation/index.ts create mode 100644 apps/dashboard/src/lib/utils/validation/validation.test.ts create mode 100644 apps/dashboard/src/lib/utils/validation/validation.ts delete mode 100644 apps/dashboard/src/routes/api/email/verify_email/+server.ts delete mode 100644 apps/dashboard/src/routes/api/email/welcome/+server.ts delete mode 100644 apps/dashboard/src/routes/api/verify/+server.ts create mode 100644 apps/dashboard/vitest.config.ts delete mode 100644 cypress/e2e/dashboard/authentication.cy.js delete mode 100644 cypress/fixtures/example.json delete mode 100644 cypress/support/commands.js delete mode 100644 cypress/support/e2e.js create mode 100644 packages/db/.gitignore create mode 100644 packages/db/README.md delete mode 100644 packages/db/existingAccounts.json create mode 100644 packages/db/scripts/generate-types.ts delete mode 100644 packages/db/src/auth-schema.ts create mode 100644 packages/db/src/auth/email-password.ts create mode 100644 packages/db/src/auth/email-verification.ts create mode 100644 packages/db/src/auth/hooks/create-profile.ts create mode 100644 packages/db/src/auth/hooks/index.ts create mode 100644 packages/db/src/auth/hooks/sync-user.ts create mode 100644 packages/db/src/constants.ts create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/src/migrations/0002_many_revanche.sql create mode 100644 packages/db/src/migrations/meta/0002_snapshot.json create mode 100644 packages/db/src/queries/auth/auth.ts create mode 100644 packages/db/src/queries/auth/index.ts create mode 100644 packages/db/src/queries/auth/profile.ts rename apps/dashboard/src/routes/upgrade/+page.svelte => packages/db/src/queries/course/.gitkeep (100%) create mode 100644 packages/db/src/queries/course/course.ts create mode 100644 packages/db/src/queries/course/index.ts create mode 100644 packages/db/src/queries/index.ts create mode 100644 packages/db/src/queries/organization/index.ts create mode 100644 packages/db/src/queries/organization/organization.ts create mode 100644 packages/db/src/queries/organization/types.ts create mode 100644 packages/db/src/scripts/seed.ts create mode 100644 packages/db/src/types.ts create mode 100644 packages/email/.gitignore create mode 100644 packages/email/README.md create mode 100644 packages/email/package.json create mode 100644 packages/email/src/config/env.ts create mode 100644 packages/email/src/core/base.ts create mode 100644 packages/email/src/core/registry.ts create mode 100644 packages/email/src/core/types.ts create mode 100644 packages/email/src/emails/forgot-password.ts create mode 100644 packages/email/src/emails/index.ts create mode 100644 packages/email/src/emails/on-password-reset.ts create mode 100644 packages/email/src/emails/verify-email.ts create mode 100644 packages/email/src/emails/welcome.ts create mode 100644 packages/email/src/index.ts create mode 100644 packages/email/src/send.ts create mode 100644 packages/email/src/templates/default.ts create mode 100644 packages/email/src/templates/index.ts create mode 100644 packages/email/src/utils/constants.ts create mode 100644 packages/email/src/utils/functions/email-helpers.ts create mode 100644 packages/email/src/utils/services/nodemailer.ts create mode 100644 packages/email/src/utils/services/zeptomail.ts create mode 100644 packages/email/src/utils/types.ts create mode 100644 packages/email/tsconfig.json create mode 100755 packages/ui/add-ui-prefix.cjs create mode 100644 packages/ui/src/base/drawer/drawer-close.svelte create mode 100644 packages/ui/src/base/drawer/drawer-content.svelte create mode 100644 packages/ui/src/base/drawer/drawer-description.svelte create mode 100644 packages/ui/src/base/drawer/drawer-footer.svelte create mode 100644 packages/ui/src/base/drawer/drawer-header.svelte create mode 100644 packages/ui/src/base/drawer/drawer-nested.svelte create mode 100644 packages/ui/src/base/drawer/drawer-overlay.svelte create mode 100644 packages/ui/src/base/drawer/drawer-title.svelte create mode 100644 packages/ui/src/base/drawer/drawer-trigger.svelte create mode 100644 packages/ui/src/base/drawer/drawer.svelte create mode 100644 packages/ui/src/base/drawer/index.ts create mode 100644 packages/ui/src/base/input-group/index.ts create mode 100644 packages/ui/src/base/input-group/input-group-addon.svelte create mode 100644 packages/ui/src/base/input-group/input-group-button.svelte create mode 100644 packages/ui/src/base/input-group/input-group-input.svelte create mode 100644 packages/ui/src/base/input-group/input-group-text.svelte create mode 100644 packages/ui/src/base/input-group/input-group-textarea.svelte create mode 100644 packages/ui/src/base/input-group/input-group.svelte create mode 100644 packages/ui/src/base/item/index.ts create mode 100644 packages/ui/src/base/item/item-actions.svelte create mode 100644 packages/ui/src/base/item/item-content.svelte create mode 100644 packages/ui/src/base/item/item-description.svelte create mode 100644 packages/ui/src/base/item/item-footer.svelte create mode 100644 packages/ui/src/base/item/item-group.svelte create mode 100644 packages/ui/src/base/item/item-header.svelte create mode 100644 packages/ui/src/base/item/item-media.svelte create mode 100644 packages/ui/src/base/item/item-separator.svelte create mode 100644 packages/ui/src/base/item/item-title.svelte create mode 100644 packages/ui/src/base/item/item.svelte create mode 100644 packages/ui/src/base/kbd/index.ts create mode 100644 packages/ui/src/base/kbd/kbd-group.svelte create mode 100644 packages/ui/src/base/kbd/kbd.svelte create mode 100644 packages/ui/src/base/textarea/index.ts create mode 100644 packages/ui/src/base/textarea/textarea.svelte create mode 100644 packages/utils/.prettierignore create mode 100644 packages/utils/README.md create mode 100644 packages/utils/src/constants/index.ts create mode 100644 packages/utils/src/constants/org.ts create mode 100644 packages/utils/src/constants/roles.ts create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/src/validation/README.md create mode 100644 packages/utils/src/validation/account/account.ts create mode 100644 packages/utils/src/validation/account/index.ts create mode 100644 packages/utils/src/validation/constants.ts create mode 100644 packages/utils/src/validation/course/course.ts create mode 100644 packages/utils/src/validation/course/index.ts create mode 100644 packages/utils/src/validation/index.ts create mode 100644 packages/utils/src/validation/mail/index.ts create mode 100644 packages/utils/src/validation/mail/mail.ts create mode 100644 packages/utils/src/validation/onboarding/index.ts create mode 100644 packages/utils/src/validation/onboarding/onboarding.ts create mode 100644 packages/utils/src/validation/organization/index.ts create mode 100644 packages/utils/src/validation/organization/organization.ts delete mode 100644 todo/api.md delete mode 100644 todo/v1.md diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 00000000..e1dd4433 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,45 @@ +# Dependencies +node_modules/ +**/node_modules/ +.pnp +.pnp.js +.pnpm-store/ + +# Build outputs +**/dist/ +**/.next/ +**/out/ +**/build/ +**/.turbo/ +**/.svelte-kit/ + +# Testing +**/coverage/ +cypress/downloads/ + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Misc +.DS_Store +*.pem + +# Generated files +apps/api/dist/ +packages/utils/dist/ +packages/course-app/dist/ +packages/db/dist/ + +# Vite temp files +vite.config.ts*.mjs + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + diff --git a/.gitignore b/.gitignore index ee18d284..0e4f23c4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ vite.config.ts*.mjs #Ignore vscode AI rules .github/instructions/codacy.instructions.md + +tsconfig.tsbuildinfo* \ No newline at end of file diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 00000000..60cdd147 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "MD024": { "siblings_only": true } +} diff --git a/.prettierignore b/.prettierignore index b1e781fd..9e3bea4a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,4 +4,5 @@ build .turbo _template supabase -static \ No newline at end of file +static +dist \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index f3416c3a..74436ab4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,5 +21,30 @@ "[svelte]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "vitest.disableWorkspaceWarning": true + "vitest.disableWorkspaceWarning": true, + "typescript.tsserver.maxTsServerMemory": 4072, + "typescript.disableAutomaticTypeAcquisition": false, + "typescript.tsserver.experimental.enableProjectDiagnostics": false, + "files.watcherExclude": { + "**/.git/objects/**": true, + "**/.git/subtree-cache/**": true, + "**/node_modules/**": true, + "**/.hg/store/**": true, + "**/dist/**": true, + "**/.next/**": true, + "**/.svelte-kit/**": true, + "**/build/**": true, + "**/.turbo/**": true, + "**/coverage/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/dist": true, + "**/.next": true, + "**/.svelte-kit": true, + "**/build": true, + "**/.turbo": true, + "**/coverage": true + // "pnpm-lock.yaml": true + } } diff --git a/ARCHITECHTURE.md b/ARCHITECHTURE.md new file mode 100644 index 00000000..2f71a868 --- /dev/null +++ b/ARCHITECHTURE.md @@ -0,0 +1,1626 @@ +# API Route Development Guide + +## Overview + +This guide outlines the standard practices for creating new API routes in ClassroomIO. **All routes should be designed with the assumption they may become public API endpoints**, requiring careful attention to consistency, security, validation, and documentation. + +--- + +## Architecture Layers + +Our API follows a four-layer architecture with shared validation: + +```txt +┌─────────────────────────────────────┐ +│ Frontend (Svelte/SvelteKit) │ +│ - UI Components │ +│ - API Client (RPC with Hono) │ +│ - Client-side Validation │ +└──────────────┬──────────────────────┘ + │ + │ ┌────────────────────────────┐ + │ │ Shared Validation Layer │ + ├──┤ (@cio/utils/validation) │ + │ │ - Zod Schemas │ + │ │ - Type Definitions │ + │ └────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Route Layer (Hono) │ +│ - HTTP Request/Response │ +│ - Input Validation (Zod) │ +│ - Authentication Check │ +│ - Calls Services │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Service Layer │ +│ - Business Logic │ +│ - Transaction Orchestration │ +│ - Error Handling │ +│ - Calls Queries │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Query Layer (Drizzle ORM) │ +│ - Database Operations │ +│ - Pure Functions │ +│ - Type-Safe Queries │ +└─────────────────────────────────────┘ +``` + +**Key Principles**: + +- **Shared Validation**: Zod schemas in `@cio/utils/validation` used by both API and frontend +- **Routes**: Handle HTTP concerns only (validation, auth, serialization) +- **Services**: Contain all business logic and orchestrate queries +- **Queries**: Pure database operations with no business logic + +--- + +## Type Generation + +**Location**: `packages/db/src/types.ts` (auto-generated) + +### How Types Are Generated + +Types are automatically generated from your Drizzle schema using Drizzle's built-in type inference: + +```typescript +// packages/db/src/types.ts (auto-generated - DO NOT EDIT) +import * as schema from './schema'; + +// For each table, two types are generated: +export type TUser = typeof schema.user.$inferSelect; // For reading +export type TNewUser = typeof schema.user.$inferInsert; // For creating + +export type TOrganization = typeof schema.organization.$inferSelect; +export type TNewOrganization = typeof schema.organization.$inferInsert; +``` + +**To regenerate types after schema changes:** + +```bash +cd packages/db +pnpm generate-types +``` + +This script: + +1. Reads `src/schema.ts` to find all tables and enums +2. Generates type definitions using Drizzle's `$inferSelect` and `$inferInsert` +3. Outputs to `src/types.ts` +4. Formats the file with Prettier + +**When to regenerate:** + +- ✏️ After adding/removing tables in `schema.ts` +- ✏️ After adding/removing columns in existing tables +- ✏️ After changing column types or constraints +- ℹ️ **Not needed** for data changes (migrations) - only for schema definition changes + +--- + +## Step 1: Create Reusable Queries + +**Location**: `packages/db/src/queries/{domain}/{entity}.ts` + +### Principles + +1. **Pure Functions**: Queries should be pure database operations with no business logic +2. **Single Responsibility**: Each query does one thing well +3. **Type Safety**: Leverage Drizzle's type inference for automatic types +4. **Reusability**: Queries can be used across multiple routes and services + +### Example: Organization Queries + +```typescript +// packages/db/src/queries/organization/organization.ts +import * as schema from '@db/schema'; +import { db } from '@db/drizzle'; +import { eq } from 'drizzle-orm'; +import type { TNewOrganization, TOrganization } from '@db/types'; + +/** + * Creates a new organization + * @param data Organization creation data + * @returns Created organization record + */ +export const createOrganization = async (data: TNewOrganization) => { + const [organization] = await db + .insert(schema.organization) + .values(data) + .returning(); + + return organization; +}; + +/** + * Updates an organization + * @param id Organization ID + * @param data Partial organization data + * @returns Updated organization record + */ +export const updateOrganization = async (id: string, data: TOrganization) => { + const [organization] = await db + .update(schema.organization) + .set(data) + .where(eq(schema.organization.id, id)) + .returning(); + + return organization; +}; +``` + +**Type System:** + +- Types are auto-generated from Drizzle schema using built-in inference +- `TNewOrganization` = `typeof schema.organization.$inferInsert` (for creates) +- `TOrganization` = `typeof schema.organization.$inferSelect` (for reads/updates) +- Input validation happens at the API route layer (not in queries) +- Queries are pure database operations with compile-time type safety + +### Query Best Practices + +#### ✅ DO + +- **Use exported types from `@cio/db/types`** (e.g., `TNewOrganization`, `TOrganization`) +- **Use `TNew{Table}` types** for create operations (e.g., `TNewOrganization`) +- **Use `T{Table}` types** for read operations (e.g., `TOrganization`) +- **Use `Partial` types** for update operations (e.g., `Partial`) +- Use descriptive names (`getOrganizationByProfileId`, not `getOrg`) +- Add JSDoc comments for all exported functions +- Return inferred types from Drizzle +- Use `.returning()` for insert/update operations to get the created/updated record +- Use `.limit(1)` for single-record queries +- Handle null/undefined cases explicitly +- Rely on Drizzle's compile-time type safety + +#### ❌ DON'T + +- Define ad-hoc types when `@cio/db/types` exports them +- Add runtime validation in queries (validation belongs in API routes) +- Include business logic in queries (authorization, side effects) +- Make HTTP calls or external API calls from queries +- Access environment variables directly +- Handle transactions within individual queries (do this at service layer) +- Throw custom `AppError` (let database errors bubble up) +- Mix concerns (e.g., sending emails from a query function) + +### Export Queries + +```typescript +// packages/db/src/queries/organization/index.ts +export * from './organization'; +export * from './types'; + +// packages/db/src/queries/index.ts +export * from './organization'; +export * from './auth'; +export * from './course'; +``` + +--- + +## Step 2: Create Services (Business Logic) + +**Location**: `apps/api/src/services/{domain}.ts` + +### Principles + +1. **Business Logic Only**: Services contain business rules, validations, and orchestration +2. **Transaction Management**: Services handle database transactions +3. **Error Handling**: Services throw meaningful errors that routes can handle +4. **Testable**: Services should be easy to unit test +5. **No HTTP Concerns**: Services don't know about requests/responses + +### Example: Organization Service + +```typescript +// apps/api/src/services/onboarding.ts +import { db } from '@cio/db/drizzle'; +import { + createOrganization, + createOrganizationMember, + checkSiteNameExists, + getOrganizationByProfileId +} from '@cio/db/queries/organization'; +import { updateProfile } from '@cio/db/queries/auth'; +import { AppError, ErrorCodes } from '@api/utils/errors'; +import type { TProfile } from '@cio/db/types'; + +/** + * Creates a new organization with the user as owner + */ +export async function createOrganizationWithOwner( + profileId: string, + input: { orgName: string; siteName: string; fullname?: string } +) { + // Business Logic: Check sitename availability + const exists = await checkSiteNameExists(input.siteName); + if (exists) { + throw new AppError( + `Site name '${input.siteName}' already exists`, + ErrorCodes.SITENAME_EXISTS, + 409 + ); + } + + // Business Logic: Create org and member in a transaction + try { + const result = await db.transaction(async (tx) => { + const organization = await createOrganization({ + name: input.orgName, + siteName: input.siteName + }); + + const member = await createOrganizationMember({ + organizationId: organization.id, + profileId, + roleId: 1, // Owner role + verified: true + }); + + return { organization, member }; + }); + + // Fetch updated organizations list + const organizations = await getOrganizationByProfileId(profileId); + + return { + organization: result.organization, + member: result.member, + organizations + }; + } catch (error) { + // Handle database constraint violations + if (error.code === '23505') { + throw new AppError( + `Site name '${input.siteName}' already exists`, + ErrorCodes.SITENAME_EXISTS, + 409 + ); + } + throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500); + } +} +``` + +### Error Handling in Services + +Use `AppError` for consistent error handling across services: + +```typescript +// apps/api/src/utils/errors.ts +import type { ContentfulStatusCode } from 'hono/utils/http-status'; + +export class AppError extends Error { + constructor( + message: string, + public code: string, + public statusCode: ContentfulStatusCode = 500, + public field?: string // Optional: for field-specific validation errors + ) { + super(message); + this.name = 'AppError'; + } +} + +export const ErrorCodes = { + SITENAME_EXISTS: 'SITENAME_EXISTS', + ORG_CREATE_FAILED: 'ORG_CREATE_FAILED', + PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND', + // ... add more as needed +} as const; +``` + +**Why?** One reusable class scales infinitely vs creating custom error classes for every scenario. + +### Error Handling in Routes + +Use the `handleError` utility for consistent error responses across all routes: + +```typescript +// apps/api/src/utils/errors.ts +export const handleError = ( + c: Context, + error: unknown, + fallbackMessage: string = 'An unexpected error occurred', + fallbackCode: string = ErrorCodes.INTERNAL_ERROR +) => { + console.error('Error in route:', error); + + if (error instanceof AppError) { + return c.json({ + success: false, + error: error.message, + code: error.code, + field: error.field + }, error.statusCode); + } + + return c.json({ + success: false, + error: fallbackMessage, + code: fallbackCode + }, 500); +}; +``` + +**Usage in routes:** + +```typescript +// Simple usage with default message +try { + const data = await someService(); + return c.json({ success: true, data }); +} catch (error) { + return handleError(c, error); +} + +// With custom fallback message +try { + const data = await fetchAccount(userId); + return c.json({ success: true, data }); +} catch (error) { + return handleError(c, error, 'Failed to fetch account data'); +} + +// With custom fallback message AND error code +try { + await deliverEmail(data); + return c.json({ success: true }); +} catch (error) { + return handleError(c, error, 'Failed to send email', 'EMAIL_SEND_FAILED'); +} +``` + +**Benefits:** + +- ✅ **Consistency**: All routes return the same error format +- ✅ **DRY**: No repetitive error handling code +- ✅ **Type-safe**: Proper TypeScript types for responses +- ✅ **Flexible**: Custom fallback messages per route +- ✅ **Field-aware**: Supports field-specific validation errors + +### Service Best Practices + +#### ✅ DO + +- **Use descriptive function names** that explain the business operation +- **Throw `AppError`** with appropriate error codes and status codes +- **Handle transactions** within services +- **Validate business rules** (e.g., checking if sitename exists) +- **Return structured data** that routes can easily serialize +- **Add JSDoc comments** explaining the business logic +- **Keep services focused** on a specific domain + +#### ❌ DON'T + +- Return HTTP responses (status codes, headers) +- Access request/response objects directly +- Handle HTTP-specific concerns (CORS, cookies, etc.) +- Mix multiple domains in one service (keep focused) +- Catch errors just to log them (let them bubble up) +- Create custom error classes for every error type (use `AppError`) + +### Export Services + +```typescript +// apps/api/src/services/onboarding.ts +export * from './onboarding'; + +// apps/api/src/services/index.ts +export * from './onboarding'; +export * from './account'; +export * from './course'; +``` + +--- + +## Step 3: Create API Route + +**Location**: `apps/api/src/routes/{domain}/{entity}.ts` + +### Public API Standards + +Since routes may be exposed as public APIs, follow these standards rigorously: + +#### 1. **Consistent Response Format** + +All API responses should follow a consistent structure: + +```typescript +// Success Response +{ + "success": true, + "data": { ... }, // The actual response data + "message": "Optional" // Optional human-readable message +} + +// Error Response +{ + "success": false, + "error": "Human-readable error message", + "code": "ERROR_CODE", // Optional: machine-readable error code + "field": "fieldName" // Optional: for validation errors, indicates which field failed +} + +// List Response (with pagination) +{ + "success": true, + "data": [...], + "pagination": { + "page": 1, + "limit": 20, + "total": 100, + "totalPages": 5 + } +} +``` + +#### 2. **HTTP Status Codes** + +Use semantically correct HTTP status codes: + +| Code | Meaning | Usage | +|------|---------|-------| +| `200 OK` | Success | Successful GET, PUT, PATCH requests | +| `201 Created` | Created | Successful POST creating a new resource | +| `204 No Content` | Success | Successful DELETE or update with no response body | +| `400 Bad Request` | Client Error | Malformed request, validation errors | +| `401 Unauthorized` | Auth Error | Missing or invalid authentication token | +| `403 Forbidden` | Auth Error | Authenticated but not authorized for this action | +| `404 Not Found` | Client Error | Resource doesn't exist | +| `409 Conflict` | Client Error | Duplicate resource (e.g., email/sitename already exists) | +| `422 Unprocessable Entity` | Client Error | Valid syntax but semantic validation failed | +| `429 Too Many Requests` | Client Error | Rate limit exceeded | +| `500 Internal Server Error` | Server Error | Unexpected server error | +| `503 Service Unavailable` | Server Error | Temporary service outage | + +#### 3. **Input Validation with Zod** + +**Always validate all input** using Zod schemas. Define ALL validation schemas in `packages/utils/src/validation/` organized by entity. + +**Why in utils package?** + +- ✅ Shared between API (server-side validation) and Dashboard (client-side validation) +- ✅ Single source of truth - no schema duplication +- ✅ Type-safe across the entire monorepo +- ✅ Convention-based translations (clean schemas without hardcoded messages) + +**File Structure**: + +```txt +packages/utils/src/validation/ + ├── course/ + │ ├── course.ts ← All course validation schemas + │ └── index.ts ← Re-exports + ├── mail/ + │ ├── mail.ts ← Mail validation schemas + │ └── index.ts ← Re-exports + ├── constants.ts ← Shared constants + ├── index.ts ← Main exports + └── README.md ← Documentation +``` + +**Example: Course Validations.** + +Schemas should be **clean** - only validation rules, no error messages: + +```typescript +// packages/utils/src/validation/course/course.ts +import * as z from 'zod'; +import { ALLOWED_CONTENT_TYPES } from '../constants'; + +export const ZCourseClone = z.object({ + id: z.string().min(1), // No hardcoded message + title: z.string().min(1), // No hardcoded message + description: z.string().optional(), + slug: z.string().min(1), + organizationId: z.string().min(1) +}); +export type TCourseClone = z.infer; + +// packages/utils/src/validation/course/index.ts +export * from './course'; +``` + +**Using in API Routes**: + +```typescript +// apps/api/src/routes/course/clone.ts +import { ZCourseClone } from '@cio/utils/validation/course'; +import { zValidator } from '@hono/zod-validator'; + +export const cloneRouter = new Hono() + .post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => { + const data = c.req.valid('json'); // Fully typed! + // ... route handler + }); +``` + +**Using in Frontend Forms**: + +```typescript +// apps/dashboard/src/components/CourseForm.svelte +import { ZCourseClone } from '@cio/utils/validation/course'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; + +const result = ZCourseClone.safeParse(formData); + +if (!result.success) { + // Maps Zod errors to translated messages using convention + // Pass 'course' as entity name for entity-specific translations + errors = mapZodErrorsToTranslations(result.error, 'course'); + // errors.title = "Course title is required" (translated) +} +``` + +**Convention-Based Translations**: + +Frontend maps validation errors to translations using a **smart fallback system**: + +1. **Entity-specific**: `validations.{entityName}.{fieldName}.{errorCode}` (most specific) +2. **Generic field**: `validations.{fieldName}.{errorCode}` (fallback) +3. **Generic type**: `validations.generic.{errorType}` (last resort) + +This allows different messages for shared field names (e.g., "Course title is required" vs "Lesson title is required"): + +```json +// apps/dashboard/src/lib/utils/translations/en.json +{ + "validations": { + // Entity-specific translations (preferred) + "course": { + "title": { "too_small": "Course title is required" }, + "slug": { "too_small": "Course slug is required" } + }, + "lesson": { + "title": { "too_small": "Lesson title is required" }, + "duration": { "too_small": "Lesson duration must be at least 1 minute" } + }, + + // Generic field translations (fallback for fields used across entities) + "title": { "too_small": "Title is required" }, + "email": { "invalid_email": "Please enter a valid email address" }, + + // Generic error type translations (last resort) + "generic": { + "required": "This field is required", + "invalid_email": "Invalid email address", + "too_small_string": "Must be at least {min} characters" + } + } +} +``` + +**Entity Name Usage**: + +```typescript +// With entity name (entity-specific messages) +mapZodErrorsToTranslations(error, 'course') // "Course title is required" +mapZodErrorsToTranslations(error, 'lesson') // "Lesson title is required" + +// Without entity name (generic messages) +mapZodErrorsToTranslations(error) // "Title is required" +``` + +**Key Principles**: + +- **Keep schemas clean**: No hardcoded error messages (translations handled in frontend) +- **Single source of truth**: One schema used by both API and frontend +- **Entity-specific translations**: Use entity name parameter for context-specific error messages +- **Smart fallbacks**: Entity-specific → Generic field → Generic type → Hardcoded +- **Follow naming convention**: Schemas with `Z` prefix, types with `T` prefix +- **Organize by entity**: Each entity gets its own folder + +--- + +#### 4. **Translation Strategy** + +**Overview**: Our validation system uses a **convention-based translation approach** with smart fallbacks to provide flexible, maintainable error messages. + +##### Translation Lookup Order + +When a validation error occurs, the system tries translation keys in this order: + +1. **Entity-specific** (most specific): `validations.course.title.too_small` +2. **Generic field** (fallback): `validations.title.too_small` +3. **Generic type** (last resort): `validations.generic.too_small_string` +4. **Hardcoded fallback**: "Must be at least 1 character" + +##### When to Use Entity-Specific Translations + +Use entity names when: + +✅ **Shared field names** across entities + +- Example: Both Course and Lesson have `title`, but need different context +- Solution: `mapZodErrorsToTranslations(error, 'course')` vs `mapZodErrorsToTranslations(error, 'lesson')` + +✅ **Entity-specific context** matters + +- Example: "Course description helps students understand..." vs "Lesson description explains..." +- Better UX with contextual messaging + +✅ **Business domain clarity** + +- Makes error messages more professional and domain-specific + +Skip entity names when: + +❌ **Universal fields** that mean the same everywhere + +- Example: `email`, `password`, `firstName` - same validation message across all entities + +❌ **Unique field names** that only exist in one entity + +- Example: `certificateTemplate` only in Course entity + +❌ **Generic utility forms** where entity context isn't relevant + +##### Translation File Organization + +```json +{ + "validations": { + // 1. Entity-specific (for shared field names) + "course": { + "title": { + "too_small": "Course title is required", + "too_big": "Course title must be less than 100 characters" + }, + "description": { + "too_small": "Please add a course description to help students" + } + }, + "lesson": { + "title": { + "too_small": "Lesson title is required", + "too_big": "Lesson title must be less than 100 characters" + }, + "duration": { + "too_small": "Lesson must be at least 1 minute long" + } + }, + + // 2. Generic field-level (fallback for common fields) + "title": { + "too_small": "Title is required" + }, + "email": { + "invalid_email": "Please enter a valid email address" + }, + "password": { + "too_small": "Password must be at least 8 characters" + }, + + // 3. Generic error types (last resort fallback) + "generic": { + "required": "This field is required", + "invalid_email": "Invalid email address", + "invalid_url": "Invalid URL format", + "too_small_string": "Must be at least {min} characters", + "too_big_string": "Must be at most {max} characters", + "invalid_type": "Invalid value type", + "invalid_enum": "Invalid selection" + } + } +} +``` + +##### Implementation Examples + +**Example 1: Entity-specific (Course form).** + +```typescript +import { ZCourseCreate } from '@cio/utils/validation/course'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; + +const result = ZCourseCreate.safeParse(formData); +if (!result.success) { + errors = mapZodErrorsToTranslations(result.error, 'course'); + // Result: "Course title is required" (entity-specific) +} +``` + +**Example 2: Generic (User profile form).** + +```typescript +import { ZUserProfile } from '@cio/utils/validation/user'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; + +const result = ZUserProfile.safeParse(formData); +if (!result.success) { + errors = mapZodErrorsToTranslations(result.error); + // Result: "Please enter a valid email address" (generic) +} +``` + +**Example 3: Mixed fields.** + +```typescript +// Course has both shared fields (title) and unique fields (certificateTemplate) +const result = ZCourseUpdate.safeParse(formData); +if (!result.success) { + errors = mapZodErrorsToTranslations(result.error, 'course'); + // errors.title = "Course title is required" (entity-specific) + // errors.email = "Please enter a valid email address" (generic fallback) +} +``` + +##### Benefits of This Approach + +✅ **Flexible**: Use entity-specific messages only where needed +✅ **Scalable**: Add new entities without updating all translations +✅ **Maintainable**: Clear fallback hierarchy reduces duplication +✅ **Developer-friendly**: Works with or without entity name parameter +✅ **Multi-language**: Same system works across all locales +✅ **Professional UX**: Context-specific messages improve user experience + +--- + +#### 5. **Authentication & Authorization** + +```typescript +import { authMiddleware } from '@api/middlewares/auth'; + +// Protected route - requires authentication +.post('/create', authMiddleware, async (c) => { + const user = c.get('user'); // Always available after authMiddleware + const session = c.get('session'); + // ... +}); + +// Public endpoint - no authentication required +.get('/public-courses', async (c) => { + // Publicly accessible data +}); + +// Optional authentication - checks if user is logged in but doesn't require it +.get('/courses', async (c) => { + const user = c.get('user'); // May be null + if (user) { + // Show personalized content + } else { + // Show public content + } +}); +``` + +### Complete Route Example + +Routes should be thin - they handle HTTP concerns and delegate to services: + +```typescript +// apps/api/src/routes/organization/organization.ts +import { Hono } from '@api/utils/hono'; +import { authMiddleware } from '@api/middlewares/auth'; +import { zValidator } from '@hono/zod-validator'; +import { createOrganizationWithOwner } from '@api/services/organization'; +import { handleError } from '@api/utils/errors'; +import { ZCreateOrg } from '@cio/utils/validation/organization'; + +export const organizationRouter = new Hono() + .post('/create', authMiddleware, zValidator('json', ZCreateOrg), async (c) => { + try { + const user = c.get('user'); + const body = c.req.valid('json'); + + const result = await createOrganizationWithOwner(user.id, body); + + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create organization'); + } + }); +``` + +**Key Points:** + +- Route is ~20 lines - clean and focused +- Single line error handling using `handleError` utility +- Automatically handles `AppError` instances with correct status codes +- No error code → status code mapping needed +- Easy to test service independently +- Route only handles HTTP concerns (validation, auth, response formatting) + +### Route Best Practices + +#### ✅ DO + +- **Validate all inputs** with Zod schemas +- **Call services** for all business logic +- **Use `handleError` utility** in all catch blocks for consistent error responses +- **Provide custom fallback messages** to `handleError` for better error context +- **Include field names** in validation errors (via `AppError` field parameter) +- **Use consistent response format** across all routes +- **Document routes** with JSDoc comments +- **Keep routes thin** - only HTTP concerns (validation, auth, response formatting) + +#### ❌ DON'T + +- Put business logic in routes (use services) +- Return raw database errors to clients +- Write repetitive error handling code (use `handleError` utility) +- Use `200 OK` for all responses regardless of outcome +- Leave sensitive information in error messages +- Perform heavy computation in route handlers (use background jobs or services) +- Skip input validation "because the frontend validates" +- Use generic error messages like "Something went wrong" (provide context) +- Expose internal implementation details in responses +- Call queries directly from routes (use services) + +--- + +## Creating a New Route: Complete Walkthrough + +This section provides a complete end-to-end example of creating a new API route, based on the user profile update implementation. + +### Example: User Profile Update Route + +We'll create a `PUT /account/user` route that updates a user's profile information. + +--- + +### Step 1: Create Validation Schema + +**Location**: `packages/utils/src/validation/{domain}/{entity}.ts` + +```typescript +import * as z from 'zod'; + +export const ZUpdateProfile = z.object({ + fullname: z.string().min(5).optional(), + username: z.string().min(1).optional(), + locale: z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']).optional() +}); + +export type TUpdateProfile = z.infer; +``` + +**Export**: Add to `packages/utils/src/validation/{domain}/index.ts` and `packages/utils/src/validation/index.ts` + +--- + +### Step 2: Create Service Function + +**Location**: `apps/api/src/services/{domain}.ts` + +```typescript +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { updateProfile } from '@cio/db/queries/auth'; + +export async function updateUser(userId: string, data: Partial) { + try { + const updatedProfile = await updateProfile(userId, data); + if (!updatedProfile) { + throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404); + } + return updatedProfile; + } catch (error) { + if (error instanceof AppError) throw error; + + // Handle constraint violations + if (error instanceof Error && error.message.includes('profile_username_key')) { + throw new AppError('Username already exists', ErrorCodes.VALIDATION_ERROR, 400, 'username'); + } + + throw new AppError('Failed to update profile', ErrorCodes.PROFILE_UPDATE_FAILED, 500); + } +} +``` + +--- + +### Step 3: Create Route + +**Location**: `apps/api/src/routes/{domain}/{entity}.ts` + +```typescript +import { updateUser } from '@api/services/account'; +import { Hono } from '@api/utils/hono'; +import { ZUpdateProfile } from '@cio/utils/validation/account'; +import { authMiddleware } from '@api/middlewares/auth'; +import { handleError } from '@api/utils/errors'; +import { zValidator } from '@hono/zod-validator'; + +export const accountRouter = new Hono() + .put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => { + try { + const user = c.get('user'); + const validatedData = c.req.valid('json'); + const updatedProfile = await updateUser(user.id, validatedData); + + return c.json({ success: true, profile: updatedProfile }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update profile'); + } + }); +``` + +--- + +### Step 4: Export and Register Route + +```typescript +// apps/api/src/routes/account/index.ts +export * from './account'; + +// apps/api/src/app.ts +import { accountRouter } from '@api/routes/account'; + +export const app = new Hono() + .route('/account', accountRouter); // Route available at PUT /account/user +``` + +--- + +### Step 5: Use in Frontend + +**Location**: `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts` + +```typescript +import { ZProfileUpdateForm } from '@cio/utils/validation/account'; +import { classroomio } from '$lib/utils/services/api'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; + +export class ProfileApi { + isLoading = $state(false); + errors = $state>({}); + + async submit(fields: TProfileUpdateForm) { + // Client-side validation + const result = ZProfileUpdateForm.safeParse(fields); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error); + return; + } + + try { + this.isLoading = true; + + // Call API using RPC client (matches route path) + const response = await classroomio.account.user.$put({ + json: result.data + }); + + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + // Update local state, show success message, etc. + } catch (error) { + this.errors.general = error.message; + } finally { + this.isLoading = false; + } + } +} +``` + +--- + +### Route Path Convention + +When adding routes to an existing router: + +- **GET `/`** - Fetch/list resources (e.g., `GET /account` - get account data) +- **GET `/{id}`** - Fetch single resource (e.g., `GET /course/{id}`) +- **POST `/`** - Create resource (e.g., `POST /course` - create course) +- **PUT `/{resource}`** - Update resource (e.g., `PUT /account/user` - update user) +- **DELETE `/{id}`** - Delete resource (e.g., `DELETE /course/{id}`) + +**Example router with multiple routes**: + +```typescript +export const accountRouter = new Hono() + .get('/', authMiddleware, async (c) => { + // GET /account - get account data + }) + .put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => { + // PUT /account/user - update user profile + }) + .put('/password', authMiddleware, zValidator('json', ZChangePassword), async (c) => { + // PUT /account/password - change password + }); +``` + +--- + +### Quick Reference Checklist + +When creating a new route, follow this checklist: + +1. **Validation Schema** (`packages/utils/src/validation/`) + - [ ] Create Zod schema with validation rules + - [ ] Export schema and types + - [ ] Add to validation index + +2. **Service** (`apps/api/src/services/`) + - [ ] Create service function with business logic + - [ ] Use `AppError` for structured errors + - [ ] Handle database constraint violations + - [ ] Add JSDoc comments + +3. **Route** (`apps/api/src/routes/`) + - [ ] Create route handler + - [ ] Use `zValidator` for input validation + - [ ] Apply `authMiddleware` if needed + - [ ] Use `handleError` for error handling + - [ ] Return consistent response format + +4. **Registration** + - [ ] Export router in index file + - [ ] Register route in `app.ts` + +5. **Frontend** (`apps/dashboard/src/lib/features/`) + - [ ] Create API abstraction class (`.svelte.ts`) + - [ ] Use shared validation schema + - [ ] Use RPC client for API calls + - [ ] Map errors to translations + - [ ] Update local state after success + +--- + +## Step 4: Export and Register Route + +### Export Router + +```typescript +// apps/api/src/routes/onboarding/index.ts +export * from './onboarding'; +``` + +### Register in Main App + +```typescript +// apps/api/src/app.ts +import { onboardingRouter } from '@api/routes/onboarding'; + +export const app = new Hono() + // ... middleware ... + + // Routes + .route('/account', accountRouter) + .route('/onboarding', onboardingRouter) // Add new route + .route('/course', courseRouter) + .route('/mail', mailRouter) + + // ... error handling ... +``` + +--- + +## Step 5: Update Frontend + +**Location**: `apps/dashboard/src/lib/utils/services/api.ts` (or similar) + +### Setup Hono RPC Client + +The frontend uses Hono's RPC client for type-safe API calls: + +```typescript +// apps/dashboard/src/lib/utils/services/api.ts +import { hc } from 'hono/client'; +import type { app } from '@api/app'; + +export const classroomio = hc(import.meta.env.VITE_API_URL || 'http://localhost:3000', { + credentials: 'include', // Important: sends cookies for authentication + headers: { + 'Content-Type': 'application/json' + } +}); +``` + +### Using API in Components + +Replace Supabase direct database calls with API calls: + +```typescript +// Before: Direct Supabase call +const { data, error } = await supabase + .from('organization') + .insert({ + name: fields.orgName, + siteName: fields.siteName + }) + .select(); + +// After: Type-safe API call +try { + const response = await classroomio.onboarding.step1.$post({ + json: { + fullname: fields.fullname, + orgName: fields.orgName, + siteName: fields.siteName + } + }); + + const data = await response.json(); + + if (!data.success) { + // Handle error + errors.siteName = data.error; + return; + } + + // Success - use data.data.organization, data.data.member, etc. + const { organization, member, organizations } = data.data; +} catch (error) { + console.error('API call failed:', error); + // Handle network error +} +``` + +### Complete Frontend Example + +Example with **client-side validation** before API call: + +```typescript +// apps/dashboard/src/routes/course/+page.svelte + + +
+
+ + + {#if errors.title} + {errors.title} + {/if} +
+ +
+ + + {#if errors.slug} + {errors.slug} + {/if} +
+ + +
+``` + +### Frontend Best Practices + +#### ✅ DO + +- **Use type-safe RPC client** for API calls (Hono RPC) +- **Validate client-side** before API calls using shared schemas from `@cio/utils/validation` +- **Use entity-specific translations** when multiple entities share field names + + ```typescript + mapZodErrorsToTranslations(error, 'course') // "Course title is required" + ``` + +- **Provide generic translations** as fallbacks for shared fields (email, password) +- **Handle both API errors and network errors** separately +- **Show specific field errors** when validation or API returns them +- **Keep UI responsive** during API calls (loading states) +- **Optimistically update UI** when appropriate +- **Handle different HTTP status codes** appropriately +- **Show user-friendly, translated error messages** + +#### ❌ DON'T + +- Skip error handling assuming API calls always succeed +- Display raw API error messages without formatting +- Skip client-side validation (validate before API call for instant feedback) +- Hardcode error messages in schemas (use translation system) +- Use the same generic message for all entities (use entity-specific when needed) +- Make multiple simultaneous calls that should be atomic +- Store sensitive data in frontend state longer than necessary +- Retry failed requests automatically without user action (for mutations) +- Trust client-side validation alone (API always validates too) + +--- + +## Public API Considerations + +Since routes may become public APIs, consider these additional aspects: + +### 1. **API Versioning** + +Prepare for versioning from the start: + +```typescript +// Current approach (v1 implied) +.route('/account', accountRouter) + +// When versioning becomes necessary +.route('/v1/account', accountRouterV1) +.route('/v2/account', accountRouterV2) +``` + +### 2. **Rate Limiting** + +Already implemented globally, but consider per-route limits: + +```typescript +import rateLimiter from '@api/middlewares/rate-limiter'; + +// Higher rate limit for read operations +.get('/courses', rateLimiter({ limit: 100, window: 60000 }), async (c) => {}) + +// Lower rate limit for write operations +.post('/create', rateLimiter({ limit: 10, window: 60000 }), async (c) => {}) +``` + +### 3. **API Documentation** + +Add JSDoc comments to document your routes: + +```typescript +/** + * POST /api/onboarding/create-org + * Creates organization during onboarding + * + * @requires Authentication + * @returns {201} Created - Organization created successfully + * @returns {409} Conflict - Sitename already exists + */ +.post('/create-org', authMiddleware, zValidator('json', createOrgSchema), async (c) => { + // ... implementation +}); +``` + +### 4. **Pagination Standards** + +For list endpoints, use consistent pagination: + +```typescript +const paginationSchema = z.object({ + page: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1)).default('1'), + limit: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().min(1).max(100)).default('20'), + sort: z.enum(['asc', 'desc']).default('desc'), + sortBy: z.string().default('createdAt') +}); + +.get('/list', zValidator('query', paginationSchema), async (c) => { + const { page, limit, sort, sortBy } = c.req.valid('query'); + + const offset = (page - 1) * limit; + const items = await getItems({ offset, limit, sort, sortBy }); + const total = await getItemsCount(); + + return c.json({ + success: true, + data: items, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + hasMore: page * limit < total + } + }); +}); +``` + +### 5. **Filtering and Search** + +Standard query parameter naming: + +```typescript +const searchSchema = z.object({ + q: z.string().optional(), // General search query + filter: z.string().optional(), // Specific field filter (JSON or comma-separated) + status: z.enum(['active', 'inactive', 'archived']).optional(), + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional() +}); +``` + +### 6. **Security** + +Ensure the following (already configured globally in `app.ts`): + +- CORS properly configured for trusted origins +- Security headers set +- Rate limiting appropriate for endpoint +- Authentication enforced where needed + +--- + +## Testing Routes + +### Unit Testing Queries + +```typescript +// packages/db/src/queries/__tests__/organization.test.ts +import { describe, it, expect } from 'vitest'; +import { createOrganization, checkSiteNameExists } from '../organization'; + +describe('Organization Queries', () => { + it('should create an organization', async () => { + const org = await createOrganization({ + name: 'Test Org', + siteName: 'test-org' + }); + + expect(org).toHaveProperty('id'); + expect(org.name).toBe('Test Org'); + }); + + it('should check if sitename exists', async () => { + await createOrganization({ name: 'Test Org', siteName: 'test-org' }); + + expect(await checkSiteNameExists('test-org')).toBe(true); + expect(await checkSiteNameExists('other-org')).toBe(false); + }); +}); +``` + +### Integration Testing Routes + +```typescript +// apps/api/src/routes/__tests__/onboarding.test.ts +import { describe, it, expect } from 'vitest'; +import { app } from '@api/app'; + +describe('Onboarding Routes', () => { + it('should create organization with valid data', async () => { + const res = await app.request('/onboarding/create-org', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fullname: 'John Doe', + orgName: 'Test School', + siteName: 'test-school' + }) + }); + + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it('should return 409 for duplicate sitename', async () => { + // Create first, then attempt duplicate + const res = await app.request('/onboarding/create-org', { + method: 'POST', + body: JSON.stringify({ /* duplicate data */ }) + }); + + expect(res.status).toBe(409); + expect((await res.json()).code).toBe('SITENAME_EXISTS'); + }); +}); +``` + +--- + +## Checklist for New Routes + +Before marking a route as complete, ensure: + +- [ ] **Queries Layer** + - [ ] Queries are in appropriate `packages/db/src/queries/{domain}` folder + - [ ] All queries are pure functions (no side effects) + - [ ] Queries use Drizzle ORM with type inference + - [ ] **Use exported types from `@cio/db/types`** (`TNew{Table}`, `T{Table}`) + - [ ] Use `TNew{Table}` for creates, `Partial` for updates + - [ ] No runtime validation in queries (validation happens at API route layer) + - [ ] JSDoc comments added to all exported functions + - [ ] Queries are exported in index files + +- [ ] **Service Layer** + - [ ] Service file created in `apps/api/src/services/{domain}.ts` + - [ ] Business logic moved from routes to services + - [ ] `AppError` used with appropriate error codes and status codes + - [ ] Transactions handled in services + - [ ] Business rules validated (e.g., uniqueness checks) + - [ ] Services return structured data (not HTTP responses) + - [ ] JSDoc comments explaining business logic + - [ ] Services exported in index files + +- [ ] **Route Layer** + - [ ] Route file created in `apps/api/src/routes/{domain}` + - [ ] All inputs validated with Zod schemas + - [ ] **Validation schemas defined in `packages/utils/src/validation/{entity}/`** + - [ ] Schemas imported from `@cio/utils/validation/{entity}` in route handlers + - [ ] Schemas are clean (no hardcoded error messages) + - [ ] Translation keys documented in schema comments + - [ ] Authentication middleware applied where needed + - [ ] Routes call services (not queries directly) + - [ ] **`handleError` utility used in all catch blocks** + - [ ] Custom fallback messages provided to `handleError` for context + - [ ] Consistent response format used (`{ success: true/false, data/error, code? }`) + - [ ] Route documented with JSDoc comments + +- [ ] **Registration** + - [ ] Router exported in route's `index.ts` + - [ ] Route registered in main `app.ts` + +- [ ] **Frontend** + - [ ] RPC client calls replace direct database calls + - [ ] Shared validation schemas imported from `@cio/utils/validation` + - [ ] Client-side validation before API calls + - [ ] Entity-specific translations used when needed (e.g., `mapZodErrorsToTranslations(error, 'course')`) + - [ ] Generic translations provided as fallbacks + - [ ] Translation keys follow convention: `validations.{entity}.{field}.{errorCode}` + - [ ] Error handling for both validation, API, and network errors + - [ ] Loading states implemented + - [ ] Field-specific errors displayed + - [ ] User-friendly, translated error messages shown + +- [ ] **Public API Ready** + - [ ] Route can work independently of frontend + - [ ] No sensitive information exposed in responses + - [ ] Rate limiting appropriate for endpoint + - [ ] Documentation complete and accurate + - [ ] Versioning strategy considered + +- [ ] **Testing** (Optional but recommended) + - [ ] Query unit tests written + - [ ] Route integration tests written + - [ ] Error cases tested + - [ ] Authentication/authorization tested + +--- + +## Common Patterns + +### Transactional Operations + +```typescript +const result = await db.transaction(async (tx) => { + const org = await createOrganization({ name, siteName }); + const member = await createOrganizationMember({ orgId: org.id, userId }); + return { org, member }; +}); +``` + +### Authorization Checks + +```typescript +.patch('/update/:id', authMiddleware, async (c) => { + const id = c.req.param('id'); + const user = c.get('user'); + + const hasAccess = await checkUserAccessToOrg(user.id, id); + if (!hasAccess) { + return c.json({ success: false, error: 'Forbidden' }, 403); + } + + const updated = await updateOrganization(id, updates); + return c.json({ success: true, data: updated }, 200); +}); +``` + +### Bulk Operations + +```typescript +.post('/bulk', authMiddleware, zValidator('json', z.object({ + items: z.array(itemSchema).min(1).max(100) +})), async (c) => { + const { items } = c.req.valid('json'); + + const results = await db.transaction(async (tx) => { + return await Promise.all(items.map(item => createItem(item))); + }); + + return c.json({ success: true, data: results }, 201); +}); +``` + +--- + +## Summary + +This four-layer architecture ensures: + +### **Layer Separation** + +1. **Shared Validation Layer**: Zod schemas shared between API and frontend +2. **Routes (HTTP Layer)**: Handle requests/responses, validation, authentication +3. **Services (Business Logic)**: Orchestrate queries, manage transactions, business rules +4. **Queries (Data Layer)**: Pure database operations, type-safe +5. **Frontend (UI Layer)**: Type-safe API consumption via RPC with client-side validation + +### **Benefits** + +1. **Maintainability**: Clear separation of concerns across all layers +2. **Testability**: Each layer can be tested independently +3. **Reusability**: Queries and services can be used across multiple routes +4. **Type Safety**: End-to-end type safety from database to frontend +5. **Public API Ready**: Routes designed to be exposed as public APIs +6. **Consistency**: Standardized response formats and error handling +7. **Security**: Authentication, validation, and proper error handling +8. **Scalability**: Easy to add new features following established patterns +9. **Developer Experience**: Type-safe RPC client, clear error messages + +### **Example Flow** + +```txt +Frontend Form (validates with @cio/utils/validation) + ↓ +Frontend Request (with validated data) + ↓ +Route (validates input again with @cio/utils/validation, checks auth) + ↓ +Service (business logic, transactions) + ↓ +Queries (database operations) + ↓ +Database + ↓ +Queries (return type-safe data) + ↓ +Service (return structured results) + ↓ +Route (format HTTP response) + ↓ +Frontend Response (maps errors to translations if validation failed) +``` + +By following these patterns, our API remains **consistent**, **secure**, **testable**, and **ready for public consumption**. diff --git a/apps/api/.gitignore b/apps/api/.gitignore index 80647510..006c10c2 100644 --- a/apps/api/.gitignore +++ b/apps/api/.gitignore @@ -1,4 +1,5 @@ node_modules -.env +.env* .DS_Store -dist \ No newline at end of file +dist +tsconfig.tsbuildinfo \ No newline at end of file diff --git a/apps/api/fly.toml b/apps/api/fly.toml deleted file mode 100644 index 009283bc..00000000 --- a/apps/api/fly.toml +++ /dev/null @@ -1,18 +0,0 @@ -# fly.toml app configuration file generated for classroomio-server on 2023-07-27T21:01:09+01:00 -# -# See https://fly.io/docs/reference/configuration/ for information about how to use this file. -# - -app = "classroomio-server" -primary_region = "lhr" - -[build] - dockerfile = "Dockerfile" - -[http_service] - internal_port = 3002 - force_https = true - auto_stop_machines = true - auto_start_machines = true - min_machines_running = 0 - processes = ["app"] diff --git a/apps/api/package.json b/apps/api/package.json index ebfff36b..0d9e2f92 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,8 +4,11 @@ "description": "ClassroomIO Backend for long processes", "main": "dist/index.js", "type": "module", + "files": [ + "dist" + ], "scripts": { - "build": "tsc && tsc-alias", + "build": "rm -rf dist tsconfig.tsbuildinfo && tsc --build && tsc-alias", "dev": "pnpm --filter @cio/api --parallel \"/^dev:.*/\"", "dev:server": "tsx watch src/index.ts", "dev:tsc": "tsc -p tsconfig.json --watch", @@ -36,6 +39,9 @@ "dependencies": { "@aws-sdk/client-s3": "^3.388.0", "@aws-sdk/s3-request-presigner": "^3.675.0", + "@cio/db": "workspace:*", + "@cio/email": "workspace:*", + "@cio/utils": "workspace:*", "@hono/node-server": "^1.8.2", "@hono/standard-validator": "^0.1.5", "@hono/zod-validator": "^0.7.2", @@ -43,6 +49,7 @@ "@sentry/profiling-node": "^7.110.1", "@supabase/supabase-js": "^2.32.0", "axios": "^1.4.0", + "bcrypt": "^6.0.0", "dotenv": "^16.3.1", "hono": "^4.9.7", "hono-openapi": "^1.0.8", @@ -51,15 +58,13 @@ "katex": "^0.16.10", "marked": "^7.0.3", "nanoid": "^5.1.5", - "nodemailer": "^6.9.5", - "zeptomail": "^6.0.0", "zod": "^4.1.8", "zod-openapi": "^5.4.1" }, "devDependencies": { + "@types/bcrypt": "^6.0.0", "@types/katex": "^0.16.7", "@types/node": "^20.19.0", - "@types/nodemailer": "^6.4.17", "@typescript-eslint/eslint-plugin": "7.0.1", "@typescript-eslint/parser": "7.0.1", "@vitest/coverage-v8": "^1.2.2", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 2025933e..67def09d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,12 +1,18 @@ import 'dotenv/config'; -import { Hono } from 'hono'; +import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants'; + +import { Hono } from '@api/utils/hono'; +import { accountRouter } from '@api/routes/account'; +import { auth } from '@cio/db/auth'; import { cors } from 'hono/cors'; -import { courseRouter } from '@api/routes/course/course'; +import { courseRouter } from '@api/routes/course'; import { logger } from 'hono/logger'; import { mailRouter } from '@api/routes/mail'; +import { onboardingRouter } from '@api/routes/onboarding'; +import { organizationRouter } from '@api/routes/organization'; import { prettyJSON } from 'hono/pretty-json'; -import { rateLimiterMiddleware } from '@api/middlewares/rate-limiter'; +import rateLimiter from '@api/middlewares/rate-limiter'; import { secureHeaders } from 'hono/secure-headers'; // Create Hono app with chaining for RPC support @@ -18,7 +24,7 @@ export const app = new Hono() .use( '*', cors({ - origin: '*', + origin: TRUSTED_ORIGINS, allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowHeaders: ['Content-Type', 'Authorization'], exposeHeaders: ['Content-Length', 'X-Kuma-Revision'], @@ -26,16 +32,46 @@ export const app = new Hono() credentials: true }) ) - .use('*', rateLimiterMiddleware) + .use('*', rateLimiter) + .use('*', async (c, next) => { + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + + if (!session) { + c.set('user', null); + c.set('session', null); + await next(); + + return; + } + + c.set('user', session.user); + c.set('session', session.session); + await next(); + }) // Routes .get('/', (c) => c.json({ - message: '"Welcome to Classroomio.com API - docs are at https://api.classroomio.com/docs"' + message: `"Welcome to Classroomio.com API - docs are at ${API_SERVER_URL}/docs"` }) ) + .on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)) + .get('/session', async (c) => { + const session = c.get('session'); + const user = c.get('user'); + + if (!user) return c.body(null, 401); + + return c.json({ + session, + user + }); + }) + .route('/onboarding', onboardingRouter) + .route('/account', accountRouter) .route('/course', courseRouter) .route('/mail', mailRouter) + .route('/organization', organizationRouter) // Error handling .onError((err, c) => { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index e2fc8005..ea03c3f3 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -9,16 +9,18 @@ const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), OPENAPI_URL: z.string().optional(), PORT: z.string().optional(), + PRIVATE_SUPABASE_SERVICE_ROLE: z.string(), PUBLIC_SUPABASE_ANON_KEY: z.string(), PUBLIC_SUPABASE_URL: z.string(), - PRIVATE_SUPABASE_SERVICE_ROLE: z.string(), REDIS_URL: z.string().optional(), + SERVER_URL: z.string().optional(), SMTP_HOST: z.string().optional(), SMTP_PASSWORD: z.string().optional(), SMTP_PORT: z.string().optional(), SMTP_SENDER: z.string().optional(), SMTP_USER: z.string().optional(), - ZOHO_TOKEN: z.string().optional() + ZOHO_TOKEN: z.string().optional(), + TRUSTED_ORIGINS: z.string().optional() }); export const env = envSchema.parse(process.env); diff --git a/apps/api/src/constants/index.ts b/apps/api/src/constants/index.ts index 0dff0f1b..b4b360a7 100644 --- a/apps/api/src/constants/index.ts +++ b/apps/api/src/constants/index.ts @@ -2,6 +2,12 @@ import { env } from '@api/config/env'; export * from './rate-limiter'; +export const API_PORT = env.PORT ? parseInt(env.PORT) : 3002; +export const API_SERVER_URL = env.SERVER_URL || `http://localhost:${API_PORT}`; +export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS + ? env.TRUSTED_ORIGINS.split(',') + : ['http://localhost:5173', 'http://*.classroomio.*']; + const { CLOUDFLARE_BUCKET_DOMAIN, CLOUDFLARE_ACCESS_KEY, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 81d75a1c..36736977 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,18 +1,16 @@ import 'dotenv/config'; +import { API_PORT } from '@api/constants'; import { app } from '@api/app'; import { configureOpenAPI } from '@api/utils/openapi'; -import { env } from '@api/config/env'; import { serve } from '@hono/node-server'; import { showRoutes } from 'hono/dev'; // Start server -const port = env.PORT ? parseInt(env.PORT) : 3002; - function startServer() { - console.log('Starting server on port:', port); + console.log('Starting server on port:', API_PORT); - serve({ fetch: app.fetch, port }); + serve({ fetch: app.fetch, port: API_PORT }); showRoutes(app, { colorize: true }); } diff --git a/apps/api/src/middlewares/auth.ts b/apps/api/src/middlewares/auth.ts index d47d663e..4f56e047 100644 --- a/apps/api/src/middlewares/auth.ts +++ b/apps/api/src/middlewares/auth.ts @@ -1,36 +1,11 @@ import { Context, Next } from 'hono'; -import { validateUser } from '@api/utils/auth/validate-user'; - export const authMiddleware = async (c: Context, next: Next) => { try { - const authHeader = c.req.header('authorization'); - - if (!authHeader) { - return c.json( - { - success: false, - message: 'No authorization header provided' - }, - 401 - ); + if (!c.get('user') || !c.get('session')) { + throw new Error('Unauthorized'); } - // Extract the token from "Bearer " - const token = authHeader.split(' ')[1]; - - if (!token) { - return c.json( - { - success: false, - message: 'Invalid authorization header format' - }, - 401 - ); - } - - const user = await validateUser(token); - c.set('user', user); // Attach the user to the context await next(); } catch (error) { return c.json( diff --git a/apps/api/src/middlewares/rate-limiter.ts b/apps/api/src/middlewares/rate-limiter.ts index df3d6f96..c33396f0 100644 --- a/apps/api/src/middlewares/rate-limiter.ts +++ b/apps/api/src/middlewares/rate-limiter.ts @@ -76,4 +76,4 @@ export const createRateLimiter = (options: RateLimiterOptions = {}): MiddlewareH }; // Default rate limiter middleware with standard configuration -export const rateLimiterMiddleware = createRateLimiter(); +export default createRateLimiter(); diff --git a/apps/api/src/routes/account/account.ts b/apps/api/src/routes/account/account.ts new file mode 100644 index 00000000..101bca8d --- /dev/null +++ b/apps/api/src/routes/account/account.ts @@ -0,0 +1,49 @@ +import { getAccountData, updateUser } from '@api/services/account'; + +import { Hono } from '@api/utils/hono'; +import { ZUpdateProfile } from '@cio/utils/validation/account'; +import { authMiddleware } from '@api/middlewares/auth'; +import { handleError } from '@api/utils/errors'; +import { zValidator } from '@hono/zod-validator'; + +export const accountRouter = new Hono() + .get('/', authMiddleware, async (c) => { + const user = c.get('user'); + + try { + console.time('accountRouter'); + const accountData = await getAccountData(user.id); + console.timeEnd('accountRouter'); + + return c.json( + { + success: true, + user, + profile: accountData.profile, + organizations: accountData.organizations + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to fetch account data'); + } + }) + .put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => { + const user = c.get('user'); + + try { + const validatedData = c.req.valid('json'); + + const updatedProfile = await updateUser(user.id, validatedData); + + return c.json( + { + success: true, + profile: updatedProfile + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to update profile'); + } + }); diff --git a/apps/api/src/routes/account/index.ts b/apps/api/src/routes/account/index.ts new file mode 100644 index 00000000..362a768e --- /dev/null +++ b/apps/api/src/routes/account/index.ts @@ -0,0 +1 @@ +export * from './account'; diff --git a/apps/api/src/routes/course/clone.ts b/apps/api/src/routes/course/clone.ts index 6cec2a77..773bf6b7 100644 --- a/apps/api/src/routes/course/clone.ts +++ b/apps/api/src/routes/course/clone.ts @@ -1,42 +1,32 @@ -import { Hono } from 'hono'; -import { ZCourseClone, TCourseClone } from '@api/types/course'; -import { zValidator } from '@hono/zod-validator'; -import { cloneCourse } from '@api/services/course/clone'; +import type { CloneCourseErrorResponse, CloneCourseResponse } from '@api/types/database'; + +import { Hono } from '@api/utils/hono'; +import { ZCourseClone } from '@cio/utils/validation/course'; import { authMiddleware } from '@api/middlewares/auth'; -import type { CloneCourseResponse, CloneCourseErrorResponse } from '@api/types/database'; -import type { User } from '@supabase/supabase-js'; +import { cloneCourse } from '@api/services/course/clone'; +import { zValidator } from '@hono/zod-validator'; -type Variables = { - user: User; -}; +export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => { + try { + const validatedData = c.req.valid('json'); + const { id, title, description, slug, organizationId } = validatedData; -export const cloneRouter = new Hono<{ Variables: Variables }>().post( - '/', - authMiddleware, - zValidator('json', ZCourseClone), - async (c) => { - try { - const validatedData = (c.req.valid as (key: 'json') => TCourseClone)('json'); - const { id, title, description, slug, organizationId } = validatedData; + const user = c.get('user'); - // Get user from context (set by authMiddleware) - const user = c.get('user'); + // Clone the course + const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId); - // Clone the course - const newCourse = await cloneCourse(id, title, user.id, description, slug, organizationId); - - const successResponse: CloneCourseResponse = { - success: true, - course: newCourse - }; - return c.json(successResponse, 200); - } catch (error) { - console.error('Error cloning course:', error); - const errorResponse: CloneCourseErrorResponse = { - error: 'Failed to clone course', - details: error instanceof Error ? error.message : 'Unknown error' - }; - return c.json(errorResponse, 500); - } + const successResponse: CloneCourseResponse = { + success: true, + course: newCourse + }; + return c.json(successResponse, 200); + } catch (error) { + console.error('Error cloning course:', error); + const errorResponse: CloneCourseErrorResponse = { + error: 'Failed to clone course', + details: error instanceof Error ? error.message : 'Unknown error' + }; + return c.json(errorResponse, 500); } -); +}); diff --git a/apps/api/src/routes/course/course.ts b/apps/api/src/routes/course/course.ts index 8662f03c..be1444dc 100644 --- a/apps/api/src/routes/course/course.ts +++ b/apps/api/src/routes/course/course.ts @@ -1,25 +1,22 @@ -import { Hono } from 'hono'; -import { TCourseClone,TCourseDownloadContent, TCertificateDownload, ZCertificateDownload, ZCourseDownloadContent } from '@api/types/course'; +import { ZCertificateDownload, ZCourseDownloadContent } from '@cio/utils/validation/course'; +import { Hono } from '@api/utils/hono'; +import { cloneRouter } from '@api/routes/course/clone'; import { generateCertificate } from '@api/utils/certificate'; import { generateCoursePdf } from '@api/utils/course'; import { katexRouter } from '@api/routes/course/katex'; import { lessonRouter } from '@api/routes/course/lesson'; import { presignRouter } from '@api/routes/course/presign'; -import { cloneRouter } from '@api/routes/course/clone'; import { zValidator } from '@hono/zod-validator'; export const courseRouter = new Hono() .post('/download/certificate', zValidator('json', ZCertificateDownload), async (c) => { - const validatedData = (c.req.valid as (key: 'json') => TCertificateDownload)('json'); + const validatedData = c.req.valid('json'); const result = await generateCertificate(validatedData); c.header('Content-Type', 'application/pdf'); - c.header( - 'Content-Disposition', - `attachment; filename="certificate-${validatedData.courseName}.pdf"` - ); + c.header('Content-Disposition', `attachment; filename="certificate-${validatedData.courseName}.pdf"`); return c.body( new ReadableStream({ @@ -31,7 +28,7 @@ export const courseRouter = new Hono() ); }) .post('/download/content', zValidator('json', ZCourseDownloadContent), async (c) => { - const validatedData = (c.req.valid as (key: 'json') => TCourseDownloadContent)('json'); + const validatedData = c.req.valid('json'); const pdfBuffer = await generateCoursePdf(validatedData); diff --git a/apps/api/src/routes/course/index.ts b/apps/api/src/routes/course/index.ts new file mode 100644 index 00000000..8e278d18 --- /dev/null +++ b/apps/api/src/routes/course/index.ts @@ -0,0 +1 @@ +export * from './course'; diff --git a/apps/api/src/routes/course/katex.ts b/apps/api/src/routes/course/katex.ts index d5b65ad0..8ea6e9a8 100644 --- a/apps/api/src/routes/course/katex.ts +++ b/apps/api/src/routes/course/katex.ts @@ -1,9 +1,9 @@ -import { Context, Hono } from 'hono'; +import { Hono } from '@api/utils/hono'; import katex from 'katex'; import { z } from 'zod'; -export const katexRouter = new Hono().get('/', async (c: Context) => { +export const katexRouter = new Hono().get('/', async (c) => { try { const original = c.req.raw.url; diff --git a/apps/api/src/routes/course/lesson.ts b/apps/api/src/routes/course/lesson.ts index d33df431..f2e4fc21 100644 --- a/apps/api/src/routes/course/lesson.ts +++ b/apps/api/src/routes/course/lesson.ts @@ -1,24 +1,20 @@ -import { Hono } from 'hono'; -import { TLessonDownloadContent, ZLessonDownloadContent } from '@api/types/course/lesson'; +import { Hono } from '@api/utils/hono'; +import { ZLessonDownloadContent } from '@cio/utils/validation/course'; import { generateLessonPdf } from '@api/utils/lesson'; import { zValidator } from '@hono/zod-validator'; -export const lessonRouter = new Hono().post( - '/download/pdf', - zValidator('json', ZLessonDownloadContent), - async (c) => { - const validatedData = (c.req.valid as (key: 'json') => TLessonDownloadContent)('json'); +export const lessonRouter = new Hono().post('/download/pdf', zValidator('json', ZLessonDownloadContent), async (c) => { + const validatedData = c.req.valid('json'); - const pdfBuffer = await generateLessonPdf(validatedData); + const pdfBuffer = await generateLessonPdf(validatedData); - c.header('Content-Type', 'application/pdf'); - return c.body( - new ReadableStream({ - start(controller) { - controller.enqueue(pdfBuffer); - controller.close(); - } - }) - ); - } -); + c.header('Content-Type', 'application/pdf'); + return c.body( + new ReadableStream({ + start(controller) { + controller.enqueue(pdfBuffer); + controller.close(); + } + }) + ); +}); diff --git a/apps/api/src/routes/course/presign.ts b/apps/api/src/routes/course/presign.ts index 5c06b75f..3ad0bee8 100644 --- a/apps/api/src/routes/course/presign.ts +++ b/apps/api/src/routes/course/presign.ts @@ -3,13 +3,13 @@ import { ZCourseDocumentPresignUrlUpload, ZCourseDownloadPresignedUrl, ZCoursePresignUrlUpload -} from '@api/types/course'; +} from '@cio/utils/validation/course'; import { describeRoute, validator } from 'hono-openapi'; import { BUCKET_NAME } from '@api/constants/upload'; import { CLOUDFLARE } from '@api/constants'; import type { GetSignedUrlParameters } from '@api/utils/s3'; -import { Hono } from 'hono'; +import { Hono } from '@api/utils/hono'; import { authMiddleware } from '@api/middlewares/auth'; import { generateFileKey } from '@api/utils/upload'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; diff --git a/apps/api/src/routes/mail.ts b/apps/api/src/routes/mail.ts deleted file mode 100644 index 5c43c18c..00000000 --- a/apps/api/src/routes/mail.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { sendWithNodemailer, sendWithZoho } from '@api/services/mail'; - -import { Hono } from 'hono'; -import { ZSendEmailValidation } from '@api/types/mail'; -import { env } from '@api/config/env'; -import { zValidator } from '@hono/zod-validator'; - -export const mailRouter = new Hono().post( - '/send', - zValidator('json', ZSendEmailValidation), - async (c) => { - const validatedData = c.req.valid('json'); - - const results = await Promise.all( - validatedData.map(async (emailData) => { - try { - const res = env.ZOHO_TOKEN - ? await sendWithZoho(emailData) - : await sendWithNodemailer(emailData); - - console.log('Email status:', res); - return res; - } catch (error) { - console.error('Error sending email:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - details: error - }; - } - }) - ); - - const hasErrors = results.some((result) => !result.success); - if (hasErrors) { - return c.json( - { - success: false, - error: 'Some emails failed to send', - details: results - }, - 500 - ); - } - - return c.json({ success: true, details: results }); - } -); diff --git a/apps/api/src/routes/mail/index.ts b/apps/api/src/routes/mail/index.ts new file mode 100644 index 00000000..efc83f65 --- /dev/null +++ b/apps/api/src/routes/mail/index.ts @@ -0,0 +1 @@ +export * from './mail'; diff --git a/apps/api/src/routes/mail/mail.ts b/apps/api/src/routes/mail/mail.ts new file mode 100644 index 00000000..28fbf0f4 --- /dev/null +++ b/apps/api/src/routes/mail/mail.ts @@ -0,0 +1,31 @@ +import { Hono } from '@api/utils/hono'; +import { ZDeliverEmail } from '@cio/utils/validation/mail'; +import { deliverEmail } from '@cio/email'; +import { handleError } from '@api/utils/errors'; +import { zValidator } from '@hono/zod-validator'; + +export const mailRouter = new Hono().post('/send', zValidator('json', ZDeliverEmail), async (c) => { + const validatedData = c.req.valid('json'); + + try { + // Use service to send emails + const results = await deliverEmail(validatedData); + + const hasErrors = results.some((result) => !result.success); + if (hasErrors) { + return c.json( + { + success: false, + error: 'Some emails failed to send', + code: 'EMAIL_SEND_PARTIAL_FAILURE', + details: results + }, + 500 + ); + } + + return c.json({ success: true, data: results }, 200); + } catch (error) { + return handleError(c, error, 'Failed to process email send request', 'EMAIL_SEND_FAILED'); + } +}); diff --git a/apps/api/src/routes/onboarding/index.ts b/apps/api/src/routes/onboarding/index.ts new file mode 100644 index 00000000..6620955c --- /dev/null +++ b/apps/api/src/routes/onboarding/index.ts @@ -0,0 +1 @@ +export * from './onboarding'; diff --git a/apps/api/src/routes/onboarding/onboarding.ts b/apps/api/src/routes/onboarding/onboarding.ts new file mode 100644 index 00000000..0652b3c2 --- /dev/null +++ b/apps/api/src/routes/onboarding/onboarding.ts @@ -0,0 +1,44 @@ +import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding'; +import { completeOnboarding, createOrganizationWithOwner, updateUserOnboarding } from '@api/services/onboarding'; + +import { Hono } from '@api/utils/hono'; +import { authMiddleware } from '@api/middlewares/auth'; +import { handleError } from '@api/utils/errors'; +import { zValidator } from '@hono/zod-validator'; + +export const onboardingRouter = new Hono() + .post('/step1', authMiddleware, zValidator('json', ZOnboardingStep1), async (c) => { + try { + const user = c.get('user'); + const { fullname, orgName, siteName } = c.req.valid('json'); + + const result = await createOrganizationWithOwner(user.id, { fullname, orgName, siteName }); + + return c.json({ success: true, data: result }, 201); + } catch (error) { + return handleError(c, error, 'Failed to create organization'); + } + }) + .post('/step2', authMiddleware, zValidator('json', ZOnboardingStep2), async (c) => { + try { + const user = c.get('user'); + const { goal, source, fullname } = c.req.valid('json'); + + const result = await updateUserOnboarding(user.id, { goal, source, fullname }); + + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to update onboarding data'); + } + }) + .post('/complete', authMiddleware, async (c) => { + try { + const user = c.get('user'); + + await completeOnboarding(user); + + return c.json({ success: true }, 200); + } catch (error) { + return handleError(c, error, 'Failed to complete onboarding'); + } + }); diff --git a/apps/api/src/routes/organization/index.ts b/apps/api/src/routes/organization/index.ts new file mode 100644 index 00000000..5612e1c5 --- /dev/null +++ b/apps/api/src/routes/organization/index.ts @@ -0,0 +1 @@ +export * from './organization'; diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts new file mode 100644 index 00000000..731008ad --- /dev/null +++ b/apps/api/src/routes/organization/organization.ts @@ -0,0 +1,172 @@ +import { + ZCancelOrgPlan, + ZCreateOrgPlan, + ZGetCoursesBySiteName, + ZGetOrganizationAudience, + ZGetOrganizationTeam, + ZGetOrganizations, + ZUpdateOrgPlan +} from '@cio/utils/validation/organization'; +import { + cancelOrgPlan, + createOrgPlan, + getCoursesByOrgSiteName, + getOrgAudience, + getOrgTeam, + getOrganizationsWithFilters, + updateOrgPlan +} from '@api/services/organization'; + +import { Hono } from '@api/utils/hono'; +import { authMiddleware } from '@api/middlewares/auth'; +import { handleError } from '@api/utils/errors'; +import { zValidator } from '@hono/zod-validator'; + +export const organizationRouter = new Hono() + /** + * GET /organization + * Gets organizations with optional filters + * Query params: siteName (string, optional), customDomain (string, optional), isCustomDomainVerified (boolean, optional) + */ + .get('/', zValidator('query', ZGetOrganizations), async (c) => { + try { + const filters = c.req.valid('query'); + const organizations = await getOrganizationsWithFilters(filters); + + return c.json( + { + success: true, + data: organizations + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to fetch organizations'); + } + }) + /** + * GET /organization/:orgId/team + * Gets organization team members (non-students) + * Requires authentication + */ + .get('/:orgId/team', authMiddleware, zValidator('param', ZGetOrganizationTeam), async (c) => { + try { + const { orgId } = c.req.valid('param'); + const team = await getOrgTeam(orgId); + + return c.json( + { + success: true, + data: team + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to fetch organization team'); + } + }) + /** + * GET /organization/:orgId/audience + * Gets organization audience (students) + * Requires authentication + */ + .get('/:orgId/audience', authMiddleware, zValidator('param', ZGetOrganizationAudience), async (c) => { + try { + const { orgId } = c.req.valid('param'); + const audience = await getOrgAudience(orgId); + + return c.json( + { + success: true, + data: audience + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to fetch organization audience'); + } + }) + /** + * GET /organization/courses + * Gets courses by organization siteName + * Query params: siteName (string) + */ + .get('/courses', zValidator('query', ZGetCoursesBySiteName), async (c) => { + try { + const { siteName } = c.req.valid('query'); + const courses = await getCoursesByOrgSiteName(siteName); + + return c.json( + { + success: true, + data: courses + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to fetch courses'); + } + }) + /** + * POST /organization/plan + * Creates a new organization plan + * Requires authentication + */ + .post('/plan', authMiddleware, zValidator('json', ZCreateOrgPlan), async (c) => { + try { + const data = c.req.valid('json'); + const plan = await createOrgPlan(data); + + return c.json( + { + success: true, + data: plan + }, + 201 + ); + } catch (error) { + return handleError(c, error, 'Failed to create organization plan'); + } + }) + /** + * PUT /organization/plan + * Updates an organization plan by subscription ID + * Requires authentication + */ + .put('/plan', authMiddleware, zValidator('json', ZUpdateOrgPlan), async (c) => { + try { + const { subscriptionId, payload } = c.req.valid('json'); + const plan = await updateOrgPlan(subscriptionId, payload); + + return c.json( + { + success: true, + data: plan + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to update organization plan'); + } + }) + /** + * POST /organization/plan/cancel + * Cancels an organization plan by subscription ID + * Requires authentication + */ + .post('/plan/cancel', authMiddleware, zValidator('json', ZCancelOrgPlan), async (c) => { + try { + const { subscriptionId, payload } = c.req.valid('json'); + const plan = await cancelOrgPlan(subscriptionId, payload); + + return c.json( + { + success: true, + data: plan + }, + 200 + ); + } catch (error) { + return handleError(c, error, 'Failed to cancel organization plan'); + } + }); diff --git a/apps/api/src/rpc-types.ts b/apps/api/src/rpc-types.ts index 0e526f63..141eefe2 100644 --- a/apps/api/src/rpc-types.ts +++ b/apps/api/src/rpc-types.ts @@ -1,9 +1,7 @@ import { app } from './app'; import { hc } from 'hono/client'; -import type { InferRequestType, InferResponseType } from 'hono/client'; +export type { InferRequestType, InferResponseType } from 'hono/client'; export type Client = ReturnType>; export const hcWithType = (...args: Parameters): Client => hc(...args); - -export type { InferRequestType, InferResponseType }; diff --git a/apps/api/src/services/account.ts b/apps/api/src/services/account.ts new file mode 100644 index 00000000..56197845 --- /dev/null +++ b/apps/api/src/services/account.ts @@ -0,0 +1,59 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { getProfileById, updateProfile } from '@cio/db/queries/auth'; + +import type { TProfile } from '@cio/db/types'; +import { getOrganizationByProfileId } from '@cio/db/queries/organization'; + +/** + * Fetches account data including profile and organizations for a user + * @param userId - The user ID to fetch account data for + * @returns Account data with profile and organizations + */ +export async function getAccountData(userId: string) { + const [profile, organizations] = await Promise.all([getProfileById(userId), getOrganizationByProfileId(userId)]); + + if (!profile) { + throw new AppError(`Account not found for user ID: ${userId}`, ErrorCodes.ACCOUNT_NOT_FOUND, 404); + } + + return { + profile, + organizations + }; +} + +/** + * Updates profile data for a user + * @param userId - The user ID to update profile for + * @param data - Partial profile data to update (excluding email, id, createdAt, updatedAt) + * @returns Updated profile data + */ +export async function updateUser( + userId: string, + data: Partial> +) { + try { + const updatedProfile = await updateProfile(userId, data); + + if (!updatedProfile) { + throw new AppError(`Profile not found for user ID: ${userId}`, ErrorCodes.PROFILE_NOT_FOUND, 404); + } + + return updatedProfile; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + // Handle unique constraint violations + if (error instanceof Error && error.message.includes('profile_username_key')) { + throw new AppError('Username already exists', ErrorCodes.VALIDATION_ERROR, 400, 'username'); + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to update profile', + ErrorCodes.PROFILE_UPDATE_FAILED, + 500 + ); + } +} diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts new file mode 100644 index 00000000..a589bfa9 --- /dev/null +++ b/apps/api/src/services/index.ts @@ -0,0 +1,3 @@ +export * from './account'; +export * from './onboarding'; +export * from './mail'; diff --git a/apps/api/src/services/mail.ts b/apps/api/src/services/mail.ts index 42d54960..3720e4fa 100644 --- a/apps/api/src/services/mail.ts +++ b/apps/api/src/services/mail.ts @@ -1,13 +1,9 @@ -import { extractNameAndEmail, withEmailTemplate } from '@api/utils/mail'; -import { nodemailerTransporter, zohoClient } from '@api/utils/email'; +import { extractNameAndEmail, nodemailerTransporter, withEmailTemplate, zohoClient } from '@api/utils/mail'; -import { Hono } from 'hono'; -import type { TSendEmailValidation } from '@api/types/mail'; +import type { TEmailData } from '@cio/utils/validation/mail'; import type { Transporter } from 'nodemailer'; import { env } from '@api/config/env'; -export const mailRouter = new Hono(); - interface EmailResponse { success: boolean; error?: string; @@ -20,9 +16,7 @@ nodemailerTransporter().then((t: Transporter | null) => { transporter = t; }); -export async function sendWithNodemailer( - emailData: TSendEmailValidation[0] -): Promise { +export async function sendWithNodemailer(emailData: TEmailData): Promise { const { from, to, subject, content, replyTo } = emailData; if (!transporter) { @@ -54,7 +48,7 @@ export async function sendWithNodemailer( } } -export async function sendWithZoho(emailData: TSendEmailValidation[0]): Promise { +export async function sendWithZoho(emailData: TEmailData): Promise { const { from, to, subject, content } = emailData; const fromData = extractNameAndEmail(from || ''); diff --git a/apps/api/src/services/onboarding.ts b/apps/api/src/services/onboarding.ts new file mode 100644 index 00000000..17550461 --- /dev/null +++ b/apps/api/src/services/onboarding.ts @@ -0,0 +1,99 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import type { TProfile, TUser } from '@cio/db/types'; +import { + checkSiteNameExists, + createOrganization, + createOrganizationMember, + getOrganizationByProfileId +} from '@cio/db/queries'; +import { getProfileById, updateProfile } from '@cio/db/queries/auth'; + +import { ROLE } from '@cio/utils/constants'; +import { db } from '@cio/db/drizzle'; +import { sendEmail } from '@cio/email'; + +export async function createOrganizationWithOwner( + profileId: string, + input: { + fullname?: string; + orgName: string; + siteName: string; + } +) { + // Business Logic: Check sitename availability + const exists = await checkSiteNameExists(input.siteName); + if (exists) { + console.error('Site name already exists:', input.siteName); + throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409); + } + + // Business Logic: Create org and member in a transaction + try { + const result = await db.transaction(async () => { + const organization = await createOrganization({ + name: input.orgName, + siteName: input.siteName + }); + + const member = await createOrganizationMember({ + organizationId: organization.id, + profileId, + roleId: ROLE.ADMIN, + verified: true + }); + + return { organization, member }; + }); + + // Fetch updated organizations list + const organizations = await getOrganizationByProfileId(profileId); + + return { + organization: result.organization, + member: result.member, + organizations + }; + } catch (error) { + console.error('Error creating organization:', error); + // Handle database constraint violations + if (error.code === '23505') { + throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409); + } + throw new AppError(error, ErrorCodes.ORG_CREATE_FAILED, 500); + } +} + +export async function updateUserOnboarding(userId: string, data: Partial) { + try { + const updatedProfile = await updateProfile(userId, data); + + if (!updatedProfile) { + throw new AppError('Failed to update profile - profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404); + } + + return updatedProfile; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError(error, ErrorCodes.PROFILE_UPDATE_FAILED, 500); + } +} + +export async function completeOnboarding(user: Partial) { + try { + const profile = await getProfileById(user.id); + if (!profile) { + throw new AppError('Profile not found', ErrorCodes.PROFILE_NOT_FOUND, 404); + } + + await sendEmail('welcome', { + to: user.email, + fields: { + name: profile.fullname + } + }); + } catch (error) { + throw new AppError(error, ErrorCodes.INTERNAL_ERROR, 500); + } +} diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts new file mode 100644 index 00000000..4099f114 --- /dev/null +++ b/apps/api/src/services/organization.ts @@ -0,0 +1,165 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import type { TNewOrganizationPlan, TOrganizationPlan, TPlan } from '@db/types'; +import { + cancelOrganizationPlan, + createOrganizationPlan, + getOrganizationAudience, + getOrganizationTeam, + getOrganizations, + updateOrganizationPlan +} from '@cio/db/queries/organization'; + +import { getCoursesBySiteName } from '@cio/db/queries/course'; + +/** + * Gets organizations with optional filters + * @param filters - Filter options (siteName, customDomain, isCustomDomainVerified) + * @returns Array of organizations with plans + */ +export async function getOrganizationsWithFilters(filters?: { + siteName?: string; + customDomain?: string; + isCustomDomainVerified?: boolean; +}) { + try { + const organizations = await getOrganizations(filters); + return organizations; + } catch (error) { + throw new AppError( + error instanceof Error ? error.message : 'Failed to fetch organizations', + ErrorCodes.ORGANIZATION_NOT_FOUND, + 500 + ); + } +} + +/** + * Gets organization team members (non-students) + * @param orgId - The organization ID + * @returns Array of team members + */ +export async function getOrgTeam(orgId: string) { + try { + const team = await getOrganizationTeam(orgId); + return team; + } catch (error) { + throw new AppError( + error instanceof Error ? error.message : 'Failed to fetch organization team', + ErrorCodes.ORG_TEAM_FETCH_FAILED, + 500 + ); + } +} + +/** + * Gets organization audience (students) + * @param orgId - The organization ID + * @returns Array of student profiles + */ +export async function getOrgAudience(orgId: string) { + try { + const audience = await getOrganizationAudience(orgId); + return audience; + } catch (error) { + throw new AppError( + error instanceof Error ? error.message : 'Failed to fetch organization audience', + ErrorCodes.ORG_AUDIENCE_FETCH_FAILED, + 500 + ); + } +} + +/** + * Gets courses by organization siteName + * @param siteName - The organization site name + * @returns Array of courses + */ +export async function getCoursesByOrgSiteName(siteName: string) { + try { + const courses = await getCoursesBySiteName(siteName); + return courses; + } catch (error) { + throw new AppError( + error instanceof Error ? error.message : 'Failed to fetch courses', + ErrorCodes.COURSES_FETCH_FAILED, + 500 + ); + } +} + +/** + * Creates a new organization plan + * @param data - Organization plan creation data + * @returns Created organization plan + */ +export async function createOrgPlan(data: TNewOrganizationPlan) { + try { + const plan = await createOrganizationPlan({ + orgId: data.orgId, + planName: data.planName, + subscriptionId: data.subscriptionId, + triggeredBy: data.triggeredBy, + payload: data.payload, + isActive: true, + provider: 'polar' + }); + return plan; + } catch (error) { + throw new AppError( + error instanceof Error ? error.message : 'Failed to create organization plan', + ErrorCodes.ORG_PLAN_CREATE_FAILED, + 500 + ); + } +} + +/** + * Updates an organization plan + * @param subscriptionId - Subscription ID + * @param payload - Payload data to update + * @returns Updated organization plan + */ +export async function updateOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) { + try { + const plan = await updateOrganizationPlan(subscriptionId, payload); + if (!plan) { + throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404); + } + return plan; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + throw new AppError( + error instanceof Error ? error.message : 'Failed to update organization plan', + ErrorCodes.ORG_PLAN_UPDATE_FAILED, + 500 + ); + } +} + +/** + * Cancels an organization plan + * @param subscriptionId - Subscription ID + * @param payload - Payload data to update + * @returns Updated organization plan + */ +export async function cancelOrgPlan(subscriptionId: string, payload: TOrganizationPlan['payload']) { + try { + const plan = await cancelOrganizationPlan(subscriptionId, payload); + if (!plan) { + throw new AppError('Organization plan not found', ErrorCodes.ORG_PLAN_NOT_FOUND, 404); + } + return plan; + } catch (error) { + if (error instanceof AppError) { + throw error; + } + + throw new AppError( + error instanceof Error ? error.message : 'Failed to cancel organization plan', + ErrorCodes.ORG_PLAN_CANCEL_FAILED, + 500 + ); + } +} diff --git a/apps/api/src/types/auth.ts b/apps/api/src/types/auth.ts new file mode 100644 index 00000000..7fb0e3c1 --- /dev/null +++ b/apps/api/src/types/auth.ts @@ -0,0 +1,8 @@ +import { auth } from '@cio/db/auth'; + +export type AuthSession = { + Variables: { + user: typeof auth.$Infer.Session.user | null; + session: typeof auth.$Infer.Session.session | null; + }; +}; diff --git a/apps/api/src/types/course/index.ts b/apps/api/src/types/course/index.ts deleted file mode 100644 index 40ec48a9..00000000 --- a/apps/api/src/types/course/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '@api/constants/upload'; - -import { z } from 'zod'; - -export * from './lesson'; - -export const ZCourseDownloadContent = z.object({ - courseTitle: z.string(), - orgName: z.string(), - orgTheme: z.string(), - lessons: z.array( - z.object({ - lessonTitle: z.string(), - lessonNumber: z.string(), - lessonNote: z.string(), - slideUrl: z.string().optional(), - video: z.array(z.string()).optional() - }) - ) -}); - -export type TCourseDownloadContent = z.infer; - -export const ZCoursePresignUrlUpload = z.object({ - fileName: z.string().min(1, 'File name is required'), - fileType: z.enum(ALLOWED_CONTENT_TYPES, { - error: () => ({ - message: `Invalid content type. Allowed types: ${ALLOWED_CONTENT_TYPES.join(', ')}` - }) - }) -}); - -export type TCoursePresignUrlUpload = z.infer; - -export const ZCourseDocumentPresignUrlUpload = z.object({ - fileName: z.string().min(1, 'File name is required'), - fileType: z.enum(ALLOWED_DOCUMENT_TYPES, { - error: () => ({ - message: `Invalid content type. Allowed types: ${ALLOWED_DOCUMENT_TYPES.join(', ')}` - }) - }) -}); - -export type TCourseDocumentPresignUrlUpload = z.infer; - -export const ZCourseDownloadPresignedUrl = z.object({ - keys: z.array(z.string()).min(1, 'File names array is required') -}); - -export type TCourseDownloadPresignedUrl = z.infer; - -export const ZCourseClone = z.object({ - id: z.string().min(1, 'Course ID is required'), - title: z.string().min(1, 'Course title is required'), - description: z.string().optional(), - slug: z.string().min(1, 'Course slug is required'), - organizationId: z.string().min(1, 'Organization ID is required') -}); - -export type TCourseClone = z.infer; diff --git a/apps/api/src/types/course/lesson.ts b/apps/api/src/types/course/lesson.ts deleted file mode 100644 index 66f24905..00000000 --- a/apps/api/src/types/course/lesson.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from 'zod'; - -export const ZLessonDownloadContent = z.object({ - title: z.string(), - number: z.string(), - orgName: z.string(), - note: z.string(), - slideUrl: z.string().optional(), - video: z.array(z.string()).optional(), - courseTitle: z.string() -}); - -export type TLessonDownloadContent = z.infer; - -export const ZCertificateDownload = z.object({ - theme: z.string().min(1), - studentName: z.string().min(1), - courseName: z.string().min(1), - courseDescription: z.string().min(1), - orgName: z.string().min(1), - orgLogoUrl: z.string().url().optional(), - facilitator: z.string().optional() -}); - -export type TCertificateDownload = z.infer; diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts index 89bd1f87..e6ca0bf1 100644 --- a/apps/api/src/types/index.ts +++ b/apps/api/src/types/index.ts @@ -1,5 +1,2 @@ // Re-export all types -export * from './course'; export * from './database'; -export * from './mail'; - diff --git a/apps/api/src/types/mail.ts b/apps/api/src/types/mail.ts deleted file mode 100644 index 78116183..00000000 --- a/apps/api/src/types/mail.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { z } from 'zod'; - -export const ZSendEmailValidation = z.array( - z.object({ - from: z.string().optional(), - to: z.string().email(), - subject: z.string().min(1), - content: z.string().min(1), - replyTo: z.string().email().optional() - }) -); - -export type TSendEmailValidation = z.infer; diff --git a/apps/api/src/utils/certificate.ts b/apps/api/src/utils/certificate.ts index c6dc4d20..79071b01 100644 --- a/apps/api/src/utils/certificate.ts +++ b/apps/api/src/utils/certificate.ts @@ -1,16 +1,6 @@ -import type { TCertificateDownload } from '@api/types/course/lesson'; +import type { TCertificateDownload } from '@cio/utils/validation/course'; import { getCloudflarePdfBuffer } from '@api/utils/cloudflare'; -export interface CertificateData { - theme: string; - studentName: string; - courseName: string; - courseDescription: string; - orgLogoUrl: string; - orgName: string; - facilitator?: string; -} - export interface CertificateGenerationResult { success: boolean; buffer?: Buffer; diff --git a/apps/api/src/utils/course.ts b/apps/api/src/utils/course.ts index 387e4211..c36aa1ff 100644 --- a/apps/api/src/utils/course.ts +++ b/apps/api/src/utils/course.ts @@ -1,4 +1,4 @@ -import type { TCourseDownloadContent } from '@api/types/course'; +import type { TCourseDownloadContent } from '@cio/utils/validation/course'; import { getCloudflarePdfBuffer } from '@api/utils/cloudflare'; import { marked } from 'marked'; @@ -138,12 +138,7 @@ function getLessonBody( `; } -function getCourseBody({ - courseTitle, - orgName, - orgTheme, - lessons -}: TCourseDownloadContent): string { +function getCourseBody({ courseTitle, orgName, orgTheme, lessons }: TCourseDownloadContent): string { return `

${courseTitle}

@@ -155,11 +150,7 @@ function getCourseBody({ `; } -async function generateSinglePdfFromHtml( - htmlContent: string, - courseTitle: string, - orgTheme: string -): Promise { +async function generateSinglePdfFromHtml(htmlContent: string, courseTitle: string, orgTheme: string): Promise { return await getCloudflarePdfBuffer(htmlContent); } diff --git a/apps/api/src/utils/email.ts b/apps/api/src/utils/email.ts deleted file mode 100644 index 1c5a8af5..00000000 --- a/apps/api/src/utils/email.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { SendMailClient } from 'zeptomail'; -import type { Transporter } from 'nodemailer'; -import { env } from '@api/config/env'; -import nodemailer from 'nodemailer'; - -export const zohoClient = new SendMailClient({ - url: 'api.zeptomail.eu/', - token: env.ZOHO_TOKEN! -}); - -export const nodemailerTransporter = async (): Promise => { - if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) { - console.error('SMTP configuration missing'); - return null; - } - - try { - const transporter = nodemailer.createTransport({ - host: env.SMTP_HOST, - port: parseInt(env.SMTP_PORT || '465', 10), - secure: true, - auth: { - user: env.SMTP_USER, - pass: env.SMTP_PASSWORD - } - }); - - await transporter.verify(); - - return transporter; - } catch (error) { - console.error('Transporter error:', error); - return null; - } -}; diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts new file mode 100644 index 00000000..549adbba --- /dev/null +++ b/apps/api/src/utils/errors.ts @@ -0,0 +1,108 @@ +import type { ContentfulStatusCode } from 'hono/utils/http-status'; +import type { Context } from 'hono'; + +/** + * Standard application error with code for easy identification + */ +export class AppError extends Error { + constructor( + error: string | Error, + public code: string, + public statusCode: ContentfulStatusCode = 500, + public field?: string + ) { + super(error instanceof Error ? error.message : error); + this.name = 'AppError'; + } +} + +/** + * Standard error response format + */ +export interface ErrorResponse { + success: false; + error: string; + code: string; + field?: string; +} + +// Error codes constants for consistency +export const ErrorCodes = { + // Organization errors + SITENAME_EXISTS: 'SITENAME_EXISTS', + ORG_CREATE_FAILED: 'ORG_CREATE_FAILED', + ORG_NOT_FOUND: 'ORG_NOT_FOUND', + ORGANIZATION_NOT_FOUND: 'ORGANIZATION_NOT_FOUND', + ORG_TEAM_FETCH_FAILED: 'ORG_TEAM_FETCH_FAILED', + ORG_AUDIENCE_FETCH_FAILED: 'ORG_AUDIENCE_FETCH_FAILED', + COURSES_FETCH_FAILED: 'COURSES_FETCH_FAILED', + ORG_PLAN_CREATE_FAILED: 'ORG_PLAN_CREATE_FAILED', + ORG_PLAN_UPDATE_FAILED: 'ORG_PLAN_UPDATE_FAILED', + ORG_PLAN_CANCEL_FAILED: 'ORG_PLAN_CANCEL_FAILED', + ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND', + + // Profile errors + PROFILE_UPDATE_FAILED: 'PROFILE_UPDATE_FAILED', + PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND', + + // Account errors + ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND', + + // Auth errors + UNAUTHORIZED: 'UNAUTHORIZED', + FORBIDDEN: 'FORBIDDEN', + + // Generic + VALIDATION_ERROR: 'VALIDATION_ERROR', + INTERNAL_ERROR: 'INTERNAL_ERROR' +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes]; + +/** + * Handles errors consistently across all routes + * @param c - Hono context + * @param error - The error to handle + * @param fallbackMessage - Default error message if error is not an AppError + * @param fallbackCode - Default error code if error is not an AppError + * @returns JSON error response with appropriate status code + * + * @example + * ```typescript + * try { + * const data = await someService(); + * return c.json({ success: true, data }); + * } catch (error) { + * return handleError(c, error, 'Failed to process request'); + * } + * ``` + */ +export const handleError = ( + c: Context, + error: unknown, + fallbackMessage: string = 'An unexpected error occurred', + fallbackCode: string = ErrorCodes.INTERNAL_ERROR +) => { + console.error('Error in route:', error); + + if (error instanceof AppError) { + return c.json( + { + success: false, + error: error.message, + code: error.code, + field: error.field + }, + error.statusCode + ); + } + + return c.json( + { + success: false, + error: fallbackMessage, + code: fallbackCode + }, + 500 + ); +}; diff --git a/apps/api/src/utils/hono.ts b/apps/api/src/utils/hono.ts new file mode 100644 index 00000000..a2721f2c --- /dev/null +++ b/apps/api/src/utils/hono.ts @@ -0,0 +1,4 @@ +import { Hono as HonoBase } from 'hono'; +import { AuthSession } from '@api/types/auth'; + +export const Hono = HonoBase; diff --git a/apps/api/src/utils/lesson.ts b/apps/api/src/utils/lesson.ts index fec11c79..98edf746 100644 --- a/apps/api/src/utils/lesson.ts +++ b/apps/api/src/utils/lesson.ts @@ -1,4 +1,4 @@ -import type { TLessonDownloadContent } from '@api/types/course/lesson'; +import type { TLessonDownloadContent } from '@cio/utils/validation/course'; import { marked } from 'marked'; function getHtmlTemplate(body: string): string { @@ -74,15 +74,7 @@ function getHtmlTemplate(body: string): string { `; } -function getLessonBody({ - title, - number, - orgName, - note, - slideUrl, - video, - courseTitle -}: TLessonDownloadContent): string { +function getLessonBody({ title, number, orgName, note, slideUrl, video, courseTitle }: TLessonDownloadContent): string { const noteHtml = marked.parse(note); const showExtraResources = slideUrl || (video && video.length > 0); return ` @@ -146,10 +138,7 @@ function getLessonBody({ `; } -async function generateSinglePdfFromHtml( - htmlContent: string, - courseTitle: string -): Promise { +async function generateSinglePdfFromHtml(htmlContent: string, courseTitle: string): Promise { throw 'Download disabled'; } diff --git a/apps/api/src/utils/mail.ts b/apps/api/src/utils/mail.ts index 99525b6e..2018989f 100644 --- a/apps/api/src/utils/mail.ts +++ b/apps/api/src/utils/mail.ts @@ -1,3 +1,39 @@ +import { SendMailClient } from 'zeptomail'; +import type { Transporter } from 'nodemailer'; +import { env } from '@api/config/env'; +import nodemailer from 'nodemailer'; + +export const zohoClient = new SendMailClient({ + url: 'api.zeptomail.eu/', + token: env.ZOHO_TOKEN! +}); + +export const nodemailerTransporter = async (): Promise => { + if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) { + console.error('SMTP configuration missing'); + return null; + } + + try { + const transporter = nodemailer.createTransport({ + host: env.SMTP_HOST, + port: parseInt(env.SMTP_PORT || '465', 10), + secure: true, + auth: { + user: env.SMTP_USER, + pass: env.SMTP_PASSWORD + } + }); + + await transporter.verify(); + + return transporter; + } catch (error) { + console.error('Transporter error:', error); + return null; + } +}; + interface FromData { name: string; email: string; diff --git a/apps/api/src/utils/openapi/index.ts b/apps/api/src/utils/openapi/index.ts index 9a784a34..2d9c8a9e 100644 --- a/apps/api/src/utils/openapi/index.ts +++ b/apps/api/src/utils/openapi/index.ts @@ -1,8 +1,9 @@ import { Hono } from 'hono'; import { Scalar } from '@scalar/hono-api-reference'; import { env } from '@api/config/env'; +import { AuthSession } from '@api/types/auth'; -export function configureOpenAPI(app: Hono) { +export function configureOpenAPI(app: Hono) { if (env.OPENAPI_URL) { app.get('/docs', Scalar({ url: env.OPENAPI_URL, theme: 'none' })); } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index cb1bb49c..942c4ca1 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,25 +1,39 @@ { "compilerOptions": { "allowSyntheticDefaultImports": true, - "baseUrl": "./", + "baseUrl": ".", "declaration": true, + "composite": true, "declarationMap": true, "esModuleInterop": true, "module": "ESNext", "moduleResolution": "Bundler", "outDir": "./dist", - "paths": { - "@api/*": ["src/*"] - }, + "rootDir": "./src", "resolveJsonModule": false, "skipLibCheck": true, - "sourceMap": true, "target": "ESNext", "typeRoots": ["../../node_modules/@types", "./node_modules/@types", "./src/types"], - "types": ["node"] + "types": ["node"], + "paths": { + "@api/*": ["src/*"], + "@db/types": ["./node_modules/@cio/db/dist/types.d.ts"], + "@db/*": ["./node_modules/@cio/db/dist/*"], + "@cio/db/*": ["./node_modules/@cio/db/dist/*"], + "@cio/db/types": ["./node_modules/@cio/db/dist/types.d.ts"], + "@cio/utils": ["./node_modules/@cio/utils/dist/index.ts"], + "@cio/utils/*": ["./node_modules/@cio/utils/dist/*"], + "@cio/email/*": ["./node_modules/@cio/email/dist/*"], + "@cio/email": ["./node_modules/@cio/email/dist/index.d.ts"] + } }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"], + "references": [ + { "path": "../../packages/db" }, + { "path": "../../packages/utils" }, + { "path": "../../packages/email" } + ], "tsc-alias": { "resolveFullPaths": true, "verbose": false diff --git a/apps/course-app/package.json b/apps/course-app/package.json index b4ccbdb5..2d7b6e11 100644 --- a/apps/course-app/package.json +++ b/apps/course-app/package.json @@ -21,7 +21,6 @@ "autoprefixer": "^10.4.20", "bits-ui": "^0.21.16", "clsx": "^2.1.1", - "mode-watcher": "^0.5.0", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.6", "prettier-plugin-tailwindcss": "^0.6.5", @@ -40,6 +39,7 @@ "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "lucide-svelte": "^0.468.0", + "mode-watcher": "^1.1.0", "posthog-js": "^1.180.1", "svelte-inview": "^4.0.4", "svelte-motion": "^0.12.2", diff --git a/apps/course-app/src/lib/components/navigation/navigation.svelte b/apps/course-app/src/lib/components/navigation/navigation.svelte index 68af66eb..5ba84b27 100644 --- a/apps/course-app/src/lib/components/navigation/navigation.svelte +++ b/apps/course-app/src/lib/components/navigation/navigation.svelte @@ -1,17 +1,17 @@

brought to you by @@ -59,7 +59,7 @@ href="/npm" target="_blank" rel="noopener noreferrer" - class="hover:text-foreground/80 text-foreground/60 flex items-center gap-1 transition-colors" + class="flex items-center gap-1 text-foreground/60 transition-colors hover:text-foreground/80" > NPM {' '} @@ -80,7 +80,7 @@ rel="noopener noreferrer" >

{' '}
& { customerId: string }) + | null; +}; + +// src/app.d.ts +declare global { + namespace App { + interface Locals { + user: TUser | null; + session: TSession | null; + profile: TProfile | null; + organizations: AccountOrganization[]; + fromSessions?: boolean; + // getAccount: () => + } + // interface PageData {} + // interface Error {} + // interface Platform {} + } +} + +export {}; diff --git a/apps/dashboard/src/hooks.server.ts b/apps/dashboard/src/hooks.server.ts index 55f237cc..ef8cf733 100644 --- a/apps/dashboard/src/hooks.server.ts +++ b/apps/dashboard/src/hooks.server.ts @@ -1,49 +1,52 @@ -import type { Handle } from '@sveltejs/kit'; -import { validateUser } from '$lib/utils/services/middlewares'; +import { getSessionData } from '$lib/utils/services/auth/session'; +import { type Handle, redirect } from '@sveltejs/kit'; +import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute'; +import { ROUTE } from '$lib/utils/constants/routes'; -const PUBLIC_API_ROUTES = [ - '/api/completion', - 'student_prove_payment', - 'teacher_student_buycourse', - '/api/polar', - '/api/lmz', - '/api/verify' -]; +export const handle: Handle = async (args) => { + const { event } = args; + const sessionData = await getSessionData(event.cookies); -function isPublicRoute(pathname: string) { - return PUBLIC_API_ROUTES.some((route) => pathname.includes(route)); -} + if (sessionData) { + event.locals = sessionData; + } -export const handle: Handle = async ({ event, resolve }) => { - const response = await resolve(event); + // Handle API routes + if (event.url.pathname.includes('/api')) { + return handleAPIRoutes(args); + } + // Handle other routes (ui pages, etc.) + return handlePagesRoutes(args); +}; + +const handlePagesRoutes: Handle = async ({ event, resolve }) => { const { pathname } = event.url; - // Only validate API routes - if (!pathname.includes('/api')) { - return response; - } - - // Skip public routes if (isPublicRoute(pathname)) { - return response; + return resolve(event); } - const accessToken = event.request.headers.get('Authorization')!; + if (!event.locals.user) { + const shouldAddRedirectParam = !pathname.includes(ROUTE.LOGOUT); + const redirectPath = shouldAddRedirectParam ? `${ROUTE.LOGIN}?redirect=${pathname}` : ROUTE.LOGIN; - try { - const user = await validateUser(accessToken); - - response.headers.set('user_id', `${user.id}`); - } catch (error) { - if (error instanceof Error) { - if (error.message === 'Unauthenticated user') { - return new Response(JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }), { - status: 401 - }); - } - } + return redirect(303, redirectPath); } - return response; + return resolve(event); +}; + +const handleAPIRoutes: Handle = async ({ event, resolve }) => { + const { pathname } = event.url; + + if (isPublicApiRoute(pathname)) { + return resolve(event); + } + + if (!event.locals.user) { + redirect(303, '/login'); + } + + return resolve(event); }; diff --git a/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte b/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte index d9589817..da6ffe9a 100644 --- a/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte +++ b/apps/dashboard/src/lib/components/Analytics/ActivityCard.svelte @@ -1,6 +1,8 @@
import XIcon from '@lucide/svelte/icons/x'; + import { Circle } from 'svelte-loading-spinners'; import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte'; interface Props { onClick?: IconButtonProps['onClick']; contained?: boolean; + isClosing?: boolean; color?: string; size?: IconButtonProps['size']; toolTipProps?: IconButtonProps['toolTipProps']; @@ -13,11 +15,16 @@ let { onClick = () => {}, contained = false, + isClosing = false, color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500', size = 'small' }: Props = $props(); - - + + {#if isClosing} + + {:else} + + {/if} diff --git a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/index.svelte b/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/index.svelte index 9651dcc3..b80a71c9 100644 --- a/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/index.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Lesson/Exercise/index.svelte @@ -1,12 +1,11 @@ - + Course Navigation @@ -232,7 +230,7 @@ {#if item.isLesson && item.items} {#snippet child({ props })} - + {@const Icon = item.icon} diff --git a/apps/dashboard/src/lib/components/Course/components/People/InvitationModal.svelte b/apps/dashboard/src/lib/components/Course/components/People/InvitationModal.svelte index 4a95d4e1..d6dc758b 100644 --- a/apps/dashboard/src/lib/components/Course/components/People/InvitationModal.svelte +++ b/apps/dashboard/src/lib/components/Course/components/People/InvitationModal.svelte @@ -12,7 +12,7 @@ import * as Popover from '@cio/ui/base/popover'; import { qrInviteNodeStore } from './store'; - import { ROLE } from '$lib/utils/constants/roles'; + import { ROLE } from '@cio/utils/constants'; import { getOrgTeam } from '$lib/utils/services/org'; import { t } from '$lib/utils/functions/translations'; import { snackbar } from '$lib/components/Snackbar/store'; diff --git a/apps/dashboard/src/lib/components/Course/components/Settings/index.svelte b/apps/dashboard/src/lib/components/Course/components/Settings/index.svelte index 6c042303..d8f4c809 100644 --- a/apps/dashboard/src/lib/components/Course/components/Settings/index.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Settings/index.svelte @@ -11,13 +11,11 @@ import { IconButton } from '$lib/components/IconButton'; import TextArea from '$lib/components/Form/TextArea.svelte'; import TextField from '$lib/components/Form/TextField.svelte'; - import Row from '$lib/components/Org/Settings/Layout/Row.svelte'; + import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout'; import SectionTitle from '$lib/components/Org/SectionTitle.svelte'; - import Grid from '$lib/components/Org/Settings/Layout/Grid.svelte'; import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte'; import UploadWidget from '$lib/components/UploadWidget/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; - import Column from '$lib/components/Org/Settings/Layout/Column.svelte'; import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte'; import { settings } from './store'; diff --git a/apps/dashboard/src/lib/components/Course/store.ts b/apps/dashboard/src/lib/components/Course/store.ts index ca66ac07..f928b96f 100644 --- a/apps/dashboard/src/lib/components/Course/store.ts +++ b/apps/dashboard/src/lib/components/Course/store.ts @@ -3,7 +3,7 @@ import type { Course, GroupPerson, Lesson, LessonSection } from '$lib/utils/type import { get, writable, type Writable } from 'svelte/store'; import { lessonSections, lessons } from './components/Lesson/store/lessons'; -import { ROLE } from '$lib/utils/constants/roles'; +import { ROLE } from '@cio/utils/constants'; import { fetchCourse } from '$lib/utils/services/courses'; export const defaultCourse: Course = { diff --git a/apps/dashboard/src/lib/components/CourseContainer/index.svelte b/apps/dashboard/src/lib/components/CourseContainer/index.svelte index 58184ebd..a361b176 100644 --- a/apps/dashboard/src/lib/components/CourseContainer/index.svelte +++ b/apps/dashboard/src/lib/components/CourseContainer/index.svelte @@ -1,7 +1,7 @@ -
- -
+
+ +
- - - - +
+ + + +
-
+
+
-
- - diff --git a/apps/dashboard/src/lib/components/Courses/components/NewCourseModal/index.svelte b/apps/dashboard/src/lib/components/Courses/components/NewCourseModal/index.svelte index 590280c8..1c812cb4 100644 --- a/apps/dashboard/src/lib/components/Courses/components/NewCourseModal/index.svelte +++ b/apps/dashboard/src/lib/components/Courses/components/NewCourseModal/index.svelte @@ -3,7 +3,7 @@ import { goto } from '$app/navigation'; import { page } from '$app/state'; - import ComingSoon from '$lib/components/ComingSoon/index.svelte'; + import { ComingSoon } from '$lib/features/ui'; import { validateForm } from '$lib/components/Courses/functions'; import { courses, createCourseModal } from '$lib/components/Courses/store'; import TextArea from '$lib/components/Form/TextArea.svelte'; @@ -11,7 +11,7 @@ import Modal from '$lib/components/Modal/index.svelte'; import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; - import { ROLE } from '$lib/utils/constants/roles'; + import { ROLE } from '@cio/utils/constants'; import { supabase } from '$lib/utils/functions/supabase'; import { t } from '$lib/utils/functions/translations'; import { addDefaultNewsFeed, addGroupMember } from '$lib/utils/services/courses'; @@ -151,7 +151,7 @@ {$t('courses.new_course_modal.type_selector_title')} -
+
{#each options as option} - diff --git a/apps/dashboard/src/lib/components/Org/FooterMenu/components/NewsSection.svelte b/apps/dashboard/src/lib/components/Org/FooterMenu/components/NewsSection.svelte deleted file mode 100644 index 5e96bdde..00000000 --- a/apps/dashboard/src/lib/components/Org/FooterMenu/components/NewsSection.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/apps/dashboard/src/lib/components/Org/LandingPage/index.svelte b/apps/dashboard/src/lib/components/Org/LandingPage/index.svelte index 8202bc92..6edf28a5 100644 --- a/apps/dashboard/src/lib/components/Org/LandingPage/index.svelte +++ b/apps/dashboard/src/lib/components/Org/LandingPage/index.svelte @@ -24,11 +24,9 @@ import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte'; import PageLoader from './PageLoader.svelte'; - import Row from '../Settings/Layout/Row.svelte'; - import Grid from '../Settings/Layout/Grid.svelte'; + import { Row, Grid, Column } from '../Settings/Layout'; import * as Accordion from '@cio/ui/base/accordion'; import { t } from '$lib/utils/functions/translations'; - import Column from '../Settings/Layout/Column.svelte'; import type { CurrentOrg } from '$lib/utils/types/org'; import { getSupabase } from '$lib/utils/functions/supabase'; import { getCourseBySiteName } from '$lib/utils/services/org'; diff --git a/apps/dashboard/src/lib/components/Org/OrgFooter/OrgFooter.svelte b/apps/dashboard/src/lib/components/Org/OrgFooter/OrgFooter.svelte deleted file mode 100644 index d38c5bc8..00000000 --- a/apps/dashboard/src/lib/components/Org/OrgFooter/OrgFooter.svelte +++ /dev/null @@ -1,43 +0,0 @@ - - -
- - - - - - - - - - - - - -
diff --git a/apps/dashboard/src/lib/components/Org/Settings/Billing.svelte b/apps/dashboard/src/lib/components/Org/Settings/Billing.svelte index ae8ef5f3..56b7b9e5 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/Billing.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/Billing.svelte @@ -4,9 +4,7 @@ import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org'; - import Row from './Layout/Row.svelte'; - import Grid from './Layout/Grid.svelte'; - import Column from './Layout/Column.svelte'; + import { Row, Grid, Column } from './Layout'; import SectionTitle from '../SectionTitle.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; @@ -20,7 +18,7 @@ try { if ($currentOrgPlan?.provider === 'polar') { const url = new URL('/api/polar/portal', window.location.origin); - url.searchParams.set('customerId', $currentOrgPlan?.customerId); + url.searchParams.set('customerId', $currentOrgPlan?.customerId || ''); window.open(url.toString(), '_blank'); } diff --git a/apps/dashboard/src/lib/components/Org/Settings/CustomizeLms.svelte b/apps/dashboard/src/lib/components/Org/Settings/CustomizeLms.svelte index 571870f6..35060448 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/CustomizeLms.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/CustomizeLms.svelte @@ -10,9 +10,7 @@ import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { handleOpenWidget } from '$lib/components/CourseLandingPage/store'; - import Row from './Layout/Row.svelte'; - import Grid from './Layout/Grid.svelte'; - import Column from './Layout/Column.svelte'; + import { Row, Grid, Column } from './Layout'; import SectionTitle from '../SectionTitle.svelte'; import TextField from '$lib/components/Form/TextField.svelte'; import UploadWidget from '$lib/components/UploadWidget/index.svelte'; diff --git a/apps/dashboard/src/lib/components/Org/Settings/Domains.svelte b/apps/dashboard/src/lib/components/Org/Settings/Domains.svelte index 898aa5a1..aceb054a 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/Domains.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/Domains.svelte @@ -18,15 +18,13 @@ import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator'; import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain'; - import Row from './Layout/Row.svelte'; - import Grid from './Layout/Grid.svelte'; - import Column from './Layout/Column.svelte'; + import { Row, Grid, Column } from './Layout'; import SectionTitle from '../SectionTitle.svelte'; import TextChip from '$lib/components/Chip/Text.svelte'; import { IconButton } from '$lib/components/IconButton'; import TextArea from '$lib/components/Form/TextArea.svelte'; import TextField from '$lib/components/Form/TextField.svelte'; - import ComingSoon from '$lib/components/ComingSoon/index.svelte'; + import { ComingSoon } from '$lib/features/ui'; import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte'; import UploadImage from '$lib/components/UploadImage/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; @@ -158,7 +156,7 @@ snackbar.success(); - $currentOrg.customDomain = undefined; + $currentOrg.customDomain = ''; $currentOrg.isCustomDomainVerified = false; isCustomDomainLoading = false; } diff --git a/apps/dashboard/src/lib/components/Org/Settings/Integrations.svelte b/apps/dashboard/src/lib/components/Org/Settings/Integrations.svelte index 1803a2d1..f56a7efe 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/Integrations.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/Integrations.svelte @@ -5,9 +5,7 @@ import { snackbar } from '$lib/components/Snackbar/store'; import { VARIANTS } from '$lib/components/PrimaryButton/constants'; - import Row from './Layout/Row.svelte'; - import Grid from './Layout/Grid.svelte'; - import Column from './Layout/Column.svelte'; + import { Row, Grid, Column } from './Layout'; import SectionTitle from '../SectionTitle.svelte'; import TextField from '$lib/components/Form/TextField.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; @@ -30,7 +28,7 @@ console.log(error); snackbar.error('snackbar.course_settings.error.failed_integration'); } else { - $profile.telegram_chat_id = chatId; + $profile.telegramChatId = chatId; chatId = null; snackbar.success('snackbar.course_settings.success.successful_integration'); } @@ -43,7 +41,7 @@ snackbar.error('snackbar.course_settings.error.failed_deletion'); console.log(error); } else { - $profile.telegram_chat_id = null; + $profile.telegramChatId = null; snackbar.success('snackbar.course_settings.success.successful_deletion'); } } @@ -57,7 +55,7 @@ > - {#if $profile.telegram_chat_id !== null} + {#if $profile.telegramChatId !== null}
diff --git a/apps/dashboard/src/lib/components/Org/Settings/LandingpageSettings.svelte b/apps/dashboard/src/lib/components/Org/Settings/LandingpageSettings.svelte index 182e6a4d..57c1a776 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/LandingpageSettings.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/LandingpageSettings.svelte @@ -13,9 +13,7 @@ import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { handleOpenWidget } from '$lib/components/CourseLandingPage/store'; - import Row from './Layout/Row.svelte'; - import Grid from './Layout/Grid.svelte'; - import Column from './Layout/Column.svelte'; + import { Row, Grid, Column } from './Layout'; import SectionTitle from '../SectionTitle.svelte'; import { IconButton } from '$lib/components/IconButton'; import TextArea from '$lib/components/Form/TextArea.svelte'; @@ -23,6 +21,7 @@ import UploadWidget from '$lib/components/UploadWidget/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte'; + import type { OrgLandingPageJson } from '$lib/utils/types/org'; let isSaving = $state(false); let creatingNewQuestion = $state(false); @@ -154,7 +153,7 @@ // Set default from store currentOrg.subscribe((cOrg) => { if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) { - const landingpage = { ...cOrg.landingpage }; + const landingpage = { ...(cOrg.landingpage as unknown as OrgLandingPageJson) }; // Fallbacks for new keys we added into JSON, in case a user already saved the old JSON if (!landingpage?.header?.banner) { diff --git a/apps/dashboard/src/lib/components/Org/Settings/LanguagePicker.svelte b/apps/dashboard/src/lib/components/Org/Settings/LanguagePicker.svelte index 8e37f08a..f54ceff4 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/LanguagePicker.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/LanguagePicker.svelte @@ -2,21 +2,21 @@ import { Label } from '@cio/ui/base/label'; import * as Select from '@cio/ui/base/select'; - import { LOCALE } from '$lib/utils/types'; + import type { TLocale } from '@cio/db/types'; import { LANGUAGES } from '$lib/utils/constants/translation'; import { t, initialized } from '$lib/utils/functions/translations'; interface Props { className?: string; - value?: LOCALE; + value?: TLocale; hasLangChanged?: boolean; change?: () => void; } - let { className = '', value = $bindable(LOCALE.EN), hasLangChanged = $bindable(false), change }: Props = $props(); + let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props(); function handleSelect(selectedValue: string) { - value = selectedValue as LOCALE; + value = selectedValue as TLocale; hasLangChanged = true; change?.(); } diff --git a/apps/dashboard/src/lib/components/Org/Settings/Layout/index.ts b/apps/dashboard/src/lib/components/Org/Settings/Layout/index.ts new file mode 100644 index 00000000..2b47a74b --- /dev/null +++ b/apps/dashboard/src/lib/components/Org/Settings/Layout/index.ts @@ -0,0 +1,3 @@ +export { default as Grid } from './Grid.svelte'; +export { default as Row } from './Row.svelte'; +export { default as Column } from './Column.svelte'; diff --git a/apps/dashboard/src/lib/components/Org/Settings/OrgSettings.svelte b/apps/dashboard/src/lib/components/Org/Settings/OrgSettings.svelte index 0c63278e..1efead3f 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/OrgSettings.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/OrgSettings.svelte @@ -13,9 +13,7 @@ import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org'; import { injectCustomTheme, setCustomTheme, setTheme } from '$lib/utils/functions/theme'; - import Row from './Layout/Row.svelte'; - import Grid from './Layout/Grid.svelte'; - import Column from './Layout/Column.svelte'; + import { Row, Grid, Column } from './Layout'; import SectionTitle from '../SectionTitle.svelte'; import TextField from '$lib/components/Form/TextField.svelte'; import UploadImage from '$lib/components/UploadImage/index.svelte'; @@ -119,8 +117,8 @@ if (data) { const { data: response } = supabase.storage.from('avatars').getPublicUrl(filename); - updates.avatar_url = response.publicUrl; - $currentOrg.avatar_url = response.publicUrl; + updates.avatarUrl = response.publicUrl; + $currentOrg.avatarUrl = response.publicUrl; } avatar = undefined; } @@ -174,7 +172,7 @@ /> (hasUnsavedChanges = true)} diff --git a/apps/dashboard/src/lib/components/Org/Settings/Profile.svelte b/apps/dashboard/src/lib/components/Org/Settings/Profile.svelte index 176d05c8..96b9ed52 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/Profile.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/Profile.svelte @@ -1,94 +1,61 @@ @@ -103,8 +70,9 @@ (hasUnsavedChanges = true)} /> @@ -118,23 +86,64 @@ label={$t('settings.profile.personal_information.full_name')} bind:value={$profile.fullname} className="w-full lg:w-60 mb-4" - errorMessage={$t(errors.fullname)} + errorMessage={profileApi.errors.fullname ? $t(profileApi.errors.fullname) : undefined} onChange={() => (hasUnsavedChanges = true)} /> (hasUnsavedChanges = true)} - /> - (hasUnsavedChanges = true)} /> +
+ { + if (email !== $profile.email) { + isChangingEmail = true; + emailChangeInitiated = false; + } + }} + /> + {#if isChangingEmail && email !== $profile.email} +
+ + { + email = $profile.email || ''; + isChangingEmail = false; + emailChangeInitiated = false; + profileApi.errors.newEmail = ''; + }} + /> +
+ {/if} + {#if emailChangeInitiated} +
+ +

{$t('settings.profile.personal_information.email_change_verification_note')}

+
+ {/if} +
(hasUnsavedChanges = true)} bind:hasLangChanged @@ -149,8 +158,7 @@ label={$t('settings.profile.update_profile')} variant={VARIANTS.CONTAINED_DARK} className="mr-5 w-fit" - isLoading={loading} - isDisabled={loading} + isLoading={profileApi.isLoading} onClick={handleUpdate} />
diff --git a/apps/dashboard/src/lib/components/Org/Settings/TeamMembers.svelte b/apps/dashboard/src/lib/components/Org/Settings/TeamMembers.svelte index 28bb74ce..d8b2217c 100644 --- a/apps/dashboard/src/lib/components/Org/Settings/TeamMembers.svelte +++ b/apps/dashboard/src/lib/components/Org/Settings/TeamMembers.svelte @@ -2,7 +2,7 @@ import { untrack } from 'svelte'; import { Label } from '@cio/ui/base/label'; import * as Select from '@cio/ui/base/select'; - import { Moon } from 'svelte-loading-spinners'; + import { Circle } from 'svelte-loading-spinners'; import { profile } from '$lib/utils/store/user'; import { isFreePlan } from '$lib/utils/store/org'; @@ -12,18 +12,17 @@ import { snackbar } from '$lib/components/Snackbar/store'; import type { OrgTeamMember } from '$lib/utils/types/org'; import { currentOrg, orgTeam } from '$lib/utils/store/org'; - import { ROLE_LABEL, ROLE } from '$lib/utils/constants/roles'; + import { ROLE_LABEL } from '$lib/utils/constants/roles'; + import { ROLE } from '@cio/utils/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { validateEmailInString } from '$lib/utils/functions/validator'; import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification'; - import Row from './Layout/Row.svelte'; - import Grid from './Layout/Grid.svelte'; - import Column from './Layout/Column.svelte'; + import { Row, Grid, Column } from './Layout'; import SectionTitle from '../SectionTitle.svelte'; import TextChip from '$lib/components/Chip/Text.svelte'; import TextField from '$lib/components/Form/TextField.svelte'; - import ComingSoon from '$lib/components/ComingSoon/index.svelte'; + import { ComingSoon } from '$lib/features/ui'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; let emailsStr = $state(''); @@ -190,7 +189,7 @@ > {#if isFetching} - + {:else} {#each $orgTeam as teamMember}
diff --git a/apps/dashboard/src/lib/components/Org/store.ts b/apps/dashboard/src/lib/components/Org/store.ts index 8671ced3..dce85cb1 100644 --- a/apps/dashboard/src/lib/components/Org/store.ts +++ b/apps/dashboard/src/lib/components/Org/store.ts @@ -3,7 +3,3 @@ import { writable } from 'svelte/store'; export const newOrgModal = writable({ open: false }); - -export const sideBar = writable({ - hidden: true -}); diff --git a/apps/dashboard/src/lib/components/Sidebar/components/app-sidebar.svelte b/apps/dashboard/src/lib/components/Sidebar/components/app-sidebar.svelte deleted file mode 100644 index 717bda9f..00000000 --- a/apps/dashboard/src/lib/components/Sidebar/components/app-sidebar.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - - {#if sidebarData.teams} - - - - {/if} - - - - - - {#if $isFreePlan} - -
- upgrade - -

{$t('org_navigation.early_adopter')}

-

{$t('org_navigation.unlock')}

-
- -
-
- {/if} - - {#if sidebarData.user} - - - - {/if} - - -
diff --git a/apps/dashboard/src/lib/components/Sidebar/components/nav-main.svelte b/apps/dashboard/src/lib/components/Sidebar/components/nav-main.svelte deleted file mode 100644 index b472b483..00000000 --- a/apps/dashboard/src/lib/components/Sidebar/components/nav-main.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - - - Platform - - {#each items as item (item.title)} - - {#snippet child({ props })} - - - {#if item.icon} - - {/if} - {item.title} - - - {/snippet} - - {/each} - - - - diff --git a/apps/dashboard/src/lib/components/Sidebar/components/nav-projects.svelte b/apps/dashboard/src/lib/components/Sidebar/components/nav-projects.svelte deleted file mode 100644 index 746a5739..00000000 --- a/apps/dashboard/src/lib/components/Sidebar/components/nav-projects.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - - - Projects - - {#each projects as item (item.name)} - - - {#snippet child({ props })} - - - {item.name} - - {/snippet} - - - - {#snippet child({ props })} - - - More - - {/snippet} - - - - - View Project - - - - Share Project - - - - - Delete Project - - - - - {/each} - - - - More - - - - - - diff --git a/apps/dashboard/src/lib/components/Sidebar/components/nav-user.svelte b/apps/dashboard/src/lib/components/Sidebar/components/nav-user.svelte deleted file mode 100644 index 07ce53e6..00000000 --- a/apps/dashboard/src/lib/components/Sidebar/components/nav-user.svelte +++ /dev/null @@ -1,99 +0,0 @@ - - - - - - - {#snippet child({ props })} - - - - CN - -
- {user.name} - {user.email} -
- -
- {/snippet} -
- - - - - - CN - -
- {user.name} - {user.email} -
-
-
- - {#if !$globalStore.isOrgSite} - - - - - - {#if $currentOrg.avatar_url && $currentOrg.name} - - {:else if $currentOrg.shortName} - - {/if} - -
- {$currentOrg.name} - {$currentOrgDomain} -
-
-
- - {/if} - - - - - - - - - - diff --git a/apps/dashboard/src/lib/components/Sidebar/components/team-switcher.svelte b/apps/dashboard/src/lib/components/Sidebar/components/team-switcher.svelte deleted file mode 100644 index 3f35f4a3..00000000 --- a/apps/dashboard/src/lib/components/Sidebar/components/team-switcher.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - - - - {#if activeTeam} - - - {#snippet child({ props })} - - {#if $currentOrg.name} - - {#if $currentOrg.avatar_url && $currentOrg.name} - - {:else if $currentOrg.shortName} - - {/if} - - -
- - {activeTeam.name} - - {activeTeam.organization_plan[0]?.plan_name} -
- - {:else} -
- -
- {/if} -
- {/snippet} -
- - Teams - {#each teams as team (team.name)} - {#if canAddOrg} - onClick(team)} class="gap-2 p-2"> - - {#if $currentOrg.avatar_url && $currentOrg.name} - - {:else if $currentOrg.shortName} - - {/if} - - - {team.name} - - - {/if} - {/each} - - -
- -
-
Add team
- - -
-
-
- {:else} - -
- -
-
- No teams - Loading... -
-
- {/if} -
-
diff --git a/apps/dashboard/src/lib/components/Sidebar/components/types.ts b/apps/dashboard/src/lib/components/Sidebar/components/types.ts deleted file mode 100644 index cc063f46..00000000 --- a/apps/dashboard/src/lib/components/Sidebar/components/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { ComponentProps } from 'svelte'; -import * as Sidebar from '@cio/ui/base/sidebar'; -import type { SidebarData } from '$lib/utils/types/sidebar'; - -export interface AppSidebar extends ComponentProps { - sidebarData: SidebarData; - canAddOrg?: boolean; -} diff --git a/apps/dashboard/src/lib/components/Sidebar/index.svelte b/apps/dashboard/src/lib/components/Sidebar/index.svelte deleted file mode 100644 index 130040ca..00000000 --- a/apps/dashboard/src/lib/components/Sidebar/index.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - diff --git a/apps/dashboard/src/lib/components/Upgrade/Modal.svelte b/apps/dashboard/src/lib/components/Upgrade/Modal.svelte index bbe0c92d..60e2c6d2 100644 --- a/apps/dashboard/src/lib/components/Upgrade/Modal.svelte +++ b/apps/dashboard/src/lib/components/Upgrade/Modal.svelte @@ -31,7 +31,7 @@ let isConfirming = $derived((query.get('confirmation') || '') === 'true'); async function handleClick(plan, planName: string) { - if (plan.CTA.IS_DISABLED || !$profile.id) { + if (plan.CTA.IS_DISABLED || !$profile.id || !$profile.email) { return; } @@ -51,7 +51,7 @@ checkoutURL.searchParams.set( 'metadata', JSON.stringify({ - triggeredBy: $currentOrg.memberId, + triggeredBy: $currentOrg.roleId, orgId: $currentOrg.id, orgSlug: $currentOrg.siteName }) @@ -130,8 +130,8 @@
{:else} -
-
+
+
-
+
{#each planNames as planName}

diff --git a/apps/dashboard/src/lib/components/UploadImage/index.svelte b/apps/dashboard/src/lib/components/UploadImage/index.svelte index a869fd5f..47c3404d 100644 --- a/apps/dashboard/src/lib/components/UploadImage/index.svelte +++ b/apps/dashboard/src/lib/components/UploadImage/index.svelte @@ -6,7 +6,7 @@ interface Props { avatar: string | File | undefined; - src: string | undefined; + src: string | undefined | null; widthHeight?: string; shape?: string; errorMessage?: string | null; diff --git a/apps/dashboard/src/lib/components/WelcomeModal/store.ts b/apps/dashboard/src/lib/components/WelcomeModal/store.ts deleted file mode 100644 index 018f5784..00000000 --- a/apps/dashboard/src/lib/components/WelcomeModal/store.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { writable } from 'svelte/store'; - -export const welcomeModalStore = writable({ - open: false -}); diff --git a/apps/dashboard/src/lib/features/app/init.svelte.ts b/apps/dashboard/src/lib/features/app/init.svelte.ts new file mode 100644 index 00000000..a74f71ce --- /dev/null +++ b/apps/dashboard/src/lib/features/app/init.svelte.ts @@ -0,0 +1,162 @@ +import { BaseApi, classroomio } from '$lib/utils/services/api'; +import { currentOrg, isOrgStudent, orgs } from '$lib/utils/store/org'; +import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user'; + +import type { AccountResponse } from './types'; +import type { TUser } from '@cio/db/types'; +import { get } from 'svelte/store'; +import { goto } from '$app/navigation'; +import { handleLocaleChange } from '$lib/utils/functions/translations'; +import { identifyPosthogUser } from '$lib/utils/services/posthog'; +import { logout } from '$lib/utils/functions/logout'; +import { page } from '$app/state'; +import { resolve } from '$app/paths'; +import { setSentryUser } from '$lib/utils/services/sentry'; +import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth'; + +type AppSetupParams = { + isOrgSite: boolean; + orgSiteName: string; +}; + +/* + Manages everything related to loading the logged in user and setting up the organization. +*/ +class AppInitApi extends BaseApi { + data = $state(null); + session = $state(null); + + get loading() { + return this.isLoading; + } + + async setupApp(locals: App.Locals, params: AppSetupParams): Promise { + if (!locals.user) { + console.log('No user found in locals'); + return; + } + + this.session = locals; + + await this.execute({ + requestFn: () => classroomio.account.$get(), + logContext: 'fetching account', + onSuccess: (accountData) => { + this.data = accountData; + this.setupStores(); + this.setUserAnalytics(); + this.routeUserToNextPage(params); + }, + onError: () => { + logout(); + } + }); + } + + /* + 1. Update user store + 2. Update profile store + 3. Update organizations store + */ + setupStores() { + if (!this.data?.success || !this.session) { + return; + } + + user.update((_user) => ({ + ..._user, + fetchingUser: false, + isLoggedIn: true, + currentSession: (this.session?.user as unknown as TUser) || undefined + })); + + profile.set(this.data.profile); + handleLocaleChange(this.data.profile.locale ?? 'en'); + + this.setOrgStore(); + } + + setOrgStore() { + if (!this.data?.success || !this.data) { + return; + } + + if (!this.data.organizations.length) { + return; + } + + orgs.set(this.data.organizations); + + const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename'); + const lastOrg = this.data.organizations.find((org) => org.siteName === lastOrgSiteName); + + currentOrg.set(lastOrg || this.data.organizations[0]); + } + + routeUserToNextPage({ isOrgSite }: AppSetupParams) { + if (!this.data?.success) { + return; + } + + const userHasOrganizations = this.data.organizations.length > 0; + const shouldRedirectToOnboarding = !isOrgSite && !userHasOrganizations; + if (shouldRedirectToOnboarding) { + return goto(resolve(`/onboarding`, {})); + } + + const redirect = page.url.searchParams.get('redirect'); + if (redirect) { + // goto redirect won't accept dynamic url so we need to use window.location.href + window.location.href = redirect; + return; + } + + if (!shouldRedirectOnAuth(page.url.pathname)) return; + + const isStudent = get(isOrgStudent); + + return isOrgSite || isStudent ? this.goToLMS() : this.goToOrg(); + } + + goToLMS() { + goto(resolve('/lms', {})); + } + + goToOrg() { + const selectedOrg = get(currentOrg); + + goto(resolve(`/org/${selectedOrg.siteName}`, {})); + } + + setUserAnalytics() { + const profileStore = get(profile); + + if (!profileStore?.id) return; + + setSentryUser({ + id: profileStore.id, + username: profileStore.username, + email: profileStore.email, + fullname: profileStore.fullname + }); + + identifyPosthogUser(profileStore.id, { + email: profileStore.email, + name: profileStore.fullname + }); + } + + reset() { + super.reset(); + this.data = null; + + user.set(defaultUserState); + profile.set(defaultProfileState); + } + + get isInitializedAndReady() { + return !this.isLoading && !this.error && this.data !== null; + } +} + +export const appInitApi = new AppInitApi(); diff --git a/apps/dashboard/src/lib/features/app/layout-setup.ts b/apps/dashboard/src/lib/features/app/layout-setup.ts new file mode 100644 index 00000000..bf3a7863 --- /dev/null +++ b/apps/dashboard/src/lib/features/app/layout-setup.ts @@ -0,0 +1,120 @@ +import type { Cookies } from '@sveltejs/kit'; +import type { CurrentOrg } from '$lib/utils/types/org'; +import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; +import { blockedSubdomain } from '$lib/utils/constants/app'; +import { env } from '$env/dynamic/private'; +import { getCurrentOrg } from '$lib/utils/services/org'; + +export interface OrgSiteInfo { + isOrgSite: boolean; + org: CurrentOrg | null; + subdomain: string; + orgSiteName: string; +} + +export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise { + const response: OrgSiteInfo = { + orgSiteName: '', + subdomain: '', + isOrgSite: false, + org: null + }; + + // Selfhosted usecase + if (PUBLIC_IS_SELFHOSTED === 'true') { + const subdomain = getSubdomain(url); + + // Student dashboard + if (subdomain) { + const org = (await getCurrentOrg(subdomain, true)) || null; + + // Organization by subdomain not found + if (!org) { + return response; + } + + response.org = org; + response.isOrgSite = true; + response.orgSiteName = subdomain; + response.subdomain = subdomain; + } + + // Never go beyond this for selfhosted instances + return response; + } + + const isLocalHost = url.host.includes('localhost'); + const tempSiteName = url.searchParams.get('org'); + + if (isLocalHost && tempSiteName) { + cookies.set('_orgSiteName', tempSiteName, { + path: '/' + }); + } + + const _orgSiteName = cookies.get('_orgSiteName'); + const debugMode = _orgSiteName && _orgSiteName !== 'false'; + + const subdomain = getSubdomain(url) || ''; + + // Custom domain + if (isURLCustomDomain(url)) { + response.org = (await getCurrentOrg(url.host, true, true)) || null; + + if (!response.org) { + return response; + } + + response.isOrgSite = true; + response.orgSiteName = response.org?.siteName || ''; + response.subdomain = subdomain; + + return response; + } + + // Subdomain except blocked ones. + if (!blockedSubdomain.includes(subdomain)) { + const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || []; + + if (APP_SUBDOMAINS.includes(subdomain)) { + return response; + } + + response.isOrgSite = debugMode || !!subdomain; + response.orgSiteName = debugMode ? _orgSiteName : subdomain; + response.org = (await getCurrentOrg(response.orgSiteName, true)) || null; + + const shouldDeleteCookie = !response.org && _orgSiteName; + if (shouldDeleteCookie) { + cookies.delete('_orgSiteName', { path: '/' }); + } + } + + return response; +} + +function isURLCustomDomain(url: URL) { + if (url.host.includes('localhost')) { + return false; + } + + const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'vercel.app'].filter(Boolean); + + return !notCustomDomainHosts.some((host) => url.host.endsWith(host)); +} + +export function getSubdomain(url: URL) { + const host = url.hostname.replace('www.', ''); + const parts = host.split('.'); + const appHost = env.PRIVATE_APP_HOST; + + const appHostParts = appHost.split('.'); + const isAppHost = parts.slice(-appHostParts.length).join('.') === appHost; + + if (isAppHost) { + // Subdomain exists only if extra part(s) before main domain + return parts.length > appHostParts.length ? parts[0] : null; + } + + return null; +} diff --git a/apps/dashboard/src/lib/features/app/types.ts b/apps/dashboard/src/lib/features/app/types.ts new file mode 100644 index 00000000..12214991 --- /dev/null +++ b/apps/dashboard/src/lib/features/app/types.ts @@ -0,0 +1,7 @@ +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; + +export type AccountResponse = InferResponseType | null; + +export type AccountSuccess = Extract, { success: true }>; + +export type AccountOrg = AccountSuccess['organizations'][number]; diff --git a/apps/dashboard/src/lib/features/auth/api/forgot.svelte.ts b/apps/dashboard/src/lib/features/auth/api/forgot.svelte.ts new file mode 100644 index 00000000..128aff00 --- /dev/null +++ b/apps/dashboard/src/lib/features/auth/api/forgot.svelte.ts @@ -0,0 +1,37 @@ +import { BaseApiWithErrors } from '$lib/utils/services/api'; +import type { TForgotPasswordForm } from '../utils/types'; +import { authClient } from '$lib/utils/services/auth/client'; +import { forgotPasswordValidation } from '../utils/validation'; +import { snackbar } from '$lib/components/Snackbar/store'; + +export class ForgotApi extends BaseApiWithErrors { + async submit(fields: TForgotPasswordForm) { + const errors = forgotPasswordValidation(fields); + if (errors) { + this.errors = errors; + return; + } + + try { + this.isLoading = true; + this.errors = {}; + this.success = false; + + const { error } = await authClient.requestPasswordReset({ + email: fields.email, + redirectTo: window.location.origin + '/reset' + }); + + if (error) throw error; + + this.success = true; + } catch (error) { + console.error(error); + snackbar.error('snackbar.something'); + } finally { + this.isLoading = false; + } + } +} + +export const forgotApi = new ForgotApi(); diff --git a/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts b/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts new file mode 100644 index 00000000..423d3eb6 --- /dev/null +++ b/apps/dashboard/src/lib/features/auth/api/profile.svelte.ts @@ -0,0 +1,207 @@ +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import type { TLocale, TProfile } from '@cio/db/types'; +import { ZChangeEmail, ZProfileUpdateForm } from '@cio/utils/validation/account'; + +import type { TProfileUpdateForm as TProfileUpdateFormSchema } from '@cio/utils/validation/account'; +import { authClient } from '$lib/utils/services/auth/client'; +import generateUUID from '$lib/utils/functions/generateUUID'; +import { handleLocaleChange } from '$lib/utils/functions/translations'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; +import { profile as profileStore } from '$lib/utils/store/user'; +import { snackbar } from '$lib/components/Snackbar/store'; +import { supabase } from '$lib/utils/functions/supabase'; +import { t } from '$lib/utils/functions/translations'; + +export interface TProfileUpdateForm extends Omit { + avatar?: string | File | undefined; +} + +export interface TChangeEmailForm { + newEmail: string; + callbackURL?: string; +} + +export class ProfileApi extends BaseApiWithErrors { + private validateForm(fields: TProfileUpdateForm): boolean { + const validationData = { + fullname: fields.fullname, + username: fields.username, + locale: fields.locale + }; + + const result = ZProfileUpdateForm.safeParse(validationData); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error); + return false; + } + return true; + } + + private async uploadAvatar(avatar: File): Promise { + const filename = `user/${generateUUID()}.webp`; + + const { data: uploadData, error: uploadError } = await supabase.storage.from('avatars').upload(filename, avatar, { + cacheControl: '3600', + upsert: false + }); + + if (uploadError) throw uploadError; + + if (uploadData) { + const { data: urlData } = await supabase.storage.from('avatars').getPublicUrl(filename); + return urlData.publicUrl; + } + + return undefined; + } + + private async updateUserInfo( + fields: TProfileUpdateForm, + currentProfile: TProfile, + avatarUrl?: string + ): Promise { + const userUpdates: { name?: string; image?: string } = {}; + + if (fields.fullname !== currentProfile.fullname) { + userUpdates.name = fields.fullname; + } + if (avatarUrl) { + userUpdates.image = avatarUrl; + } + + if (Object.keys(userUpdates).length > 0) { + const { error: userError } = await authClient.updateUser(userUpdates); + if (userError) throw userError; + } + } + + private buildProfileUpdates( + fields: TProfileUpdateForm, + currentProfile: TProfile, + locale: TLocale | undefined, + avatarUrl?: string + ): Partial { + const profileUpdates: Partial = {}; + + if (fields.fullname !== currentProfile.fullname) { + profileUpdates.fullname = fields.fullname; + } + + if (fields.username !== currentProfile.username) { + profileUpdates.username = fields.username; + } + + if (locale && locale !== currentProfile.locale) { + profileUpdates.locale = locale; + } + + if (avatarUrl) { + profileUpdates.avatarUrl = avatarUrl; + } + + return profileUpdates; + } + + private async updateProfile(profileUpdates: Partial): Promise { + if (Object.keys(profileUpdates).length === 0) { + return; + } + + await this.execute({ + requestFn: () => classroomio.account.user.$put({ json: profileUpdates }), + logContext: 'updating profile', + onSuccess: (response) => { + profileStore.update((_profile) => ({ + ..._profile, + ...response.profile + })); + } + }); + } + + private handleError(error: unknown): void { + console.error(error); + const message = error instanceof Error ? error.message : `${error}`; + + if (message.includes('profile_username_key')) { + this.errors.username = 'snackbar.lms.error.username_exists'; + snackbar.error('snackbar.lms.error.username_exists'); + } else { + this.errors.general = message; + snackbar.error(`snackbar.lms.error.update: ${message}`); + } + } + + async submit(fields: TProfileUpdateForm, currentProfile: TProfile, hasLangChanged: boolean, locale?: TLocale) { + if (!this.validateForm(fields)) { + return; + } + + try { + this.isLoading = true; + this.errors = {}; + this.success = false; + + // Handle avatar upload if provided + let avatarUrl: string | undefined; + if (fields.avatar instanceof File) { + avatarUrl = await this.uploadAvatar(fields.avatar); + } + + // Update user info via Better Auth (name and image) + await this.updateUserInfo(fields, currentProfile, avatarUrl); + + // Update profile-specific fields via API route + const profileUpdates = this.buildProfileUpdates(fields, currentProfile, locale, avatarUrl); + await this.updateProfile(profileUpdates); + + // Handle locale change if needed + if (hasLangChanged && locale) { + handleLocaleChange(locale); + } + + snackbar.success('snackbar.course_settings.success.update_successful'); + this.success = true; + } catch (error) { + this.handleError(error); + } finally { + this.isLoading = false; + } + } + + async changeEmail(fields: TChangeEmailForm) { + // Validate email format + const result = ZChangeEmail.safeParse({ newEmail: fields.newEmail }); + console.log('result', result); + if (!result.success) { + this.errors = mapZodErrorsToTranslations(result.error); + return; + } + + try { + this.isLoading = true; + this.errors = {}; + this.success = false; + + const { error } = await authClient.changeEmail({ + newEmail: fields.newEmail, + callbackURL: window.location.origin + }); + + if (error) throw new Error(error.message); + + snackbar.success(); + this.success = true; + } catch (error) { + console.error(error); + const message = error instanceof Error ? error.message : `${error}`; + + this.errors.email = message; + snackbar.error(`${t.get('snackbar.lms.error.update')} ${message}`); + } finally { + this.isLoading = false; + } + } +} + +export const profileApi = new ProfileApi(); diff --git a/apps/dashboard/src/lib/features/auth/api/reset.svelte.ts b/apps/dashboard/src/lib/features/auth/api/reset.svelte.ts new file mode 100644 index 00000000..9518574b --- /dev/null +++ b/apps/dashboard/src/lib/features/auth/api/reset.svelte.ts @@ -0,0 +1,47 @@ +import { BaseApiWithErrors } from '$lib/utils/services/api'; +import type { TResetPasswordForm } from '../utils/types'; +import { authClient } from '$lib/utils/services/auth/client'; +import { goto } from '$app/navigation'; +import { resetPasswordValidation } from '../utils/validation'; +import { resolve } from '$app/paths'; +import { snackbar } from '$lib/components/Snackbar/store'; + +export class ResetApi extends BaseApiWithErrors { + async submit(fields: TResetPasswordForm) { + const errors = resetPasswordValidation(fields); + if (errors) { + this.errors = errors; + return; + } + + try { + this.isLoading = true; + this.errors = {}; + this.success = false; + + const { error } = await authClient.resetPassword({ + newPassword: fields.password, // required + token: fields.token // required + }); + + if (error) throw new Error(error.message); + + this.success = true; + snackbar.success(); + + goto(resolve('/login', {})); + } catch (error) { + console.error(error); + const message = error instanceof Error ? error.message : String(error); + snackbar.error(message); + } finally { + this.isLoading = false; + } + } + + setError(_errors: Record) { + this.errors = _errors; + } +} + +export const resetApi = new ResetApi(); diff --git a/packages/utils/src/plans/types.ts b/apps/dashboard/src/lib/features/auth/api/types.ts similarity index 100% rename from packages/utils/src/plans/types.ts rename to apps/dashboard/src/lib/features/auth/api/types.ts diff --git a/apps/dashboard/src/lib/features/auth/utils/types.ts b/apps/dashboard/src/lib/features/auth/utils/types.ts new file mode 100644 index 00000000..db75dfc0 --- /dev/null +++ b/apps/dashboard/src/lib/features/auth/utils/types.ts @@ -0,0 +1,13 @@ +import * as z from 'zod'; + +export const ZForgotPasswordForm = z.object({ + email: z.email() +}); +export type TForgotPasswordForm = z.infer; + +export const ZResetPasswordForm = z.object({ + password: z.string().min(8), + confirmPassword: z.string().min(8), + token: z.string() +}); +export type TResetPasswordForm = z.infer; diff --git a/apps/dashboard/src/lib/features/auth/utils/validation.ts b/apps/dashboard/src/lib/features/auth/utils/validation.ts new file mode 100644 index 00000000..c287cf8a --- /dev/null +++ b/apps/dashboard/src/lib/features/auth/utils/validation.ts @@ -0,0 +1,19 @@ +import type { TForgotPasswordForm, TResetPasswordForm } from './types'; + +import { ForgotPasswordSchema } from '@cio/utils/validation/auth/forgot'; +import { ResetPasswordSchema } from '@cio/utils/validation/auth/reset'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; + +export const forgotPasswordValidation = (field: TForgotPasswordForm) => { + const result = ForgotPasswordSchema.safeParse(field); + if (!result.success) { + return mapZodErrorsToTranslations(result.error); + } +}; + +export const resetPasswordValidation = (field: TResetPasswordForm) => { + const result = ResetPasswordSchema.safeParse(field); + if (!result.success) { + return mapZodErrorsToTranslations(result.error); + } +}; diff --git a/apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts b/apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts new file mode 100644 index 00000000..c8d25b9d --- /dev/null +++ b/apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts @@ -0,0 +1,108 @@ +import type { OnboardingField, OnboardingStep } from '../utils/types'; +import { currentOrg, orgs } from '$lib/utils/store/org'; + +import { ONBOARDING_STEPS } from '../utils/constants'; +import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api'; +import { goto } from '$app/navigation'; +import { handleLocaleChange } from '$lib/utils/functions/translations'; +import { onboardingValidation } from '../utils/validations'; +import { profile } from '$lib/utils/store/user'; +import { resolve } from '$app/paths'; +import { snackbar } from '$lib/components/Snackbar/store'; + +export class OnboardingApi extends BaseApiWithErrors { + step: OnboardingStep = $state(ONBOARDING_STEPS.ORG_SETUP); + + async submit(data: OnboardingField) { + if (this.step === ONBOARDING_STEPS.ORG_SETUP) { + return this.submitOrgSetup(data); + } + + if (this.step === ONBOARDING_STEPS.USER_METADATA) { + return this.submitUserMetada(data); + } + + return false; + } + + async submitOrgSetup(data: OnboardingField) { + const errors = onboardingValidation(data, ONBOARDING_STEPS.ORG_SETUP); + if (errors) { + this.errors = errors; + return false; + } + + await this.execute({ + requestFn: () => classroomio.onboarding.step1.$post({ json: data }), + logContext: 'submitting organization setup', + onSuccess: (result) => { + const { organizations } = result.data; + + orgs.set(organizations); + currentOrg.set(organizations[0]); + + this.errors = {}; + this.step = ONBOARDING_STEPS.USER_METADATA; + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error(result); + return; + } + + if ('message' in result) { + snackbar.error(result.message); + return; + } + + // map api validation back to frontend fields + if ('error' in result) { + this.handleValidationError(result); + } + } + }); + } + + async submitUserMetada(data: OnboardingField) { + const errors = onboardingValidation(data, ONBOARDING_STEPS.USER_METADATA); + if (errors) { + this.errors = errors; + return false; + } + + await this.execute({ + requestFn: () => classroomio.onboarding.step2.$post({ json: data }), + logContext: 'submitting organization setup', + onSuccess: (result) => { + profile.set(result.data); + handleLocaleChange(result.data.locale ?? 'en'); + + const welcomePopup = `${result.data.isEmailVerified}`; + + return goto(resolve(`/org/${data.siteName}?welcomePopup=${welcomePopup}`, {})); + }, + onError: (result) => { + if (typeof result === 'string') { + snackbar.error(result); + return; + } + + if ('message' in result) { + snackbar.error(result.message); + return; + } + + // map api validation back to frontend fields + if ('error' in result) { + this.handleValidationError(result); + } + } + }); + } + + override reset() { + super.reset(); + } +} + +export const onboardingApi = new OnboardingApi(); diff --git a/apps/dashboard/src/lib/features/onboarding/components/index.ts b/apps/dashboard/src/lib/features/onboarding/components/index.ts new file mode 100644 index 00000000..5a969815 --- /dev/null +++ b/apps/dashboard/src/lib/features/onboarding/components/index.ts @@ -0,0 +1,2 @@ +export { default as WelcomeModal } from './welcome-modal.svelte'; +export { default as VerifyEmailModal } from './verify-email-modal.svelte'; diff --git a/apps/dashboard/src/lib/components/Org/VerifyEmail/VerifyEmailModal.svelte b/apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte similarity index 71% rename from apps/dashboard/src/lib/components/Org/VerifyEmail/VerifyEmailModal.svelte rename to apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte index a4c1c8cd..a7122a62 100644 --- a/apps/dashboard/src/lib/components/Org/VerifyEmail/VerifyEmailModal.svelte +++ b/apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte @@ -1,13 +1,15 @@ - +

{$t('welcome_modal.we_at')} ClassroomIO diff --git a/apps/dashboard/src/lib/features/onboarding/utils/constants.ts b/apps/dashboard/src/lib/features/onboarding/utils/constants.ts new file mode 100644 index 00000000..95b03d82 --- /dev/null +++ b/apps/dashboard/src/lib/features/onboarding/utils/constants.ts @@ -0,0 +1,59 @@ +export const ONBOARDING_STEPS = { + ORG_SETUP: 1, + USER_METADATA: 2 +} as const; + +export const DROPDOWN_ITEMS = [ + { id: 'de', text: 'German' }, + { id: 'en', text: 'English' }, + { id: 'es', text: 'Spanish' }, + { id: 'fr', text: 'French' }, + { id: 'hi', text: 'Hindi' }, + { id: 'pl', text: 'Polish' }, + { id: 'pt', text: 'Portuguese' }, + { id: 'ru', text: 'Russian' }, + { id: 'vi', text: 'Vietnamese' }, + { id: 'da', text: 'Danish' } +]; + +export const GOALS = [ + { + label: 'onboarding.sell', + value: 'sell-online' + }, + { + label: 'onboarding.teach', + value: 'teach-online' + }, + { + label: 'onboarding.employees', + value: 'employees' + }, + { + label: 'onboarding.customers', + value: 'customers' + }, + { + label: 'onboarding.expanding', + value: 'expanding-platform' + } +]; + +export const SOURCES = [ + { + label: 'onboarding.articles', + value: 'articles' + }, + { + label: 'onboarding.search', + value: 'search-engine' + }, + { + label: 'onboarding.social', + value: 'social-media' + }, + { + label: 'onboarding.friends', + value: 'friends-family' + } +]; diff --git a/apps/dashboard/src/lib/features/onboarding/utils/types.ts b/apps/dashboard/src/lib/features/onboarding/utils/types.ts new file mode 100644 index 00000000..1f9a2e2d --- /dev/null +++ b/apps/dashboard/src/lib/features/onboarding/utils/types.ts @@ -0,0 +1,19 @@ +import { ONBOARDING_STEPS } from './constants'; +import type { TLocale } from '@cio/db/types'; + +export type OnboardingStep = (typeof ONBOARDING_STEPS)[keyof typeof ONBOARDING_STEPS]; + +export interface OnboardingField { + fullname?: string; + orgName?: string; + siteName?: string; + goal?: string; + source?: string; + locale?: TLocale; + metadata?: Record; +} + +export interface Goal { + label: string; + value: string; +} diff --git a/apps/dashboard/src/lib/features/onboarding/utils/validations.ts b/apps/dashboard/src/lib/features/onboarding/utils/validations.ts new file mode 100644 index 00000000..e8a21351 --- /dev/null +++ b/apps/dashboard/src/lib/features/onboarding/utils/validations.ts @@ -0,0 +1,26 @@ +import type { OnboardingField, OnboardingStep } from './types'; +import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding'; + +import { ONBOARDING_STEPS } from './constants'; +import { blockedSubdomain } from '@cio/utils/constants'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; + +export const onboardingValidation = (field: OnboardingField, step: OnboardingStep) => { + if (step === ONBOARDING_STEPS.ORG_SETUP) { + if (blockedSubdomain.includes(field.siteName || '')) { + return { siteName: 'Sitename already exists.' }; + } + + const result = ZOnboardingStep1.safeParse(field); + if (!result.success) { + return mapZodErrorsToTranslations(result.error); + } + } + + if (step === ONBOARDING_STEPS.USER_METADATA) { + const result = ZOnboardingStep2.safeParse(field); + if (!result.success) { + return mapZodErrorsToTranslations(result.error); + } + } +}; diff --git a/apps/dashboard/src/lib/features/org/api/index.ts b/apps/dashboard/src/lib/features/org/api/index.ts new file mode 100644 index 00000000..bff4523e --- /dev/null +++ b/apps/dashboard/src/lib/features/org/api/index.ts @@ -0,0 +1,2 @@ +export * from './org.svelte'; +export * from './org-plan.svelte'; diff --git a/apps/dashboard/src/lib/features/org/api/org-plan.svelte.ts b/apps/dashboard/src/lib/features/org/api/org-plan.svelte.ts new file mode 100644 index 00000000..dfb5fcab --- /dev/null +++ b/apps/dashboard/src/lib/features/org/api/org-plan.svelte.ts @@ -0,0 +1,46 @@ +import { BaseApi, classroomio } from '$lib/utils/services/api'; + +import type { TNewOrganizationPlan } from '@cio/db/types'; + +/** + * API class for organization plan operations + */ +class OrgPlanApi extends BaseApi { + /** + * Creates a new organization plan + * @param params Organization plan creation parameters + * @returns Created organization plan + */ + async createOrgPlan(params: TNewOrganizationPlan) { + return this.execute({ + requestFn: () => classroomio.organization.plan.$post({ json: params }), + logContext: 'creating organization plan' + }); + } + + /** + * Updates an organization plan + * @param params Update parameters (subscriptionId, payload) + * @returns Updated organization plan + */ + async updateOrgPlan(params: { subscriptionId: string; payload: Record }) { + return this.execute({ + requestFn: () => classroomio.organization.plan.$put({ json: params }), + logContext: 'updating organization plan' + }); + } + + /** + * Cancels an organization plan + * @param params Cancel parameters (subscriptionId, payload) + * @returns Updated organization plan + */ + async cancelOrgPlan(params: { subscriptionId: string; payload: Record }) { + return this.execute({ + requestFn: () => classroomio.organization.plan.cancel.$post({ json: params }), + logContext: 'canceling organization plan' + }); + } +} + +export const orgPlanApi = new OrgPlanApi(); diff --git a/apps/dashboard/src/lib/features/org/api/org.svelte.ts b/apps/dashboard/src/lib/features/org/api/org.svelte.ts new file mode 100644 index 00000000..34daed2b --- /dev/null +++ b/apps/dashboard/src/lib/features/org/api/org.svelte.ts @@ -0,0 +1,108 @@ +import { BaseApi, classroomio } from '$lib/utils/services/api'; +import type { + CoursesByOrganizationSiteName, + GetAudienceResponse, + GetCoursesBySiteNameResponse, + OrganizationAudience, + OrganizationTeamMembers +} from '../utils/types'; + +import type { GetTeamResponse } from '../utils/types'; +import type { TGetOrganizations } from '@cio/utils/validation/organization'; +import { orgs } from '$lib/utils/store/org'; + +/** + * API class for organization operations + */ +class OrgApi extends BaseApi { + teamMembers = $state([]); + audience = $state([]); + orgSiteCourses = $state([]); + + /** + * Gets organization team members (non-students) + * @param orgId Organization ID + * @returns Team members array + */ + async getOrgTeam(orgId: string) { + return this.execute({ + requestFn: () => + classroomio.organization[':orgId'].team.$get({ + param: { orgId } + }), + logContext: 'fetching organization team', + onSuccess: (response) => { + this.teamMembers = response.data; + } + }); + } + + /** + * Gets organization audience (students) + * @param orgId Organization ID + * @returns Audience array + */ + async getOrgAudience(orgId: string) { + return this.execute({ + requestFn: () => + classroomio.organization[':orgId'].audience.$get({ + param: { orgId } + }), + logContext: 'fetching organization audience', + onSuccess: (response) => { + this.audience = response.data; + } + }); + } + + /** + * Gets courses by organization siteName + * @param siteName Organization site name + * @returns Courses array + */ + async getCourseBySiteName(siteName: string) { + return this.execute({ + requestFn: () => + classroomio.organization.courses.$get({ + query: { siteName } + }), + logContext: 'fetching courses', + onSuccess: (response) => { + this.orgSiteCourses = response.data; + } + }); + } + + /** + * Gets current organization by siteName or custom domain + * @param siteName Organization site name or custom domain + * @param isCustomDomain Whether the siteName is a custom domain + * @returns Organization data or null if not found + */ + async getOrgBySiteName(siteName: string, isCustomDomain = false) { + const query: TGetOrganizations = { siteName }; + if (isCustomDomain) { + query.customDomain = siteName; + query.isCustomDomainVerified = true; + } + + const result = await this.execute({ + requestFn: () => + classroomio.organization.$get({ + query + }), + logContext: 'fetching organization', + onSuccess: (response) => { + if (!response.data || !Array.isArray(response.data) || response.data.length === 0) { + return; + } + + orgs.set(response.data); + } + }); + + return result; + } +} + +export const orgApi = new OrgApi(); diff --git a/apps/dashboard/src/lib/features/org/utils/types.ts b/apps/dashboard/src/lib/features/org/utils/types.ts new file mode 100644 index 00000000..b0935e86 --- /dev/null +++ b/apps/dashboard/src/lib/features/org/utils/types.ts @@ -0,0 +1,25 @@ +import { classroomio, type InferResponseType } from '$lib/utils/services/api'; + +// Team member types +export type GetTeamResponse = (typeof classroomio.organization)[':orgId']['team']['$get']; +export type OrganizationTeamResponse = InferResponseType | null; +export type OrganizationTeamSuccess = Extract, { success: true }>; + +export type OrganizationTeamMembers = OrganizationTeamSuccess['data']; + +// Audience types +export type GetAudienceResponse = (typeof classroomio.organization)[':orgId']['audience']['$get']; +export type OrganizationAudienceResponse = InferResponseType | null; +export type OrganizationAudienceSuccess = Extract, { success: true }>; + +export type OrganizationAudience = OrganizationAudienceSuccess['data']; + +// Org Public Courses types +export type GetCoursesBySiteNameResponse = typeof classroomio.organization.courses.$get; +export type CoursesByOrganizationSiteNameResponse = InferResponseType | null; +export type CoursesByOrganizationSiteNameSuccess = Extract< + InferResponseType, + { success: true } +>; + +export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data']; diff --git a/apps/dashboard/src/lib/components/ComingSoon/index.svelte b/apps/dashboard/src/lib/features/ui/coming-soon.svelte similarity index 53% rename from apps/dashboard/src/lib/components/ComingSoon/index.svelte rename to apps/dashboard/src/lib/features/ui/coming-soon.svelte index 71fd2132..eb0bb1a2 100644 --- a/apps/dashboard/src/lib/components/ComingSoon/index.svelte +++ b/apps/dashboard/src/lib/features/ui/coming-soon.svelte @@ -1,14 +1,10 @@ -

+ {label} -

- - + diff --git a/apps/dashboard/src/lib/features/ui/index.ts b/apps/dashboard/src/lib/features/ui/index.ts new file mode 100644 index 00000000..03ee04c6 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/index.ts @@ -0,0 +1,3 @@ +export { default as ComingSoon } from './coming-soon.svelte'; +export { default as PageLoadProgress } from './page-load-progress.svelte'; +export { default as AppHeader } from './navigation/app-header.svelte'; diff --git a/apps/dashboard/src/lib/features/ui/navigation/app-breadcrumbs.svelte b/apps/dashboard/src/lib/features/ui/navigation/app-breadcrumbs.svelte new file mode 100644 index 00000000..3bb4fe20 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/navigation/app-breadcrumbs.svelte @@ -0,0 +1,104 @@ + + + + + + + + + {#if breadcrumbs.length > 0} + + + {#if breadcrumbs.length > ITEMS_TO_DISPLAY} + + + {#if isDesktop.current} + + + + + + {#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)} + + + {breadcrumb.label} + + + {/each} + + + {:else} + + + + + + + Navigate to + Select a page to navigate to. + +
+ {#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)} + + {breadcrumb.label} + + {/each} +
+ + Close + +
+
+ {/if} +
+ + {/if} + + + {#each breadcrumbs.slice(-ITEMS_TO_DISPLAY) as breadcrumb, index (breadcrumb.label)} + + {@const isLast = index === breadcrumbs.slice(-ITEMS_TO_DISPLAY).length - 1} + {#if !isLast} + + {breadcrumb.label} + + + {:else} + + {breadcrumb.label} + + {/if} + + {/each} + {/if} +
+
diff --git a/apps/dashboard/src/lib/features/ui/navigation/app-header.svelte b/apps/dashboard/src/lib/features/ui/navigation/app-header.svelte new file mode 100644 index 00000000..5b59d5e1 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/navigation/app-header.svelte @@ -0,0 +1,42 @@ + + +
+
+ + +
+ +
+ + + + + + + + + + + + +
+
+

Your notifications will show here

+
+
+
+
+
+
diff --git a/apps/dashboard/src/lib/features/ui/navigation/breadcrumb.ts b/apps/dashboard/src/lib/features/ui/navigation/breadcrumb.ts new file mode 100644 index 00000000..94a24143 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/navigation/breadcrumb.ts @@ -0,0 +1,108 @@ +import type { NavItem } from './org-navigation'; +import { isActive } from '$lib/utils/functions/app'; + +export interface BreadcrumbItem { + label: string; + href: string; +} + +/** + * Generate breadcrumbs from the current pathname using navigation structure + */ +export function generateBreadcrumbs( + pathname: string, + searchParams: string, + navItems: NavItem[], + currentOrgPath: string, + pageData?: { breadcrumb?: string } // Optional page data for dynamic titles +): BreadcrumbItem[] { + const breadcrumbs: BreadcrumbItem[] = []; + const pathWithQuery = pathname + searchParams; + + // Remove the org path prefix to get the relative path + const relativePath = pathname.replace(currentOrgPath, '').replace(/^\//, '') || ''; + const pathSegments = relativePath.split('/').filter(Boolean); + + // Handle root/dashboard + if (!relativePath || relativePath === '') { + return breadcrumbs; + } + + // Find the matching top-level nav item + const firstSegment = pathSegments[0]; + let matchedNavItem: NavItem | undefined; + + for (const item of navItems) { + // Use path for matching (works for both hash URLs and regular URLs) + const itemPath = item.path.replace(/^\//, ''); + if (itemPath === firstSegment || (itemPath === '' && firstSegment === undefined)) { + matchedNavItem = item; + break; + } + } + + if (!matchedNavItem) { + // Try to match by checking if pathname matches the item path + for (const item of navItems) { + const fullItemPath = `${currentOrgPath}${item.path}`; + if (isActive(pathname, fullItemPath)) { + matchedNavItem = item; + break; + } + } + } + + if (matchedNavItem) { + // Handle hash URLs (like settings) - use the actual path instead of '#' + const href = matchedNavItem.useHashUrl ? `${currentOrgPath}${matchedNavItem.path}` : matchedNavItem.url; + + breadcrumbs.push({ + label: matchedNavItem.title, + href: href + }); + } + + // Handle items with query params (like settings tabs) + if (matchedNavItem?.items && searchParams) { + const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, true)); + if (subItem) { + breadcrumbs.push({ + label: subItem.title, + href: subItem.url + }); + return breadcrumbs; + } + } + + // Handle nested routes + if (pathSegments.length > 1 && matchedNavItem) { + const secondSegment = pathSegments[1]; + + // Check for static nested routes (like community/ask, settings/customize-lms) + if (matchedNavItem.nestedRoutes) { + const nestedRoute = matchedNavItem.nestedRoutes.find((route) => route.path === secondSegment); + if (nestedRoute) { + breadcrumbs.push({ + label: nestedRoute.titleKey, // Could be translated if needed + href: `${currentOrgPath}${matchedNavItem.path}/${nestedRoute.path}` + }); + return breadcrumbs; + } + } + + // Check for dynamic segments (like community/[slug], quiz/[slug]) + if (matchedNavItem.supportsDynamicSegment && secondSegment) { + // Check if it's not a static nested route + const isStaticRoute = matchedNavItem.nestedRoutes?.some((route) => route.path === secondSegment); + if (!isStaticRoute && pageData?.breadcrumb) { + breadcrumbs.push({ + label: pageData.breadcrumb, + href: `${currentOrgPath}${matchedNavItem.path}/${secondSegment}` + }); + } + return breadcrumbs; + } + } + + return breadcrumbs; +} diff --git a/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts b/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts new file mode 100644 index 00000000..77be0c88 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/navigation/org-navigation.ts @@ -0,0 +1,162 @@ +import type { AccountOrg } from '$lib/features/app/types'; +import CogIcon from '@lucide/svelte/icons/cog'; +import type { Component } from 'svelte'; +import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'; +import LibraryBigIcon from '@lucide/svelte/icons/library-big'; +import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more'; +import Settings2Icon from '@lucide/svelte/icons/settings-2'; +import UsersIcon from '@lucide/svelte/icons/users'; +import { isActive } from '$lib/utils/functions/app'; + +export interface NavItem { + title: string; // translation key + url: string; + path: string; // Actual path (e.g., '/settings') for breadcrumb generation + icon?: Component; + isActive?: boolean; + isExpanded?: boolean; + items?: NavItem[]; // for nested items like settings + // Metadata for breadcrumb generation + useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings) + nestedRoutes?: NestedRouteConfig[]; // Static nested routes (like community/ask, settings/customize-lms) + supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug]) +} + +export interface NavItemConfig { + titleKey: string; + path: string; + icon?: Component; + requiresAdmin?: boolean; + items?: NavItemConfig[]; + useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings) + nestedRoutes?: NestedRouteConfig[]; // Static nested routes + supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug]) +} + +export interface NestedRouteConfig { + path: string; // Relative to parent (e.g., 'ask', 'customize-lms') + titleKey: string; // Translation key or plain text +} + +// Base navigation configuration structure +const baseNavConfig: NavItemConfig[] = [ + { + titleKey: 'org_navigation.dashboard', + path: '', + icon: LayoutDashboardIcon + }, + { + titleKey: 'org_navigation.courses', + path: '/courses', + icon: LibraryBigIcon + }, + { + titleKey: 'org_navigation.community', + path: '/community', + icon: MessageSquareMoreIcon, + supportsDynamicSegment: true, // Supports /community/[slug] + nestedRoutes: [ + { + path: 'ask', + titleKey: 'Ask Question' // Could be translated + } + ] + }, + { + titleKey: 'org_navigation.audience', + path: '/audience', + icon: UsersIcon + }, + { + titleKey: 'org_navigation.setup', + path: '/setup', + icon: Settings2Icon, + requiresAdmin: true + }, + { + titleKey: 'org_navigation.settings', + path: '/settings', + icon: CogIcon, + useHashUrl: true, // Use '#' for collapsible parent + items: [ + { + titleKey: 'Profile', + path: '/settings' + }, + { + titleKey: 'Organization', + path: '/settings?tab=org' + }, + { + titleKey: 'Landing Page', + path: '/settings?tab=landingpage' + }, + { + titleKey: 'Billing', + path: '/settings?tab=billing' + } + ], + nestedRoutes: [ + { + path: 'customize-lms', + titleKey: 'Customize LMS' + }, + { + path: 'domains', + titleKey: 'Domains' + }, + { + path: 'teams', + titleKey: 'Teams' + } + ] + } +]; + +/** + * Get navigation items based on organization context and permissions + */ +export function getOrgNavigationItems( + currentOrgPath: string, + currentOrg: AccountOrg, + isOrgAdmin: boolean | null, + t: (key: string) => string, + pagePathname: string +): NavItem[] { + const items: NavItem[] = []; + + for (const config of baseNavConfig) { + // Skip admin-only items if user is not admin + if (config.requiresAdmin && !isOrgAdmin) { + continue; + } + + const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`; + const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`; + + const item: NavItem = { + title: t(config.titleKey), + url: config.useHashUrl ? '#' : url, + path: config.path, // Store actual path for breadcrumb generation + icon: config.icon, + isActive: isActive(pagePathname, fullPath), + isExpanded: config.items ? isActive(pagePathname, fullPath) : undefined, + useHashUrl: config.useHashUrl, + nestedRoutes: config.nestedRoutes, + supportsDynamicSegment: config.supportsDynamicSegment + }; + + // Handle nested items (like settings sub-items) + if (config.items) { + item.items = config.items.map((subConfig) => ({ + title: subConfig.titleKey, + url: `${currentOrgPath}${subConfig.path}`, + path: subConfig.path + })); + } + + items.push(item); + } + + return items; +} diff --git a/apps/dashboard/src/lib/components/Progress/PageLoadProgressBar.svelte b/apps/dashboard/src/lib/features/ui/page-load-progress.svelte similarity index 100% rename from apps/dashboard/src/lib/components/Progress/PageLoadProgressBar.svelte rename to apps/dashboard/src/lib/features/ui/page-load-progress.svelte diff --git a/apps/dashboard/src/lib/features/ui/search.svelte b/apps/dashboard/src/lib/features/ui/search.svelte new file mode 100644 index 00000000..3058f908 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/search.svelte @@ -0,0 +1,16 @@ + + + + + + + + + ⌘ + K + + diff --git a/apps/dashboard/src/lib/features/ui/sidebar/footer/index.ts b/apps/dashboard/src/lib/features/ui/sidebar/footer/index.ts new file mode 100644 index 00000000..3606e2c8 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/footer/index.ts @@ -0,0 +1 @@ +export { default as SidebarFooterMenu } from './menu.svelte'; diff --git a/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte b/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte new file mode 100644 index 00000000..324bab63 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/footer/menu.svelte @@ -0,0 +1,141 @@ + + + +{#snippet avatarblock()} + + + {$profile.fullname.slice(0, 2)} + +
+ {$profile.fullname} + {$profile.email} +
+{/snippet} + +{#snippet usertrigger()} + + + {@render avatarblock()} + + +{/snippet} + +{#snippet themetoggle()} + + + +{/snippet} + + + + + + + + {#snippet child({ props })} + + {@render avatarblock()} + + + {/snippet} + + + + + {@render usertrigger()} + + + {@render themetoggle()} + + + + +
+ {#if !$globalStore.isOrgSite} + + {/if} + + + + logout()}> + + +

{$t('settings.profile.logout')}

+
+
+
+
+
+
+
+
diff --git a/apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte b/apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte new file mode 100644 index 00000000..1c1fd88c --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/footer/theme-toggle.svelte @@ -0,0 +1,46 @@ + + +
+

Theme

+ +
+ {#each themes as theme (theme.mode)} + {@const isActive = userPrefersMode.current === theme.mode || userPrefersMode.current === undefined} + + {/each} +
+
diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/app-logo.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/app-logo.svelte new file mode 100644 index 00000000..a2c0c17f --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/app-logo.svelte @@ -0,0 +1,35 @@ + + + + + + {#snippet child({ props })} + + + + + + ClassroomIO + + {planName.toLowerCase()} + + + {/snippet} + + + diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/index.ts b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/index.ts new file mode 100644 index 00000000..adedd869 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/index.ts @@ -0,0 +1 @@ +export { default as OrgSidebar } from './org-sidebar.svelte'; diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/nav-main.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/nav-main.svelte new file mode 100644 index 00000000..fb9df323 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/nav-main.svelte @@ -0,0 +1,64 @@ + + + + {$t('org_navigation.platform')} + + {#each items as item (item.title)} + + {#snippet child({ props })} + + + {#snippet child({ props })} + + {#snippet child({ props })} + + {#if item.icon} + + {/if} + {item.title} + {#if item.items} + + {/if} + + {/snippet} + + {/snippet} + + {#if item.items} + + + {#each item.items ?? [] as subItem (subItem.title)} + + + {#snippet child({ props })} + + {subItem.title} + + {/snippet} + + + {/each} + + + {/if} + + {/snippet} + + {/each} + + diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-sidebar.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-sidebar.svelte new file mode 100644 index 00000000..99ea9152 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-sidebar.svelte @@ -0,0 +1,34 @@ + + +{#if !isOrgLoaded} + +{:else} + + + + + + + + + + + + + + + + +{/if} diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte new file mode 100644 index 00000000..8cd989df --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/org-switcher.svelte @@ -0,0 +1,156 @@ + + +{#if variant === 'breadcrumb'} + + + + {#snippet child({ props })} + + {#if $currentOrg.name} + + + {getShortOrgName($currentOrg.name)} + + + + {/snippet} + + + Organizations + + {#each $orgs as org (org.name)} + onClick(org)} class="gap-2 p-2"> + + + {getShortOrgName(org.name)} + + + {org.name} + + {/each} + + + + +
+ +
+
Add Organization
+ + +
+
+
+{:else} + + + + + + {#snippet child({ props })} + + {#if $currentOrg.name} + + + {getShortOrgName($currentOrg.name)} + + +
+ + {$currentOrg.name} + + + {$currentOrg.plans?.[0]?.planName || 'Free'} + +
+ + {:else} +
+ +
+ {/if} +
+ {/snippet} +
+ + Organizations + + {#each $orgs as org (org.name)} + onClick(org)} class="gap-2 p-2"> + + + {getShortOrgName(org.name)} + + + {org.name} + + {/each} + + + + +
+ +
+
Add Organization
+ + +
+
+
+
+
+{/if} diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/upgrade-trigger.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/upgrade-trigger.svelte new file mode 100644 index 00000000..4cebd96d --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/upgrade-trigger.svelte @@ -0,0 +1,50 @@ + + +{#if $isFreePlan} + + {#if sidebar.open || sidebar.isMobile} +
+ + +

{$t('org_navigation.early_adopter')}

+

{$t('org_navigation.unlock')}

+
+ +
+ {:else} + + {/if} +
+{/if} diff --git a/apps/dashboard/src/lib/features/ui/sidebar/sidebar-skeleton.svelte b/apps/dashboard/src/lib/features/ui/sidebar/sidebar-skeleton.svelte new file mode 100644 index 00000000..d2a25fd8 --- /dev/null +++ b/apps/dashboard/src/lib/features/ui/sidebar/sidebar-skeleton.svelte @@ -0,0 +1,49 @@ + + +
+ +
+ +
+ + +
+ +
+ + +
+ + {#each Array(5) as _, i (i)} +
+ + +
+ {/each} +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
diff --git a/apps/dashboard/src/lib/utils/constants/authentication.ts b/apps/dashboard/src/lib/utils/constants/authentication.ts index 688ccb3a..4603639a 100644 --- a/apps/dashboard/src/lib/utils/constants/authentication.ts +++ b/apps/dashboard/src/lib/utils/constants/authentication.ts @@ -5,16 +5,7 @@ export const SIGNUP_FIELDS = { confirmPassword: '' }; -export const RESET_FIELDS = { - password: '', - confirmPassword: '' -}; - export const LOGIN_FIELDS = { email: '', password: '' }; - -export const FORGOT_PASSWORD_FIELDS = { - email: '' -}; diff --git a/apps/dashboard/src/lib/utils/constants/roles.ts b/apps/dashboard/src/lib/utils/constants/roles.ts index b3f6be55..0af978f4 100644 --- a/apps/dashboard/src/lib/utils/constants/roles.ts +++ b/apps/dashboard/src/lib/utils/constants/roles.ts @@ -1,8 +1,4 @@ -export const ROLE = { - ADMIN: 1, - TUTOR: 2, - STUDENT: 3 -}; +import { ROLE } from '@cio/utils/constants'; export const ROLE_LABEL = { [ROLE.ADMIN]: 'course.navItem.people.roles.admin', diff --git a/apps/dashboard/src/lib/utils/constants/routes.ts b/apps/dashboard/src/lib/utils/constants/routes.ts index 5eea7e88..0453e793 100644 --- a/apps/dashboard/src/lib/utils/constants/routes.ts +++ b/apps/dashboard/src/lib/utils/constants/routes.ts @@ -15,11 +15,12 @@ export const ROUTE = { DISCUSSIONS: '/discussions', ASK: '/ask', FORGOT: '/forgot', - RESET: '/reset' + RESET: '/reset', + LOGOUT: '/logout' }; export const PUBLIC_ROUTES = [ - `^${ROUTE.HOME}$`, + // `^${ROUTE.HOME}$`, ROUTE.LOGIN, ROUTE.SIGN_UP, `^${ROUTE.INVITE_TEACHER}/.*`, @@ -30,6 +31,15 @@ export const PUBLIC_ROUTES = [ '/404' ]; +export const PUBLIC_API_ROUTES = [ + '/api/completion', + 'student_prove_payment', + 'teacher_student_buycourse', + '/api/polar', + '/api/lmz', + '/api/verify' +]; + export const ROUTES_TO_HIDE_NAV = [ `^${ROUTE.LOGIN}$`, `^${ROUTE.SIGN_UP}$`, diff --git a/apps/dashboard/src/lib/utils/constants/translation.ts b/apps/dashboard/src/lib/utils/constants/translation.ts index fec5b37d..a1927507 100644 --- a/apps/dashboard/src/lib/utils/constants/translation.ts +++ b/apps/dashboard/src/lib/utils/constants/translation.ts @@ -1,16 +1,16 @@ -import { LOCALE } from '../types'; +import type { TLocale } from '@cio/db/types'; -export const LANGUAGE = { - [LOCALE.DE]: 'German', - [LOCALE.EN]: 'English', - [LOCALE.ES]: 'Spanish', - [LOCALE.FR]: 'French', - [LOCALE.HI]: 'Hindi', - [LOCALE.PL]: 'Polish', - [LOCALE.PT]: 'Portuguese', - [LOCALE.RU]: 'Russian', - [LOCALE.VI]: 'Vietnamese', - [LOCALE.DA]: 'Danish' +export const LANGUAGE: Record = { + da: 'Danish', + de: 'German', + en: 'English', + es: 'Spanish', + fr: 'French', + hi: 'Hindi', + pl: 'Polish', + pt: 'Portuguese', + ru: 'Russian', + vi: 'Vietnamese' }; export const LANGUAGES = Object.keys(LANGUAGE).map((lang) => ({ diff --git a/apps/dashboard/src/lib/utils/functions/app.ts b/apps/dashboard/src/lib/utils/functions/app.ts index 1e644780..d504211c 100644 --- a/apps/dashboard/src/lib/utils/functions/app.ts +++ b/apps/dashboard/src/lib/utils/functions/app.ts @@ -25,5 +25,20 @@ export function isQuizPage(path) { } export function isLMSPage(path) { - return /lms[\/a-z 0-9 -]*/.test(path); + return /lms[/a-z 0-9 -]*/.test(path); +} + +export function isActive(pagePath: string, itemPath: string, exact: boolean = false) { + const pageLinkItems = pagePath.split('/'); + const itemLinkItems = itemPath.split('/'); + + if (exact) { + return pagePath === itemPath; + } + + if (itemLinkItems.length !== pageLinkItems.length) { + return false; + } + + return pagePath.includes(itemPath); } diff --git a/apps/dashboard/src/lib/utils/functions/appSetup.ts b/apps/dashboard/src/lib/utils/functions/appSetup.ts index c540a8da..75e16efb 100644 --- a/apps/dashboard/src/lib/utils/functions/appSetup.ts +++ b/apps/dashboard/src/lib/utils/functions/appSetup.ts @@ -1,21 +1,6 @@ -import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; -import { identifyPosthogUser, initPosthog } from '$lib/utils/services/posthog'; -import { initSentry, setSentryUser } from '$lib/utils/services/sentry'; -import { profile, user } from '$lib/utils/store/user'; - -import { ROLE } from '$lib/utils/constants/roles'; -import { ROUTE } from '$lib/utils/constants/routes'; import { dev } from '$app/environment'; -import { get } from 'svelte/store'; -import { getOrganizations } from '$lib/utils/services/org'; -import { goto } from '$app/navigation'; -import { handleLocaleChange } from '$lib/utils/functions/translations'; -import isEmpty from 'lodash/isEmpty'; -import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute'; -import { page } from '$app/stores'; -import { setTheme } from '$lib/utils/functions/theme'; -import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth'; -import { supabase } from '$lib/utils/functions/supabase'; +import { initPosthog } from '$lib/utils/services/posthog'; +import { initSentry } from '$lib/utils/services/sentry'; export function setupAnalytics() { // Set up sentry @@ -29,193 +14,3 @@ export function setupAnalytics() { localStorage.setItem('umami.disabled', '1'); } } - -function setAnalyticsUser() { - const profileStore = get(profile); - - if (!profileStore.id) return; - - setSentryUser({ - id: profileStore.id, - username: profileStore.username, - email: profileStore.email, - fullname: profileStore.fullname - }); - - identifyPosthogUser(profileStore.id, { - email: profileStore.email, - name: profileStore.fullname - }); -} - -export async function getProfile({ - path, - queryParam, - isOrgSite, - orgSiteName -}: { - path: string; - queryParam: string; - isOrgSite: boolean; - orgSiteName: string; -}) { - const pageStore = get(page); - const profileStore = get(profile); - const currentOrgStore = get(currentOrg); - const currentOrgDomainStore = get(currentOrgDomain); - - const params = new URLSearchParams(window.location.search); - // Get user profile - const { - data: { session } - } = await supabase.auth.getSession(); - const { user: authUser } = session || {}; - console.log('Get user', authUser); - - if (!authUser && !isPublicRoute(pageStore.url?.pathname)) { - return goto('/login?redirect=/' + path + queryParam); - } - - // Skip refetching profile, if already in store - if (profileStore.id) { - handleLocaleChange(profileStore.locale); - return; - } - - // Check if user has profile - const { - data: profileData, - error, - status - } = await supabase.from('profile').select(`*`).eq('id', authUser?.id).single(); - console.log('Get profile', profileData); - - if (error && !profileData && status === 406 && authUser) { - // User wasn't found, create profile - console.log(`User wasn't found, create profile`); - - const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])]; - - const isGoogleAuth = !!authUser.app_metadata?.providers?.includes('google'); - - const { data: newProfileData, error } = await supabase - .from('profile') - .insert({ - id: authUser.id, - username: regexUsernameMatch[1] + `${new Date().getTime()}`, - fullname: regexUsernameMatch[1], - email: authUser.email, - is_email_verified: isGoogleAuth, - verified_at: isGoogleAuth ? new Date().toDateString() : undefined - }) - .select(); - - // Profile created, go to onboarding or lms - if (!error && newProfileData) { - user.update((_user) => ({ - ..._user, - fetchingUser: false, - isLoggedIn: true, - currentSession: authUser - })); - - profile.set(newProfileData[0]); - - setAnalyticsUser(); - - // Fetch language - handleLocaleChange(newProfileData[0].locale); - - if (isOrgSite) { - const { data, error } = await supabase - .from('organizationmember') - .insert({ - organization_id: currentOrgStore.id, - profile_id: profileStore.id, - role_id: 3 - }) - .select(); - if (error) { - console.error('Error adding user to organisation', error); - } else { - console.log('Success adding user to organisation', data); - const memberId = data?.[0]?.id || ''; - - currentOrg.update((_currentOrg) => ({ - ..._currentOrg, - memberId - })); - } - - if (params.get('redirect')) { - goto(params.get('redirect') || ''); - } else if (shouldRedirectOnAuth(path)) { - goto('/lms'); - } - return; - } - - // On invite page, don't go to onboarding - if (!path.includes('invite')) { - goto(ROUTE.ONBOARDING); - } - } - - user.update((_user) => ({ - ..._user, - fetchingUser: false - })); - } else if (profileData) { - // Profile exists, go to profile page - user.update((_user) => ({ - ..._user, - fetchingUser: false, - isLoggedIn: true, - currentSession: authUser - })); - - profile.set(profileData); - - // Set user in sentry - setAnalyticsUser(); - - handleLocaleChange(profileData.locale); - - const orgRes = await getOrganizations(profileData.id, isOrgSite, orgSiteName); - - const isStudentAccount = orgRes.currentOrg.role_id == ROLE.STUDENT; - - // student redirect - if (isOrgSite) { - if (params.has('redirect')) { - goto(params.get('redirect') || ''); - } else if (shouldRedirectOnAuth(path)) { - goto('/lms'); - } - } else { - if (isStudentAccount) { - // Check if the student logged into the dashboard. - console.log('Student logged into dashboard'); - if (dev) { - goto('/lms'); - } else { - window.location.replace(`${currentOrgDomainStore}/lms`); - } - } else if (isEmpty(orgRes.orgs) && !path.includes('invite')) { - // Not on invite page or no org, go to onboarding - goto(ROUTE.ONBOARDING); - } else if (params.has('redirect')) { - goto(params.get('redirect') || ''); - } else if (shouldRedirectOnAuth(path)) { - // By default redirect to first organization - goto(`/org/${orgRes.currentOrg.siteName}`); - } - } - - setTheme(orgRes?.currentOrg?.theme); - } - - if (!profileData && !isPublicRoute(pageStore.url?.pathname)) { - goto('/login?redirect=/' + path); - } -} diff --git a/apps/dashboard/src/lib/utils/functions/logout.ts b/apps/dashboard/src/lib/utils/functions/logout.ts index ad147fc4..5c19bddf 100644 --- a/apps/dashboard/src/lib/utils/functions/logout.ts +++ b/apps/dashboard/src/lib/utils/functions/logout.ts @@ -1,23 +1,23 @@ -import posthog from 'posthog-js'; -import { supabase } from '$lib/utils/functions/supabase'; +import { appInitApi } from '$lib/features/app/init.svelte'; +import { authClient } from '$lib/utils/services/auth/client'; import { capturePosthogEvent } from '$lib/utils/services/posthog'; -import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user'; import { goto } from '$app/navigation'; +import posthog from 'posthog-js'; +import { resolve } from '$app/paths'; export async function logout(redirect = true) { - const { error } = await supabase.auth.signOut(); + const { error } = await authClient.signOut(); if (error) { console.error('Error logging out: ', error); } - user.set(defaultUserState); - profile.set(defaultProfileState); + appInitApi.reset(); capturePosthogEvent('user_logged_out'); posthog.reset(); if (redirect) { - goto('/login'); + goto(resolve('/login', {})); } } diff --git a/apps/dashboard/src/lib/utils/functions/org.ts b/apps/dashboard/src/lib/utils/functions/org.ts index 1ff3f3c2..d5ff2f4e 100644 --- a/apps/dashboard/src/lib/utils/functions/org.ts +++ b/apps/dashboard/src/lib/utils/functions/org.ts @@ -1,4 +1,5 @@ import { goto } from '$app/navigation'; +import { resolve } from '$app/paths'; export function genQuizPin(): number { const minm = 100000; @@ -20,5 +21,9 @@ export function openUpgradeModal() { const searchParams = url.searchParams.toString(); - goto(window.location.pathname + `?${searchParams}`); + goto(resolve(window.location.pathname + `?${searchParams}`, {})); +} + +export function getShortOrgName(name: string) { + return name?.substring(0, 2)?.toUpperCase() || ''; } diff --git a/apps/dashboard/src/lib/utils/functions/routes/isPublicRoute.ts b/apps/dashboard/src/lib/utils/functions/routes/isPublicRoute.ts index 49c9a843..2eb7ab6c 100644 --- a/apps/dashboard/src/lib/utils/functions/routes/isPublicRoute.ts +++ b/apps/dashboard/src/lib/utils/functions/routes/isPublicRoute.ts @@ -1,8 +1,14 @@ -import { PUBLIC_ROUTES } from '$lib/utils/constants/routes'; +import { PUBLIC_API_ROUTES, PUBLIC_ROUTES } from '$lib/utils/constants/routes'; -export default (route = '') => { +export const isPublicRoute = (route = '') => { return PUBLIC_ROUTES.some((publicRoute) => { const regex = new RegExp(publicRoute, 'g'); return regex.test(route); }); }; + +export default isPublicRoute; + +export const isPublicApiRoute = (pathname = '') => { + return PUBLIC_API_ROUTES.some((route) => pathname.includes(route)); +}; diff --git a/apps/dashboard/src/lib/utils/functions/routes/shouldRedirectOnAuth.ts b/apps/dashboard/src/lib/utils/functions/routes/shouldRedirectOnAuth.ts index 09288ff3..90f25cab 100644 --- a/apps/dashboard/src/lib/utils/functions/routes/shouldRedirectOnAuth.ts +++ b/apps/dashboard/src/lib/utils/functions/routes/shouldRedirectOnAuth.ts @@ -1,3 +1,3 @@ export default (path: string): boolean => { - return ['login', 'signup', 'onboarding'].some((r) => path.includes(r)) || path === ''; + return ['login', 'signup', 'onboarding'].some((r) => path.includes(r)) || path === '/'; }; diff --git a/apps/dashboard/src/lib/utils/functions/supabase.ts b/apps/dashboard/src/lib/utils/functions/supabase.ts index 0fcd9f34..8ac35935 100644 --- a/apps/dashboard/src/lib/utils/functions/supabase.ts +++ b/apps/dashboard/src/lib/utils/functions/supabase.ts @@ -11,6 +11,8 @@ export const getSupabase = () => { return supabase; }; +getSupabase(); + export const hasSession = async () => { const { data } = await getSupabase().auth.getSession(); console.log('has session', data); diff --git a/apps/dashboard/src/lib/utils/functions/translations.ts b/apps/dashboard/src/lib/utils/functions/translations.ts index 1858dce3..520bc5ad 100644 --- a/apps/dashboard/src/lib/utils/functions/translations.ts +++ b/apps/dashboard/src/lib/utils/functions/translations.ts @@ -1,4 +1,4 @@ -import { LOCALE } from '$lib/utils/types'; +import type { TLocale } from '@cio/db/types'; import i18n from '@sveltekit-i18n/base'; import parser from '@sveltekit-i18n/parser-icu'; import { writable } from 'svelte/store'; @@ -72,7 +72,7 @@ loading.subscribe(async ($loading) => { } }); -export function handleLocaleChange(newLocale: LOCALE) { +export function handleLocaleChange(newLocale: TLocale) { if (!newLocale) { return; } @@ -82,7 +82,7 @@ export function handleLocaleChange(newLocale: LOCALE) { selectedLocale.set(newLocale); } -export function lessonFallbackNote(note: string, translation: Record, locale: LOCALE) { +export function lessonFallbackNote(note: string, translation: Record, locale: TLocale) { if (!translation) { return note; } @@ -90,7 +90,7 @@ export function lessonFallbackNote(note: string, translation: Record { if (mapToId) { let value = ''; - path.forEach((p, i) => { + path.forEach((p) => { const formatP = isNumber(p) ? `[${p}]` : p; value += !value ? formatP : `.${formatP}`; @@ -113,28 +112,6 @@ export const coursePaymentValidation = (fields = {}) => { return processErrors(error); }; -export const resetValidation = (fields = {}) => { - const schema = z.object({ - password: z.string().min(6, { - message: `${t.get('validations.reset.password.min_char')}` - }) - }); - const { error } = schema.safeParse(fields); - - return processErrors(error); -}; - -export const forgotValidation = (fields = {}) => { - const schema = z.object({ - email: z.string().email({ - message: `${t.get('validations.forgot.invalid_email')}` - }) - }); - const { error } = schema.safeParse(fields); - - return processErrors(error); -}; - export const orgLandingpageValidation = (fields = {}) => { const schema = z.object({ name: z.string().min(6, { @@ -155,58 +132,6 @@ export const orgLandingpageValidation = (fields = {}) => { return processErrors(error); }; -export const onboardingValidation = (fields = {}, step) => { - const onboardingValidationSchema = { - stepOne: z.object({ - fullname: z.string().min(5, { message: `${t.get('validations.onboarding.step_one.full_name.min_char')}` }), - orgName: getOrgNameValidation(), - siteName: getSiteNameValidation() - }), - stepTwo: z.object({ - goal: z - .string({ - required_error: `${t.get('validations.onboarding.step_two.goal.required')}` - }) - .min(1), - source: z - .string({ - required_error: `${t.get('validations.onboarding.step_two.source.required')}` - }) - .min(1) - }) - }; - - const schema = step === 1 ? onboardingValidationSchema.stepOne : onboardingValidationSchema.stepTwo; - const { error } = schema.safeParse(fields); - - return processErrors(error); -}; - -export const updateProfileValidation = (fields = {}) => { - const schema = z.object({ - email: z.string().email({ message: 'validations.user_profile.email' }), - username: z.string().nonempty({ message: 'validations.user_profile.username' }), - fullname: z.string().min(5, { message: 'validations.user_profile.fullname' }) - }); - const { error } = schema.safeParse(fields); - - return processErrors(error); -}; - -// export const createTemplateExerciseValidation = (fields = {}) => { -// const schema = z.object({ -// orgName: z.string().min(5, { -// message: 'Must be 5 or more characters long', -// }), -// siteName: z.string().min(5, { -// message: 'Must be 5 or more characters long', -// }) -// }); -// const { error } = schema.safeParse(fields); - -// return processErrors(error); -// }; - export const createNewsfeedValidation = (newPost) => { const schema = z.object({ newPost: getNewsfeedValidation() diff --git a/apps/dashboard/src/lib/utils/services/api/base.svelte.ts b/apps/dashboard/src/lib/utils/services/api/base.svelte.ts new file mode 100644 index 00000000..5e43326d --- /dev/null +++ b/apps/dashboard/src/lib/utils/services/api/base.svelte.ts @@ -0,0 +1,141 @@ +import { getErrMsg } from './utils'; +import type { InferResponseType } from '@cio/api/rpc-types'; +import { ApiError, type ZodValidationError, type ZodValidatorMesssage } from './types'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation/validation'; +import { ZodError } from 'zod/v4'; + +// Extract the data type from a success response +type ExtractData = Extract, { success: true }>; +type ExtractError = Extract, { success: false }>; + +interface ExecuteOptions { + requestFn: () => Promise; + errorMessage?: string; + onSuccess?: (data: ExtractData) => Promise | void; + onError?: (error: ExtractError | string) => Promise | void; + logContext?: string; +} + +/** + * Base API class that provides shared state management and request handling + * for all API classes in the application. + */ +export class BaseApi { + isLoading = $state(false); + error = $state(null); + + /** + * Execute an API request with automatic loading and error state management + * Type is automatically inferred from the RPC call (e.g., classroomio.organization.plan.$post) + * @param options Configuration for the request + * @returns The data from the response, or the default return value on error + */ + protected async execute(options: ExecuteOptions): Promise | undefined> { + const { requestFn, onSuccess, logContext = 'API request' } = options; + + this.isLoading = true; + this.error = null; + + try { + const response = await requestFn(); + const result = (await response.json()) as unknown; + + if (typeof result === 'object' && result !== null && 'success' in result) { + const apiResult = result as { success: boolean; data?: unknown; error?: string; message?: string }; + + if (apiResult.success === false) { + console.log('apiResult false', apiResult); + options.onError?.(apiResult as ExtractError); + + return; + } + + const typedResult = apiResult as ExtractData; + + this.resetErrors(); + + if (onSuccess) { + await onSuccess(typedResult); + } + + return typedResult; + } + } catch (error) { + console.error(`Error in ${logContext}:`, error); + this.error = getErrMsg(error, `Error ${logContext}:`); + + const errorResult = this.getServerError>(error); + options.onError?.(errorResult); + } finally { + this.isLoading = false; + } + } + + getServerError(error: unknown): T | string { + if (error instanceof ApiError) { + console.log('instance of ApiError', error); + try { + const json = JSON.parse(error.message) as T; + return json; + } catch (e) { + console.log('error parsing json', e); + return error.message; + } + } + + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + return `${error}`; + } + + resetErrors() { + this.error = null; + } + + /** + * Reset the API state + */ + reset() { + this.isLoading = false; + this.error = null; + } +} + +/** + * Extended base API class that supports errors object (Record) + * for form validation scenarios + */ +export class BaseApiWithErrors extends BaseApi { + errors = $state>({}); + success = $state(false); + + handleValidationError(result: { success: false; error: string; code: string; field?: string }): Promise | void { + const zodValidationError = result.error as unknown as ZodValidationError; + if (zodValidationError.name === 'ZodError') { + const issues = JSON.parse(zodValidationError.message) as ZodValidatorMesssage; + + this.errors = mapZodErrorsToTranslations(new ZodError(issues)); + + return; + } + } + + override resetErrors() { + this.errors = {}; + } + + /** + * Reset the API state including errors and success + */ + override reset() { + super.reset(); + this.errors = {}; + this.success = false; + } +} diff --git a/apps/dashboard/src/lib/utils/services/api/index.ts b/apps/dashboard/src/lib/utils/services/api/index.ts index 1ede0eaf..8c5466ae 100644 --- a/apps/dashboard/src/lib/utils/services/api/index.ts +++ b/apps/dashboard/src/lib/utils/services/api/index.ts @@ -3,7 +3,6 @@ import { type ApiClientConfig, ApiError, type RequestConfig } from './types'; import { DEFAULT_CONFIG } from './constants'; import { delay } from './utils'; import { env } from '$env/dynamic/public'; -import { getAccessToken } from '$lib/utils/functions/supabase'; import { hcWithType } from '@cio/api/rpc-types'; class ApiClient { @@ -14,13 +13,7 @@ class ApiClient { } private async makeRequest(input: RequestInfo | URL, requestConfig: RequestConfig = {}): Promise { - console.log('makeRequest', input, requestConfig); - const { - timeout = this.config.timeout, - retries = this.config.retries, - skipAuth = false, - ...fetchConfig - } = requestConfig; + const { timeout = this.config.timeout, retries = this.config.retries, ...fetchConfig } = requestConfig; const url = typeof input === 'string' ? input : input.toString(); const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`; @@ -48,19 +41,6 @@ class ApiClient { } } - // Add authentication if not skipped - if (!skipAuth) { - try { - const token = await getAccessToken(); - if (token) { - headers.set('Authorization', `Bearer ${token}`); - } - } catch (error) { - console.warn('Failed to get access token:', error); - // Continue without auth if token retrieval fails - } - } - // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); @@ -94,16 +74,11 @@ class ApiClient { if (response.status >= 400 && response.status < 500) { const errorText = await response.text().catch(() => 'Unknown error'); - throw new ApiError(`Client error: ${errorText}`, response.status, response.statusText, response); + throw new ApiError(errorText, response.status, response.statusText, response); } if (response.status >= 500) { - const error = new ApiError( - `Server error: ${response.statusText}`, - response.status, - response.statusText, - response - ); + const error = new ApiError(response.statusText, response.status, response.statusText, response); if (attempt === retries) { throw error; @@ -165,6 +140,9 @@ export const apiClient = new ApiClient(); export const classroomio = hcWithType(env.PUBLIC_SERVER_URL, { fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => { return apiClient.request(input, requestInit); + }, + init: { + credentials: 'include' // Required for sending cookies cross-origin } }); @@ -188,3 +166,6 @@ export { } from './utils'; export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types'; + +// Export base API classes +export { BaseApi, BaseApiWithErrors } from './base.svelte'; diff --git a/apps/dashboard/src/lib/utils/services/api/types.ts b/apps/dashboard/src/lib/utils/services/api/types.ts index 495afe72..233d9a7d 100644 --- a/apps/dashboard/src/lib/utils/services/api/types.ts +++ b/apps/dashboard/src/lib/utils/services/api/types.ts @@ -1,4 +1,5 @@ -// Types for better type safety +import type { ZodError } from 'zod'; + export interface ApiClientConfig { baseURL?: string; timeout?: number; @@ -10,6 +11,13 @@ export interface ApiClientConfig { onResponse?: (response: Response) => Promise | void; } +export type ZodValidationError = { + message: string; // stringified error.issues from zod + name: 'ZodError'; +}; + +export type ZodValidatorMesssage = ZodError['issues']; + export interface RequestConfig extends RequestInit { timeout?: number; retries?: number; diff --git a/apps/dashboard/src/lib/utils/services/api/utils.ts b/apps/dashboard/src/lib/utils/services/api/utils.ts index 03452e54..9ebc9706 100644 --- a/apps/dashboard/src/lib/utils/services/api/utils.ts +++ b/apps/dashboard/src/lib/utils/services/api/utils.ts @@ -3,6 +3,23 @@ import { ApiError } from './types'; // Utility function for exponential backoff export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +export const getErrMsg = (error: unknown, fallback = 'Something went wrong'): string => { + if (error instanceof ApiError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + if (error instanceof Object && 'message' in error) { + return error.message as string; + } + + return fallback; +}; + // Helper function to handle JSON responses export const handleJsonResponse = async (response: Response): Promise => { if (!response.ok) { diff --git a/apps/dashboard/src/lib/utils/services/auth/client.ts b/apps/dashboard/src/lib/utils/services/auth/client.ts new file mode 100644 index 00000000..b412d67b --- /dev/null +++ b/apps/dashboard/src/lib/utils/services/auth/client.ts @@ -0,0 +1,9 @@ +import { createAuthClient } from 'better-auth/svelte'; +import { env } from '$env/dynamic/public'; + +export const authClient = createAuthClient({ + baseURL: env.PUBLIC_SERVER_URL, + fetchOptions: { + credentials: 'include' // Include cookies in requests + } +}); diff --git a/apps/dashboard/src/lib/utils/services/auth/session.ts b/apps/dashboard/src/lib/utils/services/auth/session.ts new file mode 100644 index 00000000..fdee71c3 --- /dev/null +++ b/apps/dashboard/src/lib/utils/services/auth/session.ts @@ -0,0 +1,50 @@ +import type { Cookies } from '@sveltejs/kit'; +import { authClient } from './client'; +import { classroomio } from '$lib/utils/services/api'; + +export const getSessionData = async (cookies: Cookies): Promise => { + try { + const cioCookies = cookies + .getAll() + .filter((c) => c.name.includes('classroomio')) + .map((c) => `${c.name}=${c.value}`) + .join('; '); + + if (!cioCookies) return null; + + const locals = await getThroughAuthClient(cioCookies); + + if (!locals) return null; + + locals.fromSessions = !!cookies.get('classroomio.session_data'); + + return locals; + } catch (error) { + console.error('Session verification failed:', error); + return null; + } +}; + +export async function getThroughTrpc(allCookies: string) { + const session = await classroomio.session.$get(undefined, { + headers: { + cookie: allCookies + } + }); + + const data = (await session.json()) as App.Locals; + + return data; +} + +async function getThroughAuthClient(allCookies: string) { + const session = await authClient.getSession({ + fetchOptions: { + headers: { + cookie: allCookies + } + } + }); + + return session.data as App.Locals | null; +} diff --git a/apps/dashboard/src/lib/utils/services/notification/notification.ts b/apps/dashboard/src/lib/utils/services/notification/notification.ts index e0172d83..2936036a 100644 --- a/apps/dashboard/src/lib/utils/services/notification/notification.ts +++ b/apps/dashboard/src/lib/utils/services/notification/notification.ts @@ -1,8 +1,6 @@ import { getAccessToken } from '$lib/utils/functions/supabase'; export const NOTIFICATION_NAME = { - WELCOME_TO_APP: 'WELCOME TO APP', - VERIFY_EMAIL: 'VERFIY_EMAIL', INVITE_TEACHER: 'INVITE TEACHER', WELCOME_TEACHER_TO_COURSE: 'WELCOME TEACHER TO COURSE', SEND_TEACHER_STUDENT_BUY_REQUEST: 'SEND TEACHER STUDENT BUY REQUEST', @@ -15,8 +13,6 @@ export const NOTIFICATION_NAME = { }; const NAME_TO_PATH = { - [NOTIFICATION_NAME.WELCOME_TO_APP]: '/api/email/welcome', - [NOTIFICATION_NAME.VERIFY_EMAIL]: '/api/email/verify_email', [NOTIFICATION_NAME.INVITE_TEACHER]: '/api/email/invite', [NOTIFICATION_NAME.WELCOME_TEACHER_TO_COURSE]: '/api/email/course/teacher_welcome', [NOTIFICATION_NAME.SEND_TEACHER_STUDENT_BUY_REQUEST]: '/api/email/course/teacher_student_buycourse', diff --git a/apps/dashboard/src/lib/utils/services/org/index.ts b/apps/dashboard/src/lib/utils/services/org/index.ts index 43ba39b8..24e31390 100644 --- a/apps/dashboard/src/lib/utils/services/org/index.ts +++ b/apps/dashboard/src/lib/utils/services/org/index.ts @@ -1,9 +1,10 @@ import type { CurrentOrg, OrgTeamMember } from '$lib/utils/types/org'; -import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles'; import { currentOrg, orgAudience, orgTeam, orgs } from '$lib/utils/store/org'; import type { OrganizationPlan } from '$lib/utils/types'; import type { PostgrestError } from '@supabase/supabase-js'; +import { ROLE } from '@cio/utils/constants'; +import { ROLE_LABEL } from '$lib/utils/constants/roles'; import { get } from 'svelte/store'; import { goto } from '$app/navigation'; import { supabase } from '$lib/utils/functions/supabase'; diff --git a/apps/dashboard/src/lib/utils/store/org.ts b/apps/dashboard/src/lib/utils/store/org.ts index be3de2d1..7c1aa41e 100644 --- a/apps/dashboard/src/lib/utils/store/org.ts +++ b/apps/dashboard/src/lib/utils/store/org.ts @@ -1,46 +1,56 @@ -import type { CurrentOrg, OrgAudience, OrgTeamMember } from '../types/org'; +import type { OrgAudience, OrgTeamMember } from '../types/org'; import { browser, dev } from '$app/environment'; import { derived, writable } from 'svelte/store'; +import type { AccountOrg } from '$lib/features/app/types'; import { PLAN } from '@cio/utils/plans'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; -import { ROLE } from '$lib/utils/constants/roles'; +import { ROLE } from '@cio/utils/constants'; import { STEPS } from '../constants/quiz'; import type { UserLessonDataType } from '$lib/utils/types'; import type { Writable } from 'svelte/store'; -// Trigger build -export const defaultCurrentOrgState: CurrentOrg = { - id: '', - name: '', - shortName: '', - siteName: '', - avatar_url: '', - memberId: '', - role_id: 0, - landingpage: {}, +export const defaultCurrentOrgState: AccountOrg = { + avatarUrl: '', + createdAt: '', + customCode: '', + customDomain: '', customization: { apps: { poll: true, comments: true }, course: { grading: true, newsfeed: true }, dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' } }, - theme: '', - organization_plan: [], - is_restricted: false + favicon: '', + id: '', + isCustomDomainVerified: false, + isRestricted: false, + landingpage: {}, + name: '', + plans: [], + roleId: 0, + settings: {}, + siteName: '', + theme: '' }; -export const orgs = writable([]); -export const currentOrg: Writable = writable(defaultCurrentOrgState); +export const orgs = writable([]); + +export const currentOrg: Writable = writable(defaultCurrentOrgState); export const orgAudience = writable([]); export const orgTeam = writable([]); export const isOrgAdmin = derived(currentOrg, ($currentOrg) => { - if ($currentOrg.role_id === 0) return null; + if ($currentOrg.roleId === 0) return null; - return $currentOrg.role_id === ROLE.ADMIN; + return $currentOrg.roleId === ROLE.ADMIN; +}); +export const isOrgStudent = derived(currentOrg, ($currentOrg) => { + if ($currentOrg.roleId === 0) return null; + + return $currentOrg.roleId === ROLE.STUDENT; }); -const getActivePlan = (org: CurrentOrg) => { - return org.organization_plan.find((p) => p.is_active); +const getActivePlan = (org: AccountOrg) => { + return org.plans.find((p) => p.isActive); }; export const currentOrgPlan = derived(currentOrg, ($currentOrg) => getActivePlan($currentOrg)); @@ -78,15 +88,15 @@ export const isFreePlan = derived(currentOrg, ($currentOrg) => { const plan = getActivePlan($currentOrg); - return !plan || plan.plan_name === PLAN.BASIC; + return !plan || plan.planName === PLAN.BASIC; }); export const currentOrgMaxAudience = derived(currentOrgPlan, ($plan) => !$plan ? 20 - : $plan.plan_name === PLAN.EARLY_ADOPTER + : $plan.planName === PLAN.EARLY_ADOPTER ? 10000 - : $plan.plan_name === PLAN.ENTERPRISE + : $plan.planName === PLAN.ENTERPRISE ? Number.MAX_SAFE_INTEGER : 20 ); diff --git a/apps/dashboard/src/lib/utils/store/user.ts b/apps/dashboard/src/lib/utils/store/user.ts index 812c40fe..ba9e901d 100644 --- a/apps/dashboard/src/lib/utils/store/user.ts +++ b/apps/dashboard/src/lib/utils/store/user.ts @@ -1,31 +1,19 @@ -import { LOCALE } from '../types'; -import type { User } from '@supabase/supabase-js'; +import type { TProfile, TUser } from '@cio/db/types'; + import { writable } from 'svelte/store'; interface UserStore { openAuthModal: boolean; fetchingUser: boolean; isLoggedIn: boolean; - currentSession: User | undefined; + currentSession: TUser | undefined; expiresAt: number; } -export interface ProfileStore { +// Using TProfile type from @cio/utils/types with optional id for store initialization +export type ProfileStore = Omit & { id: string | undefined; - fullname: string; - avatar_url: string; - profile_url?: string; - username: string; - user_id: string; - email: string; - role: string; - goal: string; - source: string; - telegram_chat_id: number | null; - locale: LOCALE; - is_email_verified: boolean; - verified_at: string; -} +}; export const defaultUserState: UserStore = { openAuthModal: false, @@ -35,22 +23,26 @@ export const defaultUserState: UserStore = { expiresAt: 0 }; -export const defaultProfileState: ProfileStore = { - id: undefined, +export const defaultProfileState: TProfile = { + id: '', fullname: '', - avatar_url: '', + avatarUrl: '', username: '', - user_id: '', - email: '', - role: '', - goal: '', - source: '', - telegram_chat_id: null, - locale: LOCALE.EN, - is_email_verified: false, - verified_at: '' + email: null, + role: null, + goal: null, + source: null, + telegramChatId: null, + locale: 'en', + isEmailVerified: false, + verifiedAt: null, + canAddCourse: true, + isRestricted: false, + createdAt: '', + updatedAt: '', + metadata: null }; export const user = writable(defaultUserState); -export const profile = writable(defaultProfileState); +export const profile = writable(defaultProfileState); diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 23780f2e..d452965f 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -99,6 +99,21 @@ "email": "Ugyldig e -mail -adresse", "username": "Brugernavn kan ikke være tomt", "fullname": "Navnet skal være mindst 5 tegn" + }, + "generic": { + "invalid_type": "Ugyldig type{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}", + "too_big": "Værdien er for stor{maksimum, vælg, udefineret{} andet{. Maksimum {maksimum}}}", + "too_small": "Værdien er for lille{minimum, vælg, udefineret{} andet{. Minimum {minimum}}}", + "invalid_format": "Ugyldigt format{validering, vælg, udefineret{} andet{ for {validation}}}", + "not_multiple_of": "Værdien skal være et multiplum af {multipleOf}", + "unrecognized_keys": "Indeholder ikke-genkendte nøgler{nøgler, vælg, udefineret{} andet{: {nøgler}}}", + "invalid_union": "Ugyldig værdi for angivne muligheder{unionErrors, select, undefined{} other{. {unionErrors} valgmulighed(er) mislykkedes}}", + "invalid_key": "Ugyldig nøgle{nøgle, vælg, udefineret{} anden{: {nøgle}}}", + "invalid_element": "Ugyldigt element{indeks, vælg, udefineret{} andet{ ved indeks {indeks}}}", + "invalid_value": "Ugyldig værdi{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}", + "custom": "{besked}", + "required_field": "Dette felt er påkrævet", + "invalid_field": "Ugyldig værdi" } }, "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index d8d233f6..da10e860 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -1555,6 +1555,21 @@ "email": "Ungültige E-Mail-Adresse", "username": "Benutzername darf nicht leer sein", "fullname": "Der Name sollte mindestens 5 Zeichen lang sein" + }, + "generic": { + "invalid_type": "Ungültiger Typ {erwartet, ausgewählt, undefiniert{} andere{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}", + "too_big": "Der Wert ist zu groß{maximum, select, undefiniert{} other{. Maximum {maximum}}}", + "too_small": "Der Wert ist zu klein{minimum, select, undefiniert{} other{. Minimum {Minimum}}}", + "invalid_format": "Ungültiges Format{validation, select, undefiniert{} other{ für {validation}}}", + "not_multiple_of": "Der Wert muss ein Vielfaches von {multipleOf} sein", + "unrecognized_keys": "Enthält nicht erkannte Schlüssel{keys, select, undefiniert{} other{: {keys}}}", + "invalid_union": "Ungültiger Wert für die bereitgestellten Optionen {unionErrors, select, undefined{} other{. {unionErrors}-Option(en) fehlgeschlagen}}", + "invalid_key": "Ungültiger Schlüssel{key, select, undefiniert{} other{: {key}}}", + "invalid_element": "Ungültiges Element{index, select, undefiniert{} other{ am Index {index}}}", + "invalid_value": "Invalid value{expected, select, undefined{} other{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}", + "custom": "{Nachricht}", + "required_field": "Dieses Feld ist erforderlich", + "invalid_field": "Ungültiger Wert" } }, "explore": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 7320acef..5284f623 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -72,7 +72,7 @@ "full_name": "Full Name", "password": "Your Password", "confirm_password": "Confirm Password", - "password_helper_message": "Password must be more than 6 characters", + "password_helper_message": "Password must be more than 8 characters", "create_account": "Create Account" }, "validation": { @@ -117,7 +117,10 @@ "heading": "Personal Information", "full_name": "Full Name", "username": "Username", - "email": "Email" + "email": "Email", + "confirm": "Confirm", + "cancel": "Cancel", + "email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email." }, "update_profile": "Update Profile", "logout": "Log out" @@ -272,6 +275,7 @@ }, "org_navigation": { + "platform": "Platform", "dashboard": "Dashboard", "courses": "Courses", "community": "Community", @@ -1661,6 +1665,21 @@ }, "email": { "not_valid": "Not valid email" + }, + "generic": { + "invalid_type": "Invalid type{expected, select, undefined{} other{. Expected {expected}}}{received, select, undefined{} other{ (received {received})}}", + "too_big": "Value is too large{maximum, select, undefined{} other{. Maximum {maximum}}}", + "too_small": "Value is too small{minimum, select, undefined{} other{. Minimum {minimum}}}", + "invalid_format": "Invalid format{validation, select, undefined{} other{ for {validation}}}", + "not_multiple_of": "Value must be a multiple of {multipleOf}", + "unrecognized_keys": "Contains unrecognized keys{keys, select, undefined{} other{: {keys}}}", + "invalid_union": "Invalid value for provided options{unionErrors, select, undefined{} other{. {unionErrors} option(s) failed}}", + "invalid_key": "Invalid key{key, select, undefined{} other{: {key}}}", + "invalid_element": "Invalid element{index, select, undefined{} other{ at index {index}}}", + "invalid_value": "Invalid value{expected, select, undefined{} other{. Expected {expected}}}{received, select, undefined{} other{ (received {received})}}", + "custom": "{message}", + "required_field": "This field is required", + "invalid_field": "Invalid value" } }, diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index d94ff0d8..d17cfd7e 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -1555,6 +1555,21 @@ "email": "Dirección de correo electrónico no válida", "username": "El nombre de usuario no puede estar vacío", "fullname": "El nombre debe tener un mínimo de 5 caracteres." + }, + "generic": { + "invalid_type": "Invalid type{expected, select, undefined{} other{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}", + "too_big": "El valor es demasiado grande{máximo, selección, undefinido{} otro{. Máximo {máximo}}}", + "too_small": "El valor es demasiado pequeño{mínimo, seleccionado, undefinido{} otro{. Mínimo {mínimo}}}", + "invalid_format": "Formato no válido{validación, selección, indefinido{} otro{ para {validación}}}", + "not_multiple_of": "El valor debe ser múltiplo de {multipleOf}", + "unrecognized_keys": "Contiene claves no reconocidas{keys, select, undefined{} other{: {keys}}}", + "invalid_union": "Valor no válido para las opciones proporcionadas{unionErrors, select, undefined{} other{. {unionErrors} opción(es) fallida(s)}}", + "invalid_key": "Clave no válida{clave, selección, undefinido{} otro{: {clave}}}", + "invalid_element": "Elemento no válido{índice, selección, undefinido{} otro{ en el índice {índice}}}", + "invalid_value": "Valor no válido{esperado, seleccionado, indefinido{} otro{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}", + "custom": "{mensaje}", + "required_field": "Este campo es obligatorio", + "invalid_field": "Invalid value" } }, "explore": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 52a7216f..57037516 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -1555,6 +1555,21 @@ "email": "Adresse e-mail invalide", "username": "Le nom d'utilisateur ne peut pas être vide", "fullname": "Le nom doit comporter au minimum 5 caractères" + }, + "generic": { + "invalid_type": "Type non valide{attendu, sélectionné, non défini{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}", + "too_big": "La valeur est trop grande{maximum, select, undefined{} autre{. Maximum {maximum}}}", + "too_small": "La valeur est trop petite{minimum, select, undefined{} autre{. Minimum {minimum}}}", + "invalid_format": "Format invalide{validation, select, non défini{} autre{ pour {validation}}}", + "not_multiple_of": "La valeur doit être un multiple de {multipleOf}", + "unrecognized_keys": "Contient des clés non reconnues{keys, select, undefined{} other{: {keys}}}", + "invalid_union": "Valeur non valide pour les options fournies {unionErrors, select, undefined{} other{. {unionErrors} option(s) a échoué}}", + "invalid_key": "Clé invalide{key, select, undefined{} other{: {key}}}", + "invalid_element": "Élément invalide{index, select, undefined{} autre{ à l'index {index}}}", + "invalid_value": "Valeur non valide{attendue, sélectionnée, non définie{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}", + "custom": "{message}", + "required_field": "Ce champ est obligatoire", + "invalid_field": "Valeur invalide" } }, "explore": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 861c882e..ddb198ba 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -1555,6 +1555,21 @@ "email": "अमान्य ईमेल पता", "username": "उपयोक्तानाम खाली नहीं हो सकता", "fullname": "नाम कम से कम 5 अक्षर का होना चाहिए" + }, + "generic": { + "invalid_type": "अमान्य प्रकार{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}", + "too_big": "मान बहुत बड़ा है {अधिकतम, चयन करें, अपरिभाषित {} अन्य {। अधिकतम {अधिकतम}}}", + "too_small": "मान बहुत छोटा है {न्यूनतम, चयन करें, अपरिभाषित {} अन्य {। न्यूनतम {न्यूनतम}}}", + "invalid_format": "अमान्य प्रारूप{सत्यापन, चयन, अपरिभाषित{} अन्य{ {सत्यापन}}} के लिए", + "not_multiple_of": "मान {multipleOf} का गुणज होना चाहिए", + "unrecognized_keys": "अज्ञात कुंजियाँ शामिल हैं {कुंजियाँ, चयन करें, अपरिभाषित{} अन्य{: {कुंजियाँ}}}", + "invalid_union": "प्रदत्त विकल्पों के लिए अमान्य मान {संघत्रुटि, चयन, अपरिभाषित{} अन्य{। {unionErrors} विकल्प विफल रहे}}", + "invalid_key": "अमान्य कुंजी{कुंजी, चयन, अपरिभाषित{} अन्य{: {कुंजी}}}", + "invalid_element": "अमान्य तत्व {सूचकांक, चयन, अपरिभाषित {} अन्य {सूचकांक {सूचकांक}}} पर", + "invalid_value": "अमान्य मान{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}", + "custom": "{संदेश}", + "required_field": "यह फ़ील्ड आवश्यक है", + "invalid_field": "अमान्य मान" } }, "explore": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 38183519..03a4d7a6 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -94,6 +94,21 @@ "email": "Nieprawidłowy adres e -mail", "username": "Nazwa użytkownika nie może być pusta", "fullname": "Nazwa powinna mieć minimum 5 znaków" + }, + "generic": { + "invalid_type": "Nieprawidłowy typ{oczekiwany, wybierz, niezdefiniowany{} inny{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}", + "too_big": "Wartość jest za duża{maksymalna, wybierz, niezdefiniowana{} inna{. Maksymalnie {maksimum}}}", + "too_small": "Wartość jest za mała{minimum, wybierz, niezdefiniowane{} inne{. Minimum {minimum}}}", + "invalid_format": "Nieprawidłowy format{validacja, wybierz, niezdefiniowany{} other{ for {validation}}}", + "not_multiple_of": "Wartość musi być wielokrotnością {multipleOf}", + "unrecognized_keys": "Zawiera nierozpoznane klucze{klucze, wybierz, niezdefiniowane{} inne{: {klucze}}}", + "invalid_union": "Nieprawidłowa wartość podanych opcji{unionErrors, wybierz, niezdefiniowane{} inne{. Niepowodzenie opcji: {unionErrors}}}", + "invalid_key": "Nieprawidłowy klucz{klucz, wybierz, niezdefiniowany{} inny{: {klucz}}}", + "invalid_element": "Nieprawidłowy element{index, wybierz, niezdefiniowany{} other{ w indeksie {index}}}", + "invalid_value": "Nieprawidłowa wartość{oczekiwana, wybierz, niezdefiniowana{} inna{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}", + "custom": "{wiadomość}", + "required_field": "To pole jest wymagane", + "invalid_field": "Nieprawidłowa wartość" } }, "generic": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 04a6111f..35e46c6a 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -1555,6 +1555,21 @@ "email": "Endereço de email invalido", "username": "O nome de usuário não pode ficar vazio", "fullname": "O nome deve ter no mínimo 5 caracteres" + }, + "generic": { + "invalid_type": "Tipo inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}", + "too_big": "O valor é muito grande{máximo, seleção, indefinido{} outro{. Máximo {máximo}}}", + "too_small": "O valor é muito pequeno{mínimo, selecione, indefinido{} outro{. Mínimo {mínimo}}}", + "invalid_format": "Formato inválido{validação, seleção, indefinido{} outro{ para {validação}}}", + "not_multiple_of": "O valor precisa ser um múltiplo de {multipleOf}", + "unrecognized_keys": "Contém chaves não reconhecidas{keys, select, undefined{} other{: {keys}}}", + "invalid_union": "Valor inválido para opções fornecidas{unionErrors, select, indefinido{} other{. Falha na(s) opção(ões) {unionErrors}}}", + "invalid_key": "Chave inválida{chave, seleção, indefinida{} outra{: {chave}}}", + "invalid_element": "Elemento inválido{index, select, indefinido{} other{ no índice {index}}}", + "invalid_value": "Valor inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}", + "custom": "{mensagem}", + "required_field": "Este campo é obrigatório", + "invalid_field": "Valor inválido" } }, "explore": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 57ab5faa..c6e6ff4f 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -1555,6 +1555,21 @@ "email": "Неверный адрес электронной почты", "username": "Имя пользователя не может быть пустым", "fullname": "Имя должно состоять минимум из 5 символов." + }, + "generic": { + "invalid_type": "Неверный тип {ожидаемый, выбранный, неопределенный{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}", + "too_big": "Значение слишком велико{максимум, выберите, неопределенное{} другое{. Максимум {максимум}}}", + "too_small": "Значение слишком мало{минимальное, выберите, неопределенное{} другое{. Минимум {минимум}}}", + "invalid_format": "Неверный формат{проверка, выбор, неопределенное{} другое{ для {проверка}}}", + "not_multiple_of": "Значение должно быть кратно {multipleOf}.", + "unrecognized_keys": "Содержит нераспознанные ключи{keys, select, undefine{}other{: {keys}}}", + "invalid_union": "Недопустимое значение для предоставленных параметров{unionErrors, select, undefine{}other{. Вариант(ы) {unionErrors} не выполнен}}", + "invalid_key": "Неверный ключ{key, select, undefine{}other{: {key}}}", + "invalid_element": "Неверный элемент{index, select, undefined{}other{ по индексу {index}}}", + "invalid_value": "Недопустимое значение{ожидаемое, выбранное, неопределенное{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}", + "custom": "{сообщение}", + "required_field": "Это поле обязательно для заполнения", + "invalid_field": "Недопустимое значение" } }, "explore": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index b249d0c7..56ea9d51 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -1555,6 +1555,21 @@ "email": "địa chỉ email không hợp lệ", "username": "Tên ngươi dung không được để trông", "fullname": "Tên phải có tối thiểu 5 ký tự" + }, + "generic": { + "invalid_type": "Loại không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}", + "too_big": "Giá trị quá lớn{tối đa, chọn, không xác định{} khác{. Tối đa {tối đa}}}", + "too_small": "Giá trị quá nhỏ{tối thiểu, chọn, không xác định{} khác{. Tối thiểu {tối thiểu}}}", + "invalid_format": "Định dạng không hợp lệ{xác thực, chọn, không xác định{} khác{ cho {xác thực}}}", + "not_multiple_of": "Giá trị phải là bội số của {multipleOf}", + "unrecognized_keys": "Chứa các khóa không được nhận dạng{keys, select, undefind{} other{: {keys}}}", + "invalid_union": "Giá trị không hợp lệ cho các tùy chọn được cung cấp{unionErrors, select, unknown{} other{. {unionErrors} tùy chọn không thành công}}", + "invalid_key": "Khóa không hợp lệ{khóa, chọn, không xác định{} khác{: {key}}}", + "invalid_element": "Phần tử không hợp lệ{chỉ mục, chọn, không xác định{} khác{ tại chỉ mục {index}}}", + "invalid_value": "Giá trị không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}", + "custom": "{tin nhắn}", + "required_field": "Trường này là bắt buộc", + "invalid_field": "Giá trị không hợp lệ" } }, "explore": { diff --git a/apps/dashboard/src/lib/utils/types/index.ts b/apps/dashboard/src/lib/utils/types/index.ts index eb5e2444..9b14f2c8 100644 --- a/apps/dashboard/src/lib/utils/types/index.ts +++ b/apps/dashboard/src/lib/utils/types/index.ts @@ -1,18 +1,7 @@ export * from './config'; export * from './dashboard'; -export enum LOCALE { - EN = 'en', - HI = 'hi', - FR = 'fr', - PL = 'pl', - PT = 'pt', - DE = 'de', - VI = 'vi', - RU = 'ru', - ES = 'es', - DA = 'da' -} +import type { TLocale } from '@cio/db/types'; //===============Custom Type=============== @@ -304,7 +293,7 @@ export interface LessonPage { title: string; totalExercises: number; totalComments: number; - locale: LOCALE; + locale: TLocale; isSaving: boolean; isFetching: boolean; materials: { diff --git a/apps/dashboard/src/lib/utils/types/sidebar.ts b/apps/dashboard/src/lib/utils/types/sidebar.ts deleted file mode 100644 index 47d546ab..00000000 --- a/apps/dashboard/src/lib/utils/types/sidebar.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Icon } from '@lucide/svelte'; - -import type { CurrentOrg } from './org'; - -export interface SidebarData { - teams: CurrentOrg[]; - navMain: Array<{ - title: string; - url: string; - icon?: typeof Icon; - isActive: boolean; - items?: { - title: string; - url: string; - }[]; - }>; - user: { - name: string; - email: string; - avatar: string; - }; -} diff --git a/apps/dashboard/src/lib/utils/validation/USAGE.md b/apps/dashboard/src/lib/utils/validation/USAGE.md new file mode 100644 index 00000000..77a4214a --- /dev/null +++ b/apps/dashboard/src/lib/utils/validation/USAGE.md @@ -0,0 +1,287 @@ +# Convention-Based Validation with Translations + +## Overview + +We use **convention-based translation keys** for Zod validation errors. This approach: +- ✅ No metadata needed - just follow the naming pattern +- ✅ Clean shared schemas without hardcoded messages +- ✅ Automatic fallback to generic translations +- ✅ Works with multiple validation rules per field + +## Convention + +Translation keys follow this pattern: + +``` +validations.{fieldName}.{errorCode} +``` + +### Common Error Codes + +| Error Code | When It Occurs | Example Field | +|------------|----------------|---------------| +| `too_small` | String too short, number too small, array too few items | username.too_small | +| `too_big` | String too long, number too large, array too many items | bio.too_big | +| `invalid_email` | Invalid email format | email.invalid_email | +| `invalid_url` | Invalid URL format | website.invalid_url | +| `invalid_type` | Wrong type (e.g., undefined when string expected) | username.invalid_type | +| `invalid_enum_value` | Value not in allowed options | role.invalid_enum_value | + +## Quick Start + +### 1. Import Schema and Error Mapper + +```typescript +import { ZEmailData } from '@cio/utils/validation/mail'; +import { mapZodErrorsToTranslations } from '$lib/utils/validation'; + +let formData = { to: '', subject: '', content: '' }; +let errors: Record = {}; + +function handleSubmit() { + const result = ZEmailData.safeParse(formData); + + if (!result.success) { + errors = mapZodErrorsToTranslations(result.error); + return; + } + + // Use validated data + sendEmail(result.data); +} +``` + +### 2. Add Translation Keys + +```json +{ + "validations": { + "to": { + "invalid_email": "Invalid email address", + "invalid_type": "Email is required" + }, + "subject": { + "too_small": "Subject is required", + "too_big": "Subject is too long" + }, + "content": { + "too_small": "Content must be at least 1 character", + "too_big": "Content is too long" + }, + + "generic": { + "required": "This field is required", + "invalid_email": "Invalid email address", + "invalid_url": "Invalid URL", + "too_small_string": "Must be at least {min} characters", + "too_big_string": "Must be at most {max} characters" + } + } +} +``` + +## Complete Example: User Profile Form + +### Schema (Shared) + +```typescript +// packages/utils/src/validation/user/user.ts +export const ZUserProfile = z.object({ + email: z.string().email(), + username: z.string().min(3).max(20), + bio: z.string().min(10).max(500).optional(), + age: z.number().min(13).max(120) +}); +``` + +### Component + +```typescript +// Component.svelte + + +
+
+ + + {#if errors.email} + {errors.email} + {/if} +
+ +
+ + + {#if errors.username} + {errors.username} + {/if} +
+ +
+ + + {#if errors.bio} + {errors.bio} + {/if} +
+ +
+ + + {#if errors.age} + {errors.age} + {/if} +
+ + +
+``` + +### Translations + +```json +{ + "validations": { + "email": { + "invalid_email": "Invalid email address", + "invalid_type": "Email is required" + }, + "username": { + "too_small": "Username must be at least 3 characters", + "too_big": "Username cannot exceed 20 characters", + "invalid_type": "Username is required" + }, + "bio": { + "too_small": "Bio must be at least 10 characters", + "too_big": "Bio cannot exceed 500 characters" + }, + "age": { + "too_small": "You must be at least 13 years old", + "too_big": "Invalid age", + "invalid_type": "Age is required" + } + } +} +``` + +## Multiple Errors Per Field + +Zod validates in order and returns the **first error** it encounters: + +```typescript +// Schema +z.string().min(3).max(20) + +// Input: "" +// Error: too_small (stops at first rule) +// Translation: validations.username.too_small + +// Input: "ab" +// Error: too_small (still fails first rule) +// Translation: validations.username.too_small + +// Input: "this_is_way_too_long_username" +// Error: too_big (passed min, failed max) +// Translation: validations.username.too_big +``` + +## Fallback Strategy + +The error mapper tries translations in this order: + +1. **Specific field + error code**: `validations.email.invalid_email` +2. **Generic error type**: `validations.generic.invalid_email` +3. **Zod's default message**: "Invalid email" + +## Advanced: Helper Function + +For cleaner code, use the helper: + +```typescript +import { validateWithTranslation } from '$lib/utils/validation'; + +function handleSubmit() { + const result = validateWithTranslation(ZUserProfile, formData); + + if ('errors' in result) { + errors = result.errors; + return; + } + + // Success - result.data is typed! + saveProfile(result.data); +} +``` + +## Adding New Fields + +1. **Add to schema** (in packages/utils): +```typescript +export const ZUserProfile = z.object({ + // ... existing fields + website: z.string().url().optional() +}); +``` + +2. **Add translations**: +```json +{ + "validations": { + "website": { + "invalid_url": "Please enter a valid URL", + "invalid_type": "Website is required" + } + } +} +``` + +3. **That's it!** No metadata, no reimplementation. + +## Generic Fallbacks + +If you don't add specific translations, the mapper uses generic ones: + +```json +{ + "validations": { + "generic": { + "required": "This field is required", + "invalid_email": "Invalid email address", + "invalid_url": "Invalid URL", + "invalid_format": "Invalid format", + "too_small_string": "Must be at least {min} characters", + "too_big_string": "Must be at most {max} characters", + "too_small_number": "Must be at least {min}", + "too_big_number": "Must be at most {max}", + "invalid_enum": "Must be one of: {options}", + "error": "Validation error" + } + } +} +``` + +Just remove the message parameter and add the translation key following the convention! diff --git a/apps/dashboard/src/lib/utils/validation/index.ts b/apps/dashboard/src/lib/utils/validation/index.ts new file mode 100644 index 00000000..b0233d5b --- /dev/null +++ b/apps/dashboard/src/lib/utils/validation/index.ts @@ -0,0 +1,7 @@ +/** + * Validation Utilities + * + * Exports utilities for handling Zod validation with translations + */ + +export { mapZodErrorsToTranslations, isValidationError, validateWithTranslation } from './validation'; diff --git a/apps/dashboard/src/lib/utils/validation/validation.test.ts b/apps/dashboard/src/lib/utils/validation/validation.test.ts new file mode 100644 index 00000000..18893a8f --- /dev/null +++ b/apps/dashboard/src/lib/utils/validation/validation.test.ts @@ -0,0 +1,299 @@ +import { ZodError, z } from 'zod'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import enTranslations from '$lib/utils/translations/en.json' assert { type: 'json' }; + +import { mapZodErrorsToTranslations } from './validation'; + +const { mockGet } = vi.hoisted(() => ({ + mockGet: vi.fn<(key: string, params?: Record) => string | null>() +})); + +vi.mock('$lib/utils/functions/translations', () => ({ + t: { + get: mockGet + } +})); + +const createError = (issue: z.core.$ZodIssue) => + new ZodError([ + { + ...issue, + path: issue.path ?? ['field'], + message: issue.message ?? 'Issue occurred' + } as z.core.$ZodIssue + ]); + +function renderTemplate(template: string, params: Record = {}): string { + let output = template; + + output = output.replace( + /\{(\w+),\s*select,\s*undefined\{\}\s*other\{((?:[^{}]|\{[^{}]*\})*)\}\}/g, + (_match, key: string, content: string) => { + const value = params[key]; + if (value === undefined || value === null || value === '') { + return ''; + } + + const renderedContent = content.replace(/\{(\w+)\}/g, (_nestedMatch: string, innerKey: string) => { + const innerValue = params[innerKey]; + return innerValue === undefined || innerValue === null ? '' : String(innerValue); + }); + + return renderedContent; + } + ); + + output = output.replace(/\{(\w+)\}/g, (_match, key: string) => { + const value = params[key]; + return value === undefined || value === null ? '' : String(value); + }); + + return output; +} + +describe('mapZodErrorsToTranslations fallback keys', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + it('prefers entity-specific translation before other fallbacks', () => { + mockGet.mockImplementation((key) => { + if (key === 'validations.course.title.too_small') { + return 'Course-specific translation'; + } + return null; + }); + + const error = createError({ + code: 'too_small', + minimum: 3, + inclusive: true, + path: ['title'], + origin: 'zod', + message: 'Title must be at least 3 characters long' + }); + + expect(mapZodErrorsToTranslations(error, 'course')).toEqual({ title: 'Course-specific translation' }); + + expect(mockGet).toHaveBeenCalledWith( + 'validations.course.title.too_small', + expect.objectContaining({ + entity: 'course', + field: 'title', + minimum: 3, + inclusive: true + }) + ); + }); + + it('falls back to field translation when entity-specific key is missing', () => { + mockGet.mockImplementation((key) => { + if (key === 'validations.title.too_small') { + return 'Field translation'; + } + return null; + }); + + const error = createError({ + code: 'too_small', + minimum: 3, + inclusive: true, + path: ['title'], + origin: 'zod', + message: 'Title must be at least 3 characters long' + }); + + expect(mapZodErrorsToTranslations(error, 'course')).toEqual({ title: 'Field translation' }); + }); + + it('falls back to generic translation when no specific keys are found', () => { + mockGet.mockImplementation((key, params = {}) => { + if (key === 'validations.generic.too_small') { + return renderTemplate(enTranslations.validations.generic.too_small, params as Record); + } + return null; + }); + + const error = createError({ + code: 'too_small', + minimum: 3, + inclusive: true, + path: ['title'], + origin: 'zod', + message: 'Title must be at least 3 characters long' + }); + + expect(mapZodErrorsToTranslations(error)).toEqual({ + title: renderTemplate(enTranslations.validations.generic.too_small, { + field: 'title', + code: 'too_small', + minimum: 3, + inclusive: true + }) + }); + }); + + it('falls back to the original Zod message when no translations are available', () => { + mockGet.mockReturnValue(null); + + const schema = z.object({ + title: z.string().min(5) + }); + const result = schema.safeParse({ title: 'abc' }); + + if (result.success) { + throw new Error('Expected validation to fail'); + } + + const zodMessage = result.error.issues[0].message; + + expect(mapZodErrorsToTranslations(result.error)).toEqual({ + title: zodMessage + }); + }); +}); + +describe('mapZodErrorsToTranslations generic parameter coverage', () => { + beforeEach(() => { + mockGet.mockReset(); + }); + + const cases: Array<{ + code: z.core.$ZodIssue['code']; + issue: Partial & Record; + expectedParams: Record; + }> = [ + { + code: 'invalid_type', + issue: { + expected: 'string', + received: 'number', + path: ['field'], + origin: 'zod', + message: 'Field must be a string' + }, + expectedParams: { expected: 'string', received: 'number' } + }, + { + code: 'too_small', + issue: { + minimum: 3, + inclusive: true, + exact: false, + path: ['field'], + origin: 'zod', + message: 'Field must be at least 3 characters long' + }, + expectedParams: { minimum: 3, inclusive: true, exact: false } + }, + { + code: 'too_big', + issue: { + maximum: 10, + inclusive: false, + exact: false, + path: ['field'], + origin: 'zod', + message: 'Field must be less than 10 characters long' + }, + expectedParams: { maximum: 10, inclusive: false, exact: false } + }, + { + code: 'invalid_format', + issue: { + validation: 'email', + regex: /@/, + path: ['field'], + origin: 'zod', + message: 'Field must be a valid email address' + }, + expectedParams: { validation: 'email', regex: /@/ } + }, + { + code: 'not_multiple_of', + issue: { multipleOf: 5, path: ['field'], origin: 'zod', message: 'Field must be a multiple of 5' }, + expectedParams: { multipleOf: 5 } + }, + { + code: 'unrecognized_keys', + issue: { + keys: ['foo', 'bar'], + path: ['field'], + origin: 'zod', + message: 'Field must not contain unrecognized keys' + }, + expectedParams: { keys: 'foo, bar' } + }, + { + code: 'invalid_union', + issue: { + unionErrors: [new ZodError([]), new ZodError([])], + path: ['field'], + origin: 'zod', + message: 'Field must be a valid union' + }, + expectedParams: { unionErrors: 2 } + }, + { + code: 'invalid_key', + issue: { key: 'unexpected', path: ['field'], origin: 'zod', message: 'Field must not contain unexpected keys' }, + expectedParams: { key: 'unexpected' } + }, + { + code: 'invalid_element', + issue: { + index: 1, + element: { foo: 'bar' }, + path: ['field'], + origin: 'zod', + message: 'Field must not contain invalid elements' + }, + expectedParams: { index: 1, element: { foo: 'bar' } } + }, + { + code: 'invalid_value', + issue: { expected: 'A', received: 'B', path: ['field'], origin: 'zod', message: 'Field must be a valid value' }, + expectedParams: { expected: 'A', received: 'B' } + }, + { + code: 'custom', + issue: { params: { reason: 'Invalid' }, message: 'Custom message', path: ['field'], origin: 'zod' }, + expectedParams: { params: JSON.stringify({ reason: 'Invalid' }), message: 'Custom message' } + } + ]; + + it.each(cases)('passes expected params to generic translation for %s', ({ code, issue, expectedParams }) => { + mockGet.mockImplementation((key, params = {}) => + key === `validations.generic.${code}` + ? renderTemplate( + enTranslations.validations.generic[code as keyof typeof enTranslations.validations.generic], + params as Record + ) + : null + ); + + const error = createError({ + code, + ...issue + } as z.core.$ZodIssue); + + const result = mapZodErrorsToTranslations(error); + const call = mockGet.mock.calls.find(([calledKey]) => calledKey === `validations.generic.${code}`); + + expect(call?.[0]).toBe(`validations.generic.${code}`); + expect(call?.[1]).toEqual( + expect.objectContaining({ + field: 'field', + code, + ...expectedParams + }) + ); + + const renderedMessage = renderTemplate( + enTranslations.validations.generic[code as keyof typeof enTranslations.validations.generic], + (call?.[1] ?? {}) as Record + ); + + expect(result).toEqual({ field: renderedMessage }); + }); +}); diff --git a/apps/dashboard/src/lib/utils/validation/validation.ts b/apps/dashboard/src/lib/utils/validation/validation.ts new file mode 100644 index 00000000..bd52370e --- /dev/null +++ b/apps/dashboard/src/lib/utils/validation/validation.ts @@ -0,0 +1,206 @@ +import * as z from 'zod'; + +import type { ZodError } from 'zod'; +import { t } from '$lib/utils/functions/translations'; + +/** + * Maps Zod validation errors to translated messages using convention-based keys. + * + * Convention: + * - With entity: validations.{entityName}.{fieldName}.{errorCode} + * - Without entity: validations.{fieldName}.{errorCode} + * + * Examples: + * - Entity: "course", Field: "title", Error: "too_small" + * → First tries: "validations.course.title.too_small" + * → Falls back to: "validations.title.too_small" + * + * - Field: "email", Error: "invalid_string" + * → Translation key: "validations.email.invalid_string" + * + * This allows entity-specific error messages like "Course title is required" + * vs "Lesson title is required" while still supporting generic messages. + * + * @param error - Zod validation error object + * @param entityName - Optional entity prefix (e.g., 'course', 'lesson') + * @returns Object mapping field paths to translated error messages + */ +export function mapZodErrorsToTranslations(error: ZodError, entityName?: string): Record { + const errors: Record = {}; + + if (!error?.issues) { + return errors; + } + + error.issues.forEach((issue: z.core.$ZodIssue) => { + const fieldPath = issue.path.join('.'); + const fieldName = issue.path[issue.path.length - 1] as string; + + if (!fieldName) return; + + // Build translation keys with entity-specific and generic fallbacks + const translationKeys = buildTranslationKeys(fieldName, issue, entityName); + const translationParams = buildTranslationParams(issue, fieldName, entityName); + + // Try each translation key in order (entity-specific first, then generic) + let translatedMessage: string | null = null; + for (const key of translationKeys) { + if (key === 'validations.generic.custom') { + translatedMessage = t.get(issue.message); + break; + } + + if (issue.code === 'invalid_type' && translationParams.received === 'undefined') { + translatedMessage = t.get('validations.generic.required_field'); + break; + } + + const translation = t.get(key, translationParams); + if (translation && translation !== key) { + translatedMessage = translation; + break; + } + } + + errors[fieldPath] = translatedMessage || issue.message; + }); + + return errors; +} +function buildTranslationKeys(fieldName: string, issue: z.core.$ZodIssue, entityName?: string): string[] { + const keys: string[] = []; + + const errorSuffix = issue.code; + + if (entityName) { + // 1. Try entity-specific key first: validations.course.title.too_small + keys.push(`validations.${entityName}.${fieldName}.${errorSuffix}`); + } + + // 2. Try generic field key: validations.title.too_small + keys.push(`validations.${fieldName}.${errorSuffix}`); + + // 3. Try generic translation based on error type + keys.push(`validations.generic.${errorSuffix}`); + + return keys; +} + +function buildTranslationParams( + issue: z.core.$ZodIssue, + fieldName: string, + entityName?: string +): Record { + const params: Record = {}; + + if (fieldName) params.field = fieldName; + if (entityName) params.entity = entityName; + if (issue.code) params.code = issue.code; + if (issue.message) params.message = issue.message; + + const details = issue as unknown as Record; + + switch (issue.code) { + case 'invalid_type': + if ('expected' in details) params.expected = details.expected; + if ('received' in details) params.received = details.received; + if (!details.received) params.received = 'undefined'; + break; + case 'too_small': + if ('minimum' in details) params.minimum = details.minimum; + if ('inclusive' in details) params.inclusive = details.inclusive; + if ('type' in details) params.type = details.type; + if ('exact' in details) params.exact = details.exact; + break; + case 'too_big': + if ('maximum' in details) params.maximum = details.maximum; + if ('inclusive' in details) params.inclusive = details.inclusive; + if ('type' in details) params.type = details.type; + if ('exact' in details) params.exact = details.exact; + break; + case 'invalid_format': + if ('format' in details) params.validation = details.format; + if ('regex' in details) params.regex = details.regex; + break; + case 'not_multiple_of': + if ('multipleOf' in details) params.multipleOf = details.multipleOf; + break; + case 'unrecognized_keys': + if ('keys' in details) { + params.keys = Array.isArray(details.keys) ? (details.keys as unknown[]).join(', ') : details.keys; + } + break; + case 'invalid_union': + if ('unionErrors' in details) { + params.unionErrors = Array.isArray(details.unionErrors) + ? (details.unionErrors as unknown[]).length + : details.unionErrors; + } + break; + case 'invalid_key': + if ('key' in details) params.key = details.key; + break; + case 'invalid_element': + if ('index' in details) params.index = details.index; + if ('element' in details) params.element = details.element; + break; + case 'invalid_value': + if ('expected' in details) params.expected = details.expected; + if ('received' in details) params.received = details.received; + break; + case 'custom': + if ('params' in details) { + params.params = + typeof details.params === 'object' && details.params !== null + ? JSON.stringify(details.params) + : details.params; + } + break; + default: + break; + } + + return params; +} + +/** + * Helper to check if a Zod validation passed + */ +export function isValidationError(result: { + success: boolean; + error?: ZodError; +}): result is { success: false; error: ZodError } { + return Boolean(result && result.success === false && result.error); +} + +/** + * Validates data and returns either errors or valid data + * + * @example + * // Without entity prefix + * const result = validateWithTranslation(ZUserSchema, formData); + * + * // With entity prefix for entity-specific error messages + * const result = validateWithTranslation(ZCourseSchema, formData, 'course'); + * + * if (result.errors) { + * // Show errors to user + * setErrors(result.errors); + * } else { + * // Use validated data + * await saveUser(result.data); + * } + */ +export function validateWithTranslation( + schema: { safeParse: (data: unknown) => { success: boolean; error?: ZodError; data?: T } }, + data: unknown, + entityName?: string +): { errors: Record } | { data: T } { + const result = schema.safeParse(data); + + if (!result.success && result.error) { + return { errors: mapZodErrorsToTranslations(result.error, entityName) }; + } + + return { data: result.data as T }; +} diff --git a/apps/dashboard/src/mail/sendEmail.ts b/apps/dashboard/src/mail/sendEmail.ts index 9f5b40b2..0b3d1376 100644 --- a/apps/dashboard/src/mail/sendEmail.ts +++ b/apps/dashboard/src/mail/sendEmail.ts @@ -19,7 +19,7 @@ const sendEmail = (sFetch: typeof fetch) => { return; } - console.log('Emails sent successfully:', response); + console.log('Emails sent successfully', response.status); return response; } catch (error) { console.error('Error sending emails:', error); diff --git a/apps/dashboard/src/routes/+layout.server.ts b/apps/dashboard/src/routes/+layout.server.ts index 5c70f1f4..c92002c2 100644 --- a/apps/dashboard/src/routes/+layout.server.ts +++ b/apps/dashboard/src/routes/+layout.server.ts @@ -3,10 +3,9 @@ import { getSupabase, supabase } from '$lib/utils/functions/supabase'; import type { CurrentOrg } from '$lib/utils/types/org'; import type { MetaTagsProps } from 'svelte-meta-tags'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; -import { blockedSubdomain } from '$lib/utils/constants/app'; import { dev } from '$app/environment'; import { env } from '$env/dynamic/private'; -import { getCurrentOrg } from '$lib/utils/services/org'; +import { getOrgSiteInfo } from '$lib/features/app/layout-setup'; import { redirect } from '@sveltejs/kit'; if (!supabase) { @@ -15,6 +14,8 @@ if (!supabase) { export const ssr = PUBLIC_IS_SELFHOSTED === 'true' ? false : true; +const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || []; + interface LoadOutput { orgSiteName: string; isOrgSite: boolean; @@ -22,115 +23,34 @@ interface LoadOutput { org: CurrentOrg | null; baseMetaTags: MetaTagsProps; serverLang: string; + locals: App.Locals; } -const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || []; +export const load = async ({ url, cookies, request, locals }): Promise => { + const debugPlay = cookies.get('debugPlay'); + + const orgSiteInfo = await getOrgSiteInfo(url, cookies); -export const load = async ({ url, cookies, request }): Promise => { const response: LoadOutput = { - orgSiteName: '', - isOrgSite: false, - skipAuth: false, - org: null, + orgSiteName: orgSiteInfo.orgSiteName, + isOrgSite: orgSiteInfo.isOrgSite, + skipAuth: orgSiteInfo.subdomain === 'play' || debugPlay === 'true', + org: orgSiteInfo.org, baseMetaTags: getBaseMetaTags(url), - serverLang: request.headers?.get('accept-language') || '' + serverLang: request.headers?.get('accept-language') || '', + locals }; console.log('PUBLIC_IS_SELFHOSTED', PUBLIC_IS_SELFHOSTED); - // Selfhosted usecase would be here - if (PUBLIC_IS_SELFHOSTED === 'true') { - const subdomain = getSubdomain(url); - console.log('subdomain', subdomain); - - // Student dashboard - if (subdomain) { - const org = (await getCurrentOrg(subdomain, true)) || null; - - // Organization by subdomain not found - if (!org) { - return response; - } - - response.org = org; - response.isOrgSite = true; - response.orgSiteName = subdomain; - } - - // Never go beyond this for selfhosted instances - return response; - } - - const isLocalHost = url.host.includes('localhost'); - - const tempSiteName = url.searchParams.get('org'); - - if (isLocalHost && tempSiteName) { - console.log('setting sitename temp'); - cookies.set('_orgSiteName', tempSiteName, { - path: '/' - }); - } - - const _orgSiteName = cookies.get('_orgSiteName'); - const debugPlay = cookies.get('debugPlay'); - const debugMode = _orgSiteName && _orgSiteName !== 'false'; - - const subdomain = getSubdomain(url) || ''; - - const isDev = dev || isLocalHost; - - if (isURLCustomDomain(url)) { - // Custom domain - response.org = (await getCurrentOrg(url.host, true, true)) || null; - - console.log('custom domain response.org', response.org); - - if (!response.org) { - console.error('Custom domain org not found, loading dashboard'); - return response; - } - - response.isOrgSite = true; - response.orgSiteName = response.org?.siteName || ''; - return response; - } else if (!blockedSubdomain.includes(subdomain)) { - if (APP_SUBDOMAINS.includes(subdomain)) { - // This is an app domain specified in the .env file - return response; - } - - console.log('subdomain', subdomain); - - response.isOrgSite = debugMode || !!subdomain; - response.orgSiteName = debugMode ? _orgSiteName : subdomain; - response.org = (await getCurrentOrg(response.orgSiteName, true)) || null; - - if (!response.org && !isDev) { - redirect(307, 'https://app.classroomio.com/404?type=org'); - } else if (!response.org && _orgSiteName) { - cookies.delete('_orgSiteName', { path: '/' }); - } - } else if (subdomain === 'play' || debugPlay === 'true') { - response.skipAuth = true; - } else if (!APP_SUBDOMAINS.includes(subdomain) && !isDev) { - // This case is for anything in our blockedSubdomains + // If it isn't a registered dashboard domain and also not a valid sub domain. + if (!APP_SUBDOMAINS.includes(orgSiteInfo.subdomain) && !dev && !orgSiteInfo.isOrgSite) { redirect(307, 'https://app.classroomio.com'); } return response; }; -function isURLCustomDomain(url: URL) { - if (url.host.includes('localhost')) { - return false; - } - - const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'vercel.app'].filter(Boolean); - - return !notCustomDomainHosts.some((host) => url.host.endsWith(host)); -} - function getBaseMetaTags(url: URL) { return Object.freeze({ title: 'ClassroomIO | The Open Source Learning Management System for Companies', @@ -168,19 +88,3 @@ function getBaseMetaTags(url: URL) { } }); } - -function getSubdomain(url: URL) { - const host = url.hostname.replace('www.', ''); - const parts = host.split('.'); - const appHost = env.PRIVATE_APP_HOST; - - const appHostParts = appHost.split('.'); - const isAppHost = parts.slice(-appHostParts.length).join('.') === appHost; - - if (isAppHost) { - // Subdomain exists only if extra part(s) before main domain - return parts.length > appHostParts.length ? parts[0] : null; - } - - return null; -} diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 481b411e..f2b65ffb 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -1,39 +1,23 @@ + const session = authClient.useSession(); + const isSessionReady = $derived(!$session.isPending && !$session.isRefetching && $session.data); - + $effect(() => { + // this means the session cookie 'classroomio.session_data' expired and we need to trigger a new session + if (!data.locals.fromSessions && isSessionReady) { + authClient.getSession().then(() => { + console.log('triggered new session'); + }); + } + }); + + $effect(() => { + if (isSessionReady && !appInitApi.isInitializedAndReady && !appInitApi.loading) { + console.log('setting up account with session data'); + + appInitApi.setupApp($session.data as App.Locals, { + isOrgSite: data.isOrgSite, + orgSiteName: data.orgSiteName + }); + } + }); + + const metaTags = $derived(merge(data.baseMetaTags, page.data.pageMetaTags)); + @@ -126,46 +101,19 @@ -{#if data.org?.is_restricted || $currentOrg.is_restricted} +{#if data.org?.is_restricted || $currentOrg.isRestricted} {:else if data.skipAuth} {:else if data.isOrgSite && !path} {:else} -
- {#if !hideNavByRoute($page.url?.pathname)} - {#if isOrgPage($page.url?.pathname) || $page.url?.pathname.includes('profile') || isCoursesPage(path)} - - {:else if isLMSPage($page.url?.pathname)} - - {:else} - - {/if} + - - {/if} - -
- {@render children?.()} -
-
+ {@render children?.()} {/if} + + + +
+ + ClassroomIO Logo +
+
+ ${content} +
+ + + + + +`; diff --git a/packages/email/src/templates/index.ts b/packages/email/src/templates/index.ts new file mode 100644 index 00000000..acced897 --- /dev/null +++ b/packages/email/src/templates/index.ts @@ -0,0 +1 @@ +export * from './default'; diff --git a/packages/email/src/utils/constants.ts b/packages/email/src/utils/constants.ts new file mode 100644 index 00000000..5b6b77be --- /dev/null +++ b/packages/email/src/utils/constants.ts @@ -0,0 +1,4 @@ +export const EMAIL_IDS = ['forgotPassword', 'welcome', 'verifyEmail', 'onPasswordReset'] as const; + +export const EMAIL_FROM = '"Best from ClassroomIO" '; +export const EMAIL_REPLY_TO = '"Best from ClassroomIO" '; diff --git a/packages/email/src/utils/functions/email-helpers.ts b/packages/email/src/utils/functions/email-helpers.ts new file mode 100644 index 00000000..7a8a59ab --- /dev/null +++ b/packages/email/src/utils/functions/email-helpers.ts @@ -0,0 +1,18 @@ +import { FromData } from '../types'; + +// format: "ClassroomIO Developers (via ClassroomIO.com)" +export function extractNameAndEmail(str: string): FromData | undefined { + // Use regular expressions to match the name and email + const regex = /"(.*?)"\s+<\s*(.*?)@(.*?)\s*>/; + const match = str.match(regex); + + if (match) { + // Extract the name and email from the match groups + const name = match[1]; + const email = match[2] + '@' + match[3]; + return { name, email }; + } else { + // Return undefined if the format doesn't match + return { name: str, email: str }; + } +} diff --git a/packages/email/src/utils/services/nodemailer.ts b/packages/email/src/utils/services/nodemailer.ts new file mode 100644 index 00000000..676b7315 --- /dev/null +++ b/packages/email/src/utils/services/nodemailer.ts @@ -0,0 +1,65 @@ +import { EmailResponse } from '../types'; +import type { TEmailData } from '@cio/utils/validation/mail'; +import type { Transporter } from 'nodemailer'; +import { env } from '../../config/env'; +import nodemailer from 'nodemailer'; + +let transporter: Transporter | undefined; + +const setupTransporter = async () => { + if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASSWORD) { + console.error('SMTP configuration missing'); + } + + try { + transporter = nodemailer.createTransport({ + host: env.SMTP_HOST, + port: parseInt(env.SMTP_PORT || '465', 10), + secure: true, + auth: { + user: env.SMTP_USER, + pass: env.SMTP_PASSWORD + } + }); + + await transporter.verify(); + + return transporter; + } catch (error) { + console.error('Transporter error:', error); + } +}; + +setupTransporter(); + +export async function sendWithNodemailer(emailData: TEmailData): Promise { + const { from, to, subject, content, replyTo } = emailData; + + if (!transporter) { + return { + success: false, + error: 'Email transporter not initialized' + }; + } + + try { + const result = await transporter.sendMail({ + from: from || env.SMTP_SENDER, + to, + subject, + replyTo, + html: content + }); + + return { + success: true, + details: result + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + details: error + }; + } +} diff --git a/packages/email/src/utils/services/zeptomail.ts b/packages/email/src/utils/services/zeptomail.ts new file mode 100644 index 00000000..95361a50 --- /dev/null +++ b/packages/email/src/utils/services/zeptomail.ts @@ -0,0 +1,69 @@ +import { EmailResponse } from '../types'; +import { SendMailClient } from 'zeptomail'; +import type { TEmailData } from '@cio/utils/validation/mail'; +import { env } from '../../config/env'; +import { extractNameAndEmail } from '../functions/email-helpers'; + +export const zohoClient = new SendMailClient({ + url: 'api.zeptomail.eu/', + token: env.ZOHO_TOKEN! +}); + +export async function sendWithZoho(emailData: TEmailData): Promise { + const { from, to, subject, content, replyTo } = emailData; + + const fromData = extractNameAndEmail(from || ''); + + if (!fromData) { + return { + success: false, + error: 'Invalid from data' + }; + } + + try { + const result = await zohoClient.sendMail({ + from: { + address: fromData.email, + name: fromData.name + }, + to: [ + { + email_address: { + address: to + } + } + ], + subject, + htmlbody: content, + reply_to: getReplyTo(replyTo) + }); + + return { + success: true, + details: result + }; + } catch (error) { + console.error('Error sending email with Zoho:', error); + console.log(error.error.details?.[0]); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error occurred', + details: error + }; + } +} + +function getReplyTo(replyTo?: string): { address: string; name: string }[] | undefined { + if (!replyTo) return undefined; + + const fromData = extractNameAndEmail(replyTo); + if (!fromData) return undefined; + + return [ + { + address: fromData.email, + name: fromData.name + } + ]; +} diff --git a/packages/email/src/utils/types.ts b/packages/email/src/utils/types.ts new file mode 100644 index 00000000..1ada0f7d --- /dev/null +++ b/packages/email/src/utils/types.ts @@ -0,0 +1,24 @@ +import { EMAIL_IDS } from './constants'; +import type { EmailSchemas } from '../emails'; + +export interface EmailResponse { + success: boolean; + error?: string; + details?: unknown; +} + +export interface FromData { + name: string; + email: string; +} +export type EmailId = (typeof EMAIL_IDS)[number]; + +/** + * Conditional type that looks up the schema type for a given EmailId + * + * @example + * type ForgotPasswordSchema = EmailSchemaFor<'forgotPassword'>; + */ +export type EmailSchemaFor = TEmailId extends keyof EmailSchemas + ? EmailSchemas[TEmailId] + : never; diff --git a/packages/email/tsconfig.json b/packages/email/tsconfig.json new file mode 100644 index 00000000..f6a743dc --- /dev/null +++ b/packages/email/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@cio/tsconfig/base.json", + "compilerOptions": { + "composite": true, + "allowSyntheticDefaultImports": true, + "strict": false, + "baseUrl": ".", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ESNext", + "typeRoots": ["../../node_modules/@types", "./node_modules/@types"], + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tsconfig.tsbuildinfo"], + "references": [{ "path": "../utils" }], + "tsc-alias": { + "resolveFullPaths": true, + "verbose": false + } +} diff --git a/packages/storybook/tsconfig.json b/packages/storybook/tsconfig.json index 69d0f1f2..fe87ddc5 100644 --- a/packages/storybook/tsconfig.json +++ b/packages/storybook/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@cio/tsconfig/base.json", "compilerOptions": { + "composite": true, "allowJs": true, "checkJs": true, "esModuleInterop": true, @@ -32,5 +33,6 @@ } ] }, - "include": ["src/**/*", ".storybook/**/*", "vite.config.ts"] + "include": ["src/**/*", ".storybook/**/*", "vite.config.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/packages/ui/add-ui-prefix.cjs b/packages/ui/add-ui-prefix.cjs new file mode 100755 index 00000000..a0aca4d3 --- /dev/null +++ b/packages/ui/add-ui-prefix.cjs @@ -0,0 +1,333 @@ +#!/usr/bin/env node + +/** + * Script to add 'ui:' prefix to Tailwind classes in packages/ui + * Usage: node add-ui-prefix.cjs [--dry-run] + * + * This script will: + * - Find all .svelte, .ts, .tsx, .js, .jsx files in packages/ui/src + * - Add 'ui:' prefix to Tailwind utility classes + * - Skip classes that already have the prefix + * - Preserve existing code structure + */ + +const fs = require('fs'); +const path = require('path'); + +const DRY_RUN = process.argv.includes('--dry-run'); +const UI_PREFIX = 'ui:'; + +// Check if a class looks like a Tailwind utility class +function isTailwindClass(className) { + // Skip if already has ui: prefix + if (className.startsWith(UI_PREFIX)) { + return false; + } + + // Skip if it's clearly not a Tailwind class (contains special chars that aren't Tailwind) + // Allow brackets for arbitrary values like w-[100px] and colons for variant prefixes like after: + if (/[=;{}]/.test(className) && !className.includes('[') && !className.includes(']')) { + return false; + } + + // Common Tailwind patterns + const patterns = [ + // Variant prefixes (must come first to catch all variant combinations) + // This catches any class that starts with a variant prefix followed by a colon + /^[a-z-]+(\[.*?\])?(\/.*?)?:/, + // Layout & Display (including compound classes like inline-flex) + /^(flex|grid|block|inline|hidden|absolute|relative|fixed|sticky|contents|list-item|table|table-row|table-cell|inline-flex|inline-grid|inline-block|inline-table)$/, + // Flexbox utilities + /^(shrink|grow|flex-|basis-)/, + // Spacing (m-, p-, gap-, space-, inset-) + /^(m|p|mx|my|mt|mr|mb|ml|px|py|pt|pr|pb|pl|gap|space-[xy]|inset)-/, + // Negative spacing (e.g., -inset-2, -top-1) + /^-\w+/, + // Sizing (w-, h-, size-, min-, max-) + /^(w|h|min-w|min-h|max-w|max-h|size)-/, + // Typography + /^(text|font|leading|tracking|text-|font-)/, + // Colors + /^(bg|text|border|ring|outline|divide|from|via|to|fill|stroke)-/, + // Effects + /^(opacity|shadow|blur|brightness|contrast|grayscale|hue-rotate|invert|saturate|sepia|backdrop-)/, + // Transforms + /^(scale|rotate|translate|skew|transform|origin)-/, + // Transitions + /^(transition|duration|ease|delay|animate)-/, + // Interactivity (specific variants) + /^(hover|focus|active|disabled|group|peer|aria-|data-):/, + // Responsive + /^(sm|md|lg|xl|2xl):/, + // Dark mode + /^dark:/, + // Arbitrary values (must come before general patterns) + /^[a-z]+-\[/, + // Arbitrary variants (like [&_svg]:pointer-events-none, after:absolute, etc.) + /^\[.*\]:|^(after|before|first|last|only|nth|marker|placeholder|selection|backdrop):/, + // Common utilities + /^(rounded|border|outline|shadow|ring|overflow|z-|order-|col-|row-|justify-|items-|content-|self-|place-|object-|cursor-|select-|resize-|appearance-|pointer-events-|visibility-|whitespace-|break-|overflow-|overscroll-|scroll-|will-change-|transform|transition|top|right|bottom|left)/ + ]; + + return patterns.some((pattern) => pattern.test(className)); +} + +// Process a single class string +function processClassString(classString) { + // Split by whitespace and process each class + const classes = classString.split(/\s+/).filter((c) => c.trim()); + const processed = classes.map((cls) => { + if (isTailwindClass(cls)) { + return `${UI_PREFIX}${cls}`; + } + return cls; + }); + + return processed.join(' '); +} + +// Process file content +function processFileContent(content) { + let modified = content; + let changes = 0; + + // Helper to process and count changes + function processAndReplace(pattern, replacer) { + modified = modified.replace(pattern, (match, ...args) => { + const result = replacer(match, ...args); + if (result !== match) { + changes++; + } + return result; + }); + } + + // Pattern 1: class="..." or className="..." + processAndReplace(/(class|className)="([^"]*)"/g, (match, attr, classes) => { + const processed = processClassString(classes); + return processed !== classes ? `${attr}="${processed}"` : match; + }); + + // Pattern 1b: class='...' or className='...' + processAndReplace(/(class|className)='([^']*)'/g, (match, attr, classes) => { + const processed = processClassString(classes); + return processed !== classes ? `${attr}='${processed}'` : match; + }); + + // Pattern 2: class={`...`} or className={`...`} + processAndReplace(/(class|className)=\{`([^`]+)`\}/g, (match, attr, classes) => { + const processed = processClassString(classes); + return processed !== classes ? `${attr}={\`${processed}\`}` : match; + }); + + // Pattern 3: class={'...'} or className={'...'} + processAndReplace(/(class|className)=\{['"]([^'"]+)['"]\}/g, (match, attr, classes) => { + const processed = processClassString(classes); + return processed !== classes ? `${attr}={\`${processed}\`}` : match; + }); + + // Pattern 4: DISABLED - Too broad, was catching JavaScript object properties + // Pattern 4: String literals in object properties: base: "...", default: '...', etc. + // This handles tv() calls and similar object literals + // Match any property followed by a string that looks like it contains Tailwind classes + // DISABLED: This was catching JavaScript object properties incorrectly + // processAndReplace(/(\w+)\s*:\s*["']([^"']+)["']/g, (match, key, value) => { + // // Check if value contains Tailwind classes + // // Skip if it's clearly not a class string (contains =, :, ; that aren't part of Tailwind syntax) + // if (/[=:;]/.test(value) && !value.includes('[') && !value.includes(']')) { + // return match; + // } + + // const classes = value.split(/\s+/).filter((c) => c.trim()); + // const hasTailwind = classes.some((c) => isTailwindClass(c)); + + // if (hasTailwind) { + // const processed = processClassString(value); + // // Preserve original quote style + // const quote = match.includes("'") ? "'" : '"'; + // return processed !== value ? `${key}: ${quote}${processed}${quote}` : match; + // } + // return match; + // }); + + // Pattern 5: DISABLED - Too broad, was catching JavaScript object properties + // Pattern 5: Template literals in object properties: base: `...` + // DISABLED: This was catching JavaScript object properties incorrectly + // processAndReplace(/(\w+)\s*:\s*`([^`]+)`/g, (match, key, value) => { + // // Similar check as above + // if (/[=:;]/.test(value) && !value.includes('[') && !value.includes(']')) { + // return match; + // } + + // const classes = value.split(/\s+/).filter((c) => c.trim()); + // const hasTailwind = classes.some((c) => isTailwindClass(c)); + + // if (hasTailwind) { + // const processed = processClassString(value); + // return processed !== value ? `${key}: \`${processed}\`` : match; + // } + // return match; + // }); + + // Pattern 6: Simple string literals in function calls: cn("..."), tv({...}) + // Only process specific functions that are known to contain Tailwind classes + // This pattern matches strings that are the first argument and have no complex expressions + const tailwindFunctionNames = ['cn', 'tv', 'clsx', 'classNames']; + processAndReplace( + new RegExp(`(${tailwindFunctionNames.join('|')})\\s*\\(\\s*['"]([^'"]+)['"]\\s*([,)])`, 'g'), + (match, fn, classes, separator) => { + const processed = processClassString(classes); + return processed !== classes ? `${fn}("${processed}"${separator}` : match; + } + ); + + // Pattern 7: Template literals in function calls: cn(`...`) + processAndReplace(/(cn|tv)\s*\(`([^`]+)`/g, (match, fn, classes) => { + const processed = processClassString(classes); + return processed !== classes ? `${fn}(\`${processed}\`` : match; + }); + + // Pattern 9: tv() function calls with object properties containing Tailwind classes + // This specifically targets tv() calls and their variant/base properties + processAndReplace(/tv\s*\(\s*\{([\s\S]*?)\}\s*\)/g, (match, objContent) => { + // Process all string literals in the tv() object, regardless of nesting level + let processedObj = objContent; + + // Process double-quoted strings + processedObj = processedObj.replace(/"([^"]*)"/g, (match, value) => { + const processed = processClassString(value); + return processed !== value ? `"${processed}"` : match; + }); + + // Process single-quoted strings + processedObj = processedObj.replace(/'([^']*)'/g, (match, value) => { + const processed = processClassString(value); + return processed !== value ? `'${processed}'` : match; + }); + + // Process template literals + processedObj = processedObj.replace(/`([^`]*)`/g, (match, value) => { + const processed = processClassString(value); + return processed !== value ? `\`${processed}\`` : match; + }); + + return `tv({${processedObj}})`; + }); + + // Pattern 8: class: cn(...) patterns (like in derived props) + // Matches: class: cn('string1', 'string2', ...) + processAndReplace(/class:\s*cn\s*\(([^)]+)\)/g, (match, args) => { + // Use a more sophisticated approach to handle multi-line strings and comments + let processedArgs = args; + + // Process single-quoted strings: 'content' + processedArgs = processedArgs.replace(/'([^']*)'/g, (match, content) => { + const processed = processClassString(content); + return processed !== content ? `'${processed}'` : match; + }); + + // Process double-quoted strings: "content" + processedArgs = processedArgs.replace(/"([^"]*)"/g, (match, content) => { + const processed = processClassString(content); + return processed !== content ? `"${processed}"` : match; + }); + + return `class: cn(${processedArgs})`; + }); + + return { content: modified, changes }; +} + +// Process a single file +function processFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + const { content: modified, changes } = processFileContent(content); + + if (changes > 0) { + if (DRY_RUN) { + console.log(`[DRY RUN] Would update ${filePath} (${changes} changes)`); + } else { + fs.writeFileSync(filePath, modified, 'utf8'); + console.log(`✓ Updated ${filePath} (${changes} changes)`); + } + return changes; + } + return 0; + } catch (error) { + console.error(`✗ Error processing ${filePath}:`, error.message); + return 0; + } +} + +// Recursively find all relevant files +function findFiles(dir, extensions = ['.svelte', '.ts', '.tsx', '.js', '.jsx']) { + const files = []; + + // Files to exclude + const excludedFiles = ['output.css']; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + // Skip node_modules and other ignored directories + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) { + continue; + } + files.push(...findFiles(fullPath, extensions)); + } else if (entry.isFile()) { + // Skip excluded files + if (excludedFiles.includes(entry.name)) { + continue; + } + + const ext = path.extname(entry.name); + if (extensions.includes(ext)) { + files.push(fullPath); + } + } + } + } catch (error) { + console.error(`Error reading directory ${dir}:`, error.message); + } + + return files; +} + +// Main execution +function main() { + const uiDir = path.join(__dirname); + const srcDir = path.join(uiDir, 'src'); + + if (!fs.existsSync(srcDir)) { + console.error(`Error: ${srcDir} does not exist`); + process.exit(1); + } + + console.log(`Searching for files in ${srcDir}...`); + const files = findFiles(srcDir); + console.log(`Found ${files.length} files to process\n`); + + if (DRY_RUN) { + console.log('DRY RUN MODE - No files will be modified\n'); + } + + let totalChanges = 0; + let filesChanged = 0; + + for (const file of files) { + const changes = processFile(file); + if (changes > 0) { + filesChanged++; + totalChanges += changes; + } + } + + console.log(`\n${DRY_RUN ? 'Would process' : 'Processed'} ${filesChanged} files with ${totalChanges} total changes`); +} + +main(); diff --git a/packages/ui/components.json b/packages/ui/components.json index 98a4b00e..e10ae462 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -2,7 +2,7 @@ "$schema": "https://shadcn-svelte.com/schema.json", "tailwind": { "css": "src/index.css", - "baseColor": "slate" + "baseColor": "stone" }, "aliases": { "components": "$src", diff --git a/packages/ui/package.json b/packages/ui/package.json index ddbef417..3b4f15f5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,14 +5,17 @@ "scripts": { "generate-css": "echo 'generating CSS' && pnpx @tailwindcss/cli -i ./src/index.css -o ./src/output.css --minify", "build": "pnpm generate-css", - "dev": "pnpm generate-css --watch" + "dev": "pnpm generate-css --watch", + "format": "prettier --write .", + "prefix:list": "node add-ui-prefix.cjs --dry-run", + "prefix": "node add-ui-prefix.cjs && pnpm format" }, "devDependencies": { "@internationalized/date": "^3.8.1", "@lucide/svelte": "^0.544.0", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.5.27", - "@sveltejs/vite-plugin-svelte": "^4.0.4", + "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/typography": "^0.5.18", "bits-ui": "^2.11.7", "clsx": "^2.1.1", @@ -23,7 +26,8 @@ "tailwind-merge": "^3.3.1", "tailwind-variants": "^3.1.1", "tailwindcss": "^4.1.14", - "tw-animate-css": "^1.4.0" + "tw-animate-css": "^1.4.0", + "vaul-svelte": "1.0.0-next.7" }, "dependencies": { "@aarkue/tiptap-math-extension": "^1.4.0", @@ -55,6 +59,6 @@ "tiptap-markdown": "^0.9.0" }, "peerDependencies": { - "bits-ui": "^2.11.0" + "bits-ui": "^2.14.1" } } diff --git a/packages/ui/src/base/accordion/accordion-content.svelte b/packages/ui/src/base/accordion/accordion-content.svelte index cfda6067..c3d89a32 100644 --- a/packages/ui/src/base/accordion/accordion-content.svelte +++ b/packages/ui/src/base/accordion/accordion-content.svelte @@ -1,22 +1,22 @@ -
- {@render children?.()} -
+
+ {@render children?.()} +
diff --git a/packages/ui/src/base/accordion/accordion-item.svelte b/packages/ui/src/base/accordion/accordion-item.svelte index 4b6f5ab3..bb28bce3 100644 --- a/packages/ui/src/base/accordion/accordion-item.svelte +++ b/packages/ui/src/base/accordion/accordion-item.svelte @@ -1,17 +1,13 @@ diff --git a/packages/ui/src/base/accordion/accordion-trigger.svelte b/packages/ui/src/base/accordion/accordion-trigger.svelte index 61ac8f26..f4fc67ea 100644 --- a/packages/ui/src/base/accordion/accordion-trigger.svelte +++ b/packages/ui/src/base/accordion/accordion-trigger.svelte @@ -1,32 +1,32 @@ - - svg]:rotate-180", - className - )} - {...restProps} - > - {@render children?.()} - - + + svg]:rotate-180', + className + )} + {...restProps} + > + {@render children?.()} + + diff --git a/packages/ui/src/base/accordion/accordion.svelte b/packages/ui/src/base/accordion/accordion.svelte index 117ee37f..9018cf20 100644 --- a/packages/ui/src/base/accordion/accordion.svelte +++ b/packages/ui/src/base/accordion/accordion.svelte @@ -1,16 +1,7 @@ - + diff --git a/packages/ui/src/base/accordion/index.ts b/packages/ui/src/base/accordion/index.ts index ac343a10..eacbcf6f 100644 --- a/packages/ui/src/base/accordion/index.ts +++ b/packages/ui/src/base/accordion/index.ts @@ -1,16 +1,16 @@ -import Root from "./accordion.svelte"; -import Content from "./accordion-content.svelte"; -import Item from "./accordion-item.svelte"; -import Trigger from "./accordion-trigger.svelte"; +import Root from './accordion.svelte'; +import Content from './accordion-content.svelte'; +import Item from './accordion-item.svelte'; +import Trigger from './accordion-trigger.svelte'; export { - Root, - Content, - Item, - Trigger, - // - Root as Accordion, - Content as AccordionContent, - Item as AccordionItem, - Trigger as AccordionTrigger, + Root, + Content, + Item, + Trigger, + // + Root as Accordion, + Content as AccordionContent, + Item as AccordionItem, + Trigger as AccordionTrigger }; diff --git a/packages/ui/src/base/avatar/avatar-fallback.svelte b/packages/ui/src/base/avatar/avatar-fallback.svelte index 67c9c78b..e35a4ae1 100644 --- a/packages/ui/src/base/avatar/avatar-fallback.svelte +++ b/packages/ui/src/base/avatar/avatar-fallback.svelte @@ -8,6 +8,6 @@ diff --git a/packages/ui/src/base/avatar/avatar-image.svelte b/packages/ui/src/base/avatar/avatar-image.svelte index 89cc4be4..e16a8e72 100644 --- a/packages/ui/src/base/avatar/avatar-image.svelte +++ b/packages/ui/src/base/avatar/avatar-image.svelte @@ -8,6 +8,6 @@ diff --git a/packages/ui/src/base/avatar/avatar.svelte b/packages/ui/src/base/avatar/avatar.svelte index 9b99090b..317bcc30 100644 --- a/packages/ui/src/base/avatar/avatar.svelte +++ b/packages/ui/src/base/avatar/avatar.svelte @@ -14,6 +14,6 @@ bind:ref bind:loadingStatus data-slot="avatar" - class={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)} + class={cn('ui:relative ui:flex ui:size-8 ui:shrink-0 ui:overflow-hidden ui:rounded-full', className)} {...restProps} /> diff --git a/packages/ui/src/base/avatar/index.ts b/packages/ui/src/base/avatar/index.ts index d06457be..9cf5cc18 100644 --- a/packages/ui/src/base/avatar/index.ts +++ b/packages/ui/src/base/avatar/index.ts @@ -1,13 +1,13 @@ -import Root from "./avatar.svelte"; -import Image from "./avatar-image.svelte"; -import Fallback from "./avatar-fallback.svelte"; +import Root from './avatar.svelte'; +import Image from './avatar-image.svelte'; +import Fallback from './avatar-fallback.svelte'; export { - Root, - Image, - Fallback, - // - Root as Avatar, - Image as AvatarImage, - Fallback as AvatarFallback, + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback }; diff --git a/packages/ui/src/base/badge/badge.svelte b/packages/ui/src/base/badge/badge.svelte index 26dd608d..448a7d65 100644 --- a/packages/ui/src/base/badge/badge.svelte +++ b/packages/ui/src/base/badge/badge.svelte @@ -2,14 +2,14 @@ import { type VariantProps, tv } from 'tailwind-variants'; export const badgeVariants = tv({ - base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3', + base: 'ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:w-fit ui:shrink-0 ui:items-center ui:justify-center ui:gap-1 ui:overflow-hidden ui:whitespace-nowrap ui:rounded-full ui:border ui:px-2 ui:py-0.5 ui:text-xs ui:font-medium ui:transition-[color,box-shadow] ui:focus-visible:ring-[3px] ui:[&>svg]:pointer-events-none ui:[&>svg]:size-3', variants: { variant: { - default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent', - secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent', + default: 'ui:bg-primary ui:text-primary-foreground ui:[a&]:hover:bg-primary/90 ui:border-transparent', + secondary: 'ui:bg-secondary ui:text-secondary-foreground ui:[a&]:hover:bg-secondary/90 ui:border-transparent', destructive: - 'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white', - outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground' + 'ui:bg-destructive ui:[a&]:hover:bg-destructive/90 ui:focus-visible:ring-destructive/20 ui:dark:focus-visible:ring-destructive/40 ui:dark:bg-destructive/70 ui:border-transparent ui:text-white', + outline: 'ui:text-foreground ui:[a&]:hover:bg-accent ui:[a&]:hover:text-accent-foreground' } }, defaultVariants: { diff --git a/packages/ui/src/base/badge/index.ts b/packages/ui/src/base/badge/index.ts index 64e0aa9b..f05fb87f 100644 --- a/packages/ui/src/base/badge/index.ts +++ b/packages/ui/src/base/badge/index.ts @@ -1,2 +1,2 @@ -export { default as Badge } from "./badge.svelte"; -export { badgeVariants, type BadgeVariant } from "./badge.svelte"; +export { default as Badge } from './badge.svelte'; +export { badgeVariants, type BadgeVariant } from './badge.svelte'; diff --git a/packages/ui/src/base/breadcrumb/breadcrumb-ellipsis.svelte b/packages/ui/src/base/breadcrumb/breadcrumb-ellipsis.svelte index 0190ae39..399cd755 100644 --- a/packages/ui/src/base/breadcrumb/breadcrumb-ellipsis.svelte +++ b/packages/ui/src/base/breadcrumb/breadcrumb-ellipsis.svelte @@ -15,9 +15,9 @@ data-slot="breadcrumb-ellipsis" role="presentation" aria-hidden="true" - class={cn('flex size-9 items-center justify-center', className)} + class={cn('ui:flex ui:size-9 ui:items-center ui:justify-center', className)} {...restProps} > - + More diff --git a/packages/ui/src/base/breadcrumb/breadcrumb-item.svelte b/packages/ui/src/base/breadcrumb/breadcrumb-item.svelte index 02101d5e..a0f7e6c2 100644 --- a/packages/ui/src/base/breadcrumb/breadcrumb-item.svelte +++ b/packages/ui/src/base/breadcrumb/breadcrumb-item.svelte @@ -1,20 +1,15 @@
  • - {@render children?.()} + {@render children?.()}
  • diff --git a/packages/ui/src/base/breadcrumb/breadcrumb-link.svelte b/packages/ui/src/base/breadcrumb/breadcrumb-link.svelte index 7e2c7037..39f7f528 100644 --- a/packages/ui/src/base/breadcrumb/breadcrumb-link.svelte +++ b/packages/ui/src/base/breadcrumb/breadcrumb-link.svelte @@ -16,7 +16,7 @@ const attrs = $derived({ 'data-slot': 'breadcrumb-link', - class: cn('hover:text-foreground transition-colors', className), + class: cn('ui:hover:text-foreground ui:transition-colors', className), href, ...restProps }); diff --git a/packages/ui/src/base/breadcrumb/breadcrumb-list.svelte b/packages/ui/src/base/breadcrumb/breadcrumb-list.svelte index 1dec790a..a41ae981 100644 --- a/packages/ui/src/base/breadcrumb/breadcrumb-list.svelte +++ b/packages/ui/src/base/breadcrumb/breadcrumb-list.svelte @@ -8,7 +8,10 @@
      {@render children?.()} diff --git a/packages/ui/src/base/breadcrumb/breadcrumb-page.svelte b/packages/ui/src/base/breadcrumb/breadcrumb-page.svelte index 40b23bf3..4acd37d2 100644 --- a/packages/ui/src/base/breadcrumb/breadcrumb-page.svelte +++ b/packages/ui/src/base/breadcrumb/breadcrumb-page.svelte @@ -16,7 +16,7 @@ role="link" aria-disabled="true" aria-current="page" - class={cn('text-foreground font-normal', className)} + class={cn('ui:text-foreground ui:font-normal', className)} {...restProps} > {@render children?.()} diff --git a/packages/ui/src/base/breadcrumb/breadcrumb-separator.svelte b/packages/ui/src/base/breadcrumb/breadcrumb-separator.svelte index dabdc1a6..2df2b5f4 100644 --- a/packages/ui/src/base/breadcrumb/breadcrumb-separator.svelte +++ b/packages/ui/src/base/breadcrumb/breadcrumb-separator.svelte @@ -11,7 +11,7 @@ data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" - class={cn('[&>svg]:size-3.5', className)} + class={cn('ui:[&>svg]:size-3.5', className)} {...restProps} > {#if children} diff --git a/packages/ui/src/base/breadcrumb/index.ts b/packages/ui/src/base/breadcrumb/index.ts index dc914ec3..271c3027 100644 --- a/packages/ui/src/base/breadcrumb/index.ts +++ b/packages/ui/src/base/breadcrumb/index.ts @@ -1,25 +1,25 @@ -import Root from "./breadcrumb.svelte"; -import Ellipsis from "./breadcrumb-ellipsis.svelte"; -import Item from "./breadcrumb-item.svelte"; -import Separator from "./breadcrumb-separator.svelte"; -import Link from "./breadcrumb-link.svelte"; -import List from "./breadcrumb-list.svelte"; -import Page from "./breadcrumb-page.svelte"; +import Root from './breadcrumb.svelte'; +import Ellipsis from './breadcrumb-ellipsis.svelte'; +import Item from './breadcrumb-item.svelte'; +import Separator from './breadcrumb-separator.svelte'; +import Link from './breadcrumb-link.svelte'; +import List from './breadcrumb-list.svelte'; +import Page from './breadcrumb-page.svelte'; export { - Root, - Ellipsis, - Item, - Separator, - Link, - List, - Page, - // - Root as Breadcrumb, - Ellipsis as BreadcrumbEllipsis, - Item as BreadcrumbItem, - Separator as BreadcrumbSeparator, - Link as BreadcrumbLink, - List as BreadcrumbList, - Page as BreadcrumbPage, + Root, + Ellipsis, + Item, + Separator, + Link, + List, + Page, + // + Root as Breadcrumb, + Ellipsis as BreadcrumbEllipsis, + Item as BreadcrumbItem, + Separator as BreadcrumbSeparator, + Link as BreadcrumbLink, + List as BreadcrumbList, + Page as BreadcrumbPage }; diff --git a/packages/ui/src/base/button/button.svelte b/packages/ui/src/base/button/button.svelte index 007d3db2..2fadbcc9 100644 --- a/packages/ui/src/base/button/button.svelte +++ b/packages/ui/src/base/button/button.svelte @@ -5,25 +5,25 @@ import { Spinner } from '../spinner'; export const buttonVariants = tv({ - base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + base: "ui:focus-visible:border-ring ui:cursor-pointer ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:inline-flex ui:shrink-0 ui:items-center ui:justify-center ui:gap-2 ui:whitespace-nowrap ui:rounded-md ui:text-sm ui:font-medium ui:outline-none ui:transition-all ui:focus-visible:ring-[3px] ui:disabled:pointer-events-none ui:disabled:opacity-50 ui:aria-disabled:pointer-events-none ui:aria-disabled:opacity-50 ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0", variants: { variant: { - default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + default: 'ui:bg-primary ui:text-primary-foreground ui:shadow-xs ui:hover:bg-primary/90', destructive: - 'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white', + 'ui:bg-destructive ui:shadow-xs ui:hover:bg-destructive/90 ui:focus-visible:ring-destructive/20 ui:dark:focus-visible:ring-destructive/40 ui:dark:bg-destructive/60 ui:text-white', outline: - 'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border', - secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', - ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', - link: 'text-primary underline-offset-4 hover:underline' + 'ui:bg-background ui:shadow-xs ui:hover:bg-accent ui:hover:text-accent-foreground ui:dark:bg-input/30 ui:dark:border-input ui:dark:hover:bg-input/50 ui:border', + secondary: 'ui:bg-secondary ui:text-secondary-foreground ui:shadow-xs ui:hover:bg-secondary/80', + ghost: 'ui:hover:bg-accent ui:hover:text-accent-foreground ui:dark:hover:bg-accent/50', + link: 'ui:text-primary ui:underline-offset-4 ui:hover:underline' }, size: { - default: 'h-9 px-4 py-2 has-[>svg]:px-3', - sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', - lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', - icon: 'size-9', - 'icon-sm': 'size-8', - 'icon-lg': 'size-10' + default: 'ui:h-9 ui:px-4 ui:py-2 ui:has-[>svg]:px-3', + sm: 'ui:h-8 ui:gap-1.5 ui:rounded-md ui:px-3 ui:has-[>svg]:px-2.5', + lg: 'ui:h-10 ui:rounded-md ui:px-6 ui:has-[>svg]:px-4', + icon: 'ui:size-9', + 'icon-sm': 'ui:size-8', + 'icon-lg': 'ui:size-10' } }, defaultVariants: { diff --git a/packages/ui/src/base/chart/chart-container.svelte b/packages/ui/src/base/chart/chart-container.svelte index dfa134d3..7edac6c7 100644 --- a/packages/ui/src/base/chart/chart-container.svelte +++ b/packages/ui/src/base/chart/chart-container.svelte @@ -31,7 +31,7 @@ data-chart={chartId} data-slot="chart" class={cn( - 'flex aspect-video justify-center overflow-visible text-xs', + 'ui:flex ui:justify-center ui:overflow-visible ui:text-xs aspect-video', // Overrides // // Stroke around dots/marks when hovering diff --git a/packages/ui/src/base/chart/chart-style.svelte b/packages/ui/src/base/chart/chart-style.svelte index 864ecc31..41e6ecb2 100644 --- a/packages/ui/src/base/chart/chart-style.svelte +++ b/packages/ui/src/base/chart/chart-style.svelte @@ -1,37 +1,37 @@ {#if themeContents} - {#key id} - - {themeContents} - - {/key} + {#key id} + + {themeContents} + + {/key} {/if} diff --git a/packages/ui/src/base/chart/chart-tooltip.svelte b/packages/ui/src/base/chart/chart-tooltip.svelte index 49041de1..89a228af 100644 --- a/packages/ui/src/base/chart/chart-tooltip.svelte +++ b/packages/ui/src/base/chart/chart-tooltip.svelte @@ -73,7 +73,7 @@ {#snippet TooltipLabel()} {#if formattedLabel} -
      +
      {#if typeof formattedLabel === 'function'} {@render formattedLabel()} {:else} @@ -86,7 +86,7 @@
      +
      {#each tooltipCtx.payload as item, i (item.key + i)} {@const key = `${nameKey || item.key || item.name || 'value'}`} {@const itemConfig = getPayloadConfigFromPayload(chart.config, item, key)} {@const indicatorColor = color || item.payload?.color || item.color}
      svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5', + 'ui:[&>svg]:text-muted-foreground ui:flex ui:w-full ui:flex-wrap ui:items-stretch ui:gap-2 ui:[&>svg]:size-2.5', indicator === 'dot' && 'items-center' )} > @@ -119,27 +119,30 @@ {:else if !hideIndicator}
      {/if}
      -
      +
      {#if nestLabel} {@render TooltipLabel()} {/if} - + {itemConfig?.label || item.name}
      {#if item.value !== undefined} - + {item.value.toLocaleString()} {/if} diff --git a/packages/ui/src/base/chart/chart-utils.ts b/packages/ui/src/base/chart/chart-utils.ts index 2decbbf7..797ae1d2 100644 --- a/packages/ui/src/base/chart/chart-utils.ts +++ b/packages/ui/src/base/chart/chart-utils.ts @@ -1,66 +1,57 @@ -import type { Tooltip } from "layerchart"; -import { getContext, setContext, type Component, type ComponentProps, type Snippet } from "svelte"; +import type { Tooltip } from 'layerchart'; +import { getContext, setContext, type Component, type ComponentProps, type Snippet } from 'svelte'; -export const THEMES = { light: "", dark: ".dark" } as const; +export const THEMES = { light: '', dark: '.dark' } as const; export type ChartConfig = { - [k in string]: { - label?: string; - icon?: Component; - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record } - ); + [k in string]: { + label?: string; + icon?: Component; + } & ({ color?: string; theme?: never } | { color?: never; theme: Record }); }; export type ExtractSnippetParams = T extends Snippet<[infer P]> ? P : never; -export type TooltipPayload = ExtractSnippetParams< - ComponentProps["children"] ->["payload"][number]; +export type TooltipPayload = ExtractSnippetParams['children']>['payload'][number]; // Helper to extract item config from a payload. -export function getPayloadConfigFromPayload( - config: ChartConfig, - payload: TooltipPayload, - key: string -) { - if (typeof payload !== "object" || payload === null) return undefined; +export function getPayloadConfigFromPayload(config: ChartConfig, payload: TooltipPayload, key: string) { + if (typeof payload !== 'object' || payload === null) return undefined; - const payloadPayload = - "payload" in payload && typeof payload.payload === "object" && payload.payload !== null - ? payload.payload - : undefined; + const payloadPayload = + 'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null + ? payload.payload + : undefined; - let configLabelKey: string = key; + let configLabelKey: string = key; - if (payload.key === key) { - configLabelKey = payload.key; - } else if (payload.name === key) { - configLabelKey = payload.name; - } else if (key in payload && typeof payload[key as keyof typeof payload] === "string") { - configLabelKey = payload[key as keyof typeof payload] as string; - } else if ( - payloadPayload !== undefined && - key in payloadPayload && - typeof payloadPayload[key as keyof typeof payloadPayload] === "string" - ) { - configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string; - } + if (payload.key === key) { + configLabelKey = payload.key; + } else if (payload.name === key) { + configLabelKey = payload.name; + } else if (key in payload && typeof payload[key as keyof typeof payload] === 'string') { + configLabelKey = payload[key as keyof typeof payload] as string; + } else if ( + payloadPayload !== undefined && + key in payloadPayload && + typeof payloadPayload[key as keyof typeof payloadPayload] === 'string' + ) { + configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string; + } - return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; + return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config]; } type ChartContextValue = { - config: ChartConfig; + config: ChartConfig; }; -const chartContextKey = Symbol("chart-context"); +const chartContextKey = Symbol('chart-context'); export function setChartContext(value: ChartContextValue) { - return setContext(chartContextKey, value); + return setContext(chartContextKey, value); } export function useChart() { - return getContext(chartContextKey); + return getContext(chartContextKey); } diff --git a/packages/ui/src/base/checkbox/checkbox.svelte b/packages/ui/src/base/checkbox/checkbox.svelte index 0df3f280..560e8107 100644 --- a/packages/ui/src/base/checkbox/checkbox.svelte +++ b/packages/ui/src/base/checkbox/checkbox.svelte @@ -17,7 +17,7 @@ bind:ref data-slot="checkbox" class={cn( - 'border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50', + 'ui:border-input ui:dark:bg-input/30 ui:data-[state=checked]:bg-primary ui:data-[state=checked]:text-primary-foreground ui:dark:data-[state=checked]:bg-primary ui:data-[state=checked]:border-primary ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:aria-invalid:ring-destructive/20 ui:dark:aria-invalid:ring-destructive/40 ui:aria-invalid:border-destructive ui:shadow-xs ui:flex ui:size-4 ui:shrink-0 ui:items-center ui:justify-center ui:rounded-[4px] ui:border ui:outline-none ui:transition-shadow ui:focus-visible:ring-[3px] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 peer', className )} bind:checked @@ -25,11 +25,11 @@ {...restProps} > {#snippet children({ checked, indeterminate })} -
      +
      {#if checked} - + {:else if indeterminate} - + {/if}
      {/snippet} diff --git a/packages/ui/src/base/checkbox/index.ts b/packages/ui/src/base/checkbox/index.ts index 6d92d945..0543c5ab 100644 --- a/packages/ui/src/base/checkbox/index.ts +++ b/packages/ui/src/base/checkbox/index.ts @@ -1,6 +1,6 @@ -import Root from "./checkbox.svelte"; +import Root from './checkbox.svelte'; export { - Root, - // - Root as Checkbox, + Root, + // + Root as Checkbox }; diff --git a/packages/ui/src/base/collapsible/collapsible-content.svelte b/packages/ui/src/base/collapsible/collapsible-content.svelte index bdabb559..6588d5c4 100644 --- a/packages/ui/src/base/collapsible/collapsible-content.svelte +++ b/packages/ui/src/base/collapsible/collapsible-content.svelte @@ -1,7 +1,7 @@ diff --git a/packages/ui/src/base/collapsible/collapsible-trigger.svelte b/packages/ui/src/base/collapsible/collapsible-trigger.svelte index ece7ad68..a24a44e0 100644 --- a/packages/ui/src/base/collapsible/collapsible-trigger.svelte +++ b/packages/ui/src/base/collapsible/collapsible-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/packages/ui/src/base/collapsible/collapsible.svelte b/packages/ui/src/base/collapsible/collapsible.svelte index 39cdd4e4..714196f2 100644 --- a/packages/ui/src/base/collapsible/collapsible.svelte +++ b/packages/ui/src/base/collapsible/collapsible.svelte @@ -1,11 +1,7 @@ diff --git a/packages/ui/src/base/collapsible/index.ts b/packages/ui/src/base/collapsible/index.ts index 169b4791..0ecd6270 100644 --- a/packages/ui/src/base/collapsible/index.ts +++ b/packages/ui/src/base/collapsible/index.ts @@ -1,13 +1,13 @@ -import Root from "./collapsible.svelte"; -import Trigger from "./collapsible-trigger.svelte"; -import Content from "./collapsible-content.svelte"; +import Root from './collapsible.svelte'; +import Trigger from './collapsible-trigger.svelte'; +import Content from './collapsible-content.svelte'; export { - Root, - Content, - Trigger, - // - Root as Collapsible, - Content as CollapsibleContent, - Trigger as CollapsibleTrigger, + Root, + Content, + Trigger, + // + Root as Collapsible, + Content as CollapsibleContent, + Trigger as CollapsibleTrigger }; diff --git a/packages/ui/src/base/command/command-dialog.svelte b/packages/ui/src/base/command/command-dialog.svelte index 6e961021..ba71b8ca 100644 --- a/packages/ui/src/base/command/command-dialog.svelte +++ b/packages/ui/src/base/command/command-dialog.svelte @@ -28,9 +28,9 @@ {title} {description} - + diff --git a/packages/ui/src/base/command/command-group.svelte b/packages/ui/src/base/command/command-group.svelte index 8c440548..35465020 100644 --- a/packages/ui/src/base/command/command-group.svelte +++ b/packages/ui/src/base/command/command-group.svelte @@ -17,12 +17,12 @@ {#if heading} - + {heading} {/if} diff --git a/packages/ui/src/base/command/command-input.svelte b/packages/ui/src/base/command/command-input.svelte index 6b7d53d3..6f8d6fc7 100644 --- a/packages/ui/src/base/command/command-input.svelte +++ b/packages/ui/src/base/command/command-input.svelte @@ -11,12 +11,12 @@ }: CommandPrimitive.InputProps = $props(); -
      - +
      + diff --git a/packages/ui/src/base/command/command-separator.svelte b/packages/ui/src/base/command/command-separator.svelte index 218717cb..b972498a 100644 --- a/packages/ui/src/base/command/command-separator.svelte +++ b/packages/ui/src/base/command/command-separator.svelte @@ -8,6 +8,6 @@ diff --git a/packages/ui/src/base/command/command-shortcut.svelte b/packages/ui/src/base/command/command-shortcut.svelte index 76aad054..59df8fad 100644 --- a/packages/ui/src/base/command/command-shortcut.svelte +++ b/packages/ui/src/base/command/command-shortcut.svelte @@ -13,7 +13,7 @@ {@render children?.()} diff --git a/packages/ui/src/base/command/command.svelte b/packages/ui/src/base/command/command.svelte index 14f8d100..25369678 100644 --- a/packages/ui/src/base/command/command.svelte +++ b/packages/ui/src/base/command/command.svelte @@ -20,6 +20,9 @@ bind:value bind:ref data-slot="command" - class={cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', className)} + class={cn( + 'ui:bg-popover ui:text-popover-foreground ui:flex ui:h-full ui:w-full ui:flex-col ui:overflow-hidden ui:rounded-md', + className + )} {...restProps} /> diff --git a/packages/ui/src/base/command/index.ts b/packages/ui/src/base/command/index.ts index d3dbadea..a9f28a9d 100644 --- a/packages/ui/src/base/command/index.ts +++ b/packages/ui/src/base/command/index.ts @@ -1,40 +1,40 @@ -import { Command as CommandPrimitive } from "bits-ui"; +import { Command as CommandPrimitive } from 'bits-ui'; -import Root from "./command.svelte"; -import Dialog from "./command-dialog.svelte"; -import Empty from "./command-empty.svelte"; -import Group from "./command-group.svelte"; -import Item from "./command-item.svelte"; -import Input from "./command-input.svelte"; -import List from "./command-list.svelte"; -import Separator from "./command-separator.svelte"; -import Shortcut from "./command-shortcut.svelte"; -import LinkItem from "./command-link-item.svelte"; +import Root from './command.svelte'; +import Dialog from './command-dialog.svelte'; +import Empty from './command-empty.svelte'; +import Group from './command-group.svelte'; +import Item from './command-item.svelte'; +import Input from './command-input.svelte'; +import List from './command-list.svelte'; +import Separator from './command-separator.svelte'; +import Shortcut from './command-shortcut.svelte'; +import LinkItem from './command-link-item.svelte'; const Loading = CommandPrimitive.Loading; export { - Root, - Dialog, - Empty, - Group, - Item, - LinkItem, - Input, - List, - Separator, - Shortcut, - Loading, - // - Root as Command, - Dialog as CommandDialog, - Empty as CommandEmpty, - Group as CommandGroup, - Item as CommandItem, - LinkItem as CommandLinkItem, - Input as CommandInput, - List as CommandList, - Separator as CommandSeparator, - Shortcut as CommandShortcut, - Loading as CommandLoading, + Root, + Dialog, + Empty, + Group, + Item, + LinkItem, + Input, + List, + Separator, + Shortcut, + Loading, + // + Root as Command, + Dialog as CommandDialog, + Empty as CommandEmpty, + Group as CommandGroup, + Item as CommandItem, + LinkItem as CommandLinkItem, + Input as CommandInput, + List as CommandList, + Separator as CommandSeparator, + Shortcut as CommandShortcut, + Loading as CommandLoading }; diff --git a/packages/ui/src/base/dark-mode/dark-mode.svelte b/packages/ui/src/base/dark-mode/dark-mode.svelte index 42c63ae8..a26b807a 100644 --- a/packages/ui/src/base/dark-mode/dark-mode.svelte +++ b/packages/ui/src/base/dark-mode/dark-mode.svelte @@ -9,8 +9,12 @@ - - + + Toggle theme diff --git a/packages/ui/src/base/dark-mode/index.ts b/packages/ui/src/base/dark-mode/index.ts index 0cef385e..29c7c135 100644 --- a/packages/ui/src/base/dark-mode/index.ts +++ b/packages/ui/src/base/dark-mode/index.ts @@ -1,3 +1,3 @@ export { default as ModeSwitcher } from './dark-mode.svelte'; -export * from 'mode-watcher'; +export { ModeWatcher } from 'mode-watcher'; diff --git a/packages/ui/src/base/dialog/dialog-close.svelte b/packages/ui/src/base/dialog/dialog-close.svelte index 840b2f68..c4718545 100644 --- a/packages/ui/src/base/dialog/dialog-close.svelte +++ b/packages/ui/src/base/dialog/dialog-close.svelte @@ -1,7 +1,7 @@ diff --git a/packages/ui/src/base/dialog/dialog-content.svelte b/packages/ui/src/base/dialog/dialog-content.svelte index 3e0ecb81..b485ad69 100644 --- a/packages/ui/src/base/dialog/dialog-content.svelte +++ b/packages/ui/src/base/dialog/dialog-content.svelte @@ -25,7 +25,7 @@ bind:ref data-slot="dialog-content" class={cn( - 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', + 'ui:bg-background ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:fixed ui:left-[50%] ui:top-[50%] ui:z-50 ui:grid ui:w-full ui:max-w-[calc(100%-2rem)] ui:translate-x-[-50%] ui:translate-y-[-50%] ui:gap-4 ui:rounded-lg ui:border ui:p-6 ui:shadow-lg ui:duration-200 ui:sm:max-w-lg', className )} {...restProps} @@ -33,7 +33,7 @@ {@render children?.()} {#if showCloseButton} Close diff --git a/packages/ui/src/base/dialog/dialog-description.svelte b/packages/ui/src/base/dialog/dialog-description.svelte index ba9a6b81..3dfc28f4 100644 --- a/packages/ui/src/base/dialog/dialog-description.svelte +++ b/packages/ui/src/base/dialog/dialog-description.svelte @@ -8,6 +8,6 @@ diff --git a/packages/ui/src/base/dialog/dialog-footer.svelte b/packages/ui/src/base/dialog/dialog-footer.svelte index 7425fc46..9c06d83b 100644 --- a/packages/ui/src/base/dialog/dialog-footer.svelte +++ b/packages/ui/src/base/dialog/dialog-footer.svelte @@ -13,7 +13,7 @@
      {@render children?.()} diff --git a/packages/ui/src/base/dialog/dialog-header.svelte b/packages/ui/src/base/dialog/dialog-header.svelte index f83ca6d6..04b530bf 100644 --- a/packages/ui/src/base/dialog/dialog-header.svelte +++ b/packages/ui/src/base/dialog/dialog-header.svelte @@ -13,7 +13,7 @@
      {@render children?.()} diff --git a/packages/ui/src/base/dialog/dialog-overlay.svelte b/packages/ui/src/base/dialog/dialog-overlay.svelte index 1e026c0a..14e73081 100644 --- a/packages/ui/src/base/dialog/dialog-overlay.svelte +++ b/packages/ui/src/base/dialog/dialog-overlay.svelte @@ -9,7 +9,7 @@ bind:ref data-slot="dialog-overlay" class={cn( - 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50', + 'ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:fixed ui:inset-0 ui:z-50 ui:bg-black/50', className )} {...restProps} diff --git a/packages/ui/src/base/dialog/dialog-title.svelte b/packages/ui/src/base/dialog/dialog-title.svelte index a9c53cbc..5e530259 100644 --- a/packages/ui/src/base/dialog/dialog-title.svelte +++ b/packages/ui/src/base/dialog/dialog-title.svelte @@ -8,6 +8,6 @@ diff --git a/packages/ui/src/base/dialog/dialog-trigger.svelte b/packages/ui/src/base/dialog/dialog-trigger.svelte index 9d1e8011..a2e7178d 100644 --- a/packages/ui/src/base/dialog/dialog-trigger.svelte +++ b/packages/ui/src/base/dialog/dialog-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/packages/ui/src/base/dialog/index.ts b/packages/ui/src/base/dialog/index.ts index dce1d9dc..4ad09d70 100644 --- a/packages/ui/src/base/dialog/index.ts +++ b/packages/ui/src/base/dialog/index.ts @@ -1,37 +1,37 @@ -import { Dialog as DialogPrimitive } from "bits-ui"; +import { Dialog as DialogPrimitive } from 'bits-ui'; -import Title from "./dialog-title.svelte"; -import Footer from "./dialog-footer.svelte"; -import Header from "./dialog-header.svelte"; -import Overlay from "./dialog-overlay.svelte"; -import Content from "./dialog-content.svelte"; -import Description from "./dialog-description.svelte"; -import Trigger from "./dialog-trigger.svelte"; -import Close from "./dialog-close.svelte"; +import Title from './dialog-title.svelte'; +import Footer from './dialog-footer.svelte'; +import Header from './dialog-header.svelte'; +import Overlay from './dialog-overlay.svelte'; +import Content from './dialog-content.svelte'; +import Description from './dialog-description.svelte'; +import Trigger from './dialog-trigger.svelte'; +import Close from './dialog-close.svelte'; const Root = DialogPrimitive.Root; const Portal = DialogPrimitive.Portal; export { - Root, - Title, - Portal, - Footer, - Header, - Trigger, - Overlay, - Content, - Description, - Close, - // - Root as Dialog, - Title as DialogTitle, - Portal as DialogPortal, - Footer as DialogFooter, - Header as DialogHeader, - Trigger as DialogTrigger, - Overlay as DialogOverlay, - Content as DialogContent, - Description as DialogDescription, - Close as DialogClose, + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose }; diff --git a/packages/ui/src/base/drawer/drawer-close.svelte b/packages/ui/src/base/drawer/drawer-close.svelte new file mode 100644 index 00000000..95c24796 --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-close.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/ui/src/base/drawer/drawer-content.svelte b/packages/ui/src/base/drawer/drawer-content.svelte new file mode 100644 index 00000000..0ac4bcbf --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-content.svelte @@ -0,0 +1,37 @@ + + + + + +
      + {@render children?.()} +
      +
      diff --git a/packages/ui/src/base/drawer/drawer-description.svelte b/packages/ui/src/base/drawer/drawer-description.svelte new file mode 100644 index 00000000..3f2e4475 --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-description.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/ui/src/base/drawer/drawer-footer.svelte b/packages/ui/src/base/drawer/drawer-footer.svelte new file mode 100644 index 00000000..5398405a --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-footer.svelte @@ -0,0 +1,20 @@ + + +
      + {@render children?.()} +
      diff --git a/packages/ui/src/base/drawer/drawer-header.svelte b/packages/ui/src/base/drawer/drawer-header.svelte new file mode 100644 index 00000000..bc83e229 --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-header.svelte @@ -0,0 +1,20 @@ + + +
      + {@render children?.()} +
      diff --git a/packages/ui/src/base/drawer/drawer-nested.svelte b/packages/ui/src/base/drawer/drawer-nested.svelte new file mode 100644 index 00000000..cda38d0b --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-nested.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/ui/src/base/drawer/drawer-overlay.svelte b/packages/ui/src/base/drawer/drawer-overlay.svelte new file mode 100644 index 00000000..cfefcce2 --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-overlay.svelte @@ -0,0 +1,16 @@ + + + diff --git a/packages/ui/src/base/drawer/drawer-title.svelte b/packages/ui/src/base/drawer/drawer-title.svelte new file mode 100644 index 00000000..1c09667c --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-title.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/ui/src/base/drawer/drawer-trigger.svelte b/packages/ui/src/base/drawer/drawer-trigger.svelte new file mode 100644 index 00000000..f1877d8e --- /dev/null +++ b/packages/ui/src/base/drawer/drawer-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/ui/src/base/drawer/drawer.svelte b/packages/ui/src/base/drawer/drawer.svelte new file mode 100644 index 00000000..0cb57ff9 --- /dev/null +++ b/packages/ui/src/base/drawer/drawer.svelte @@ -0,0 +1,12 @@ + + + diff --git a/packages/ui/src/base/drawer/index.ts b/packages/ui/src/base/drawer/index.ts new file mode 100644 index 00000000..cfbdb8b2 --- /dev/null +++ b/packages/ui/src/base/drawer/index.ts @@ -0,0 +1,41 @@ +import { Drawer as DrawerPrimitive } from "vaul-svelte"; + +import Root from "./drawer.svelte"; +import Content from "./drawer-content.svelte"; +import Description from "./drawer-description.svelte"; +import Overlay from "./drawer-overlay.svelte"; +import Footer from "./drawer-footer.svelte"; +import Header from "./drawer-header.svelte"; +import Title from "./drawer-title.svelte"; +import NestedRoot from "./drawer-nested.svelte"; +import Close from "./drawer-close.svelte"; +import Trigger from "./drawer-trigger.svelte"; + +const Portal: typeof DrawerPrimitive.Portal = DrawerPrimitive.Portal; + +export { + Root, + NestedRoot, + Content, + Description, + Overlay, + Footer, + Header, + Title, + Trigger, + Portal, + Close, + + // + Root as Drawer, + NestedRoot as DrawerNestedRoot, + Content as DrawerContent, + Description as DrawerDescription, + Overlay as DrawerOverlay, + Footer as DrawerFooter, + Header as DrawerHeader, + Title as DrawerTitle, + Trigger as DrawerTrigger, + Portal as DrawerPortal, + Close as DrawerClose, +}; diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-checkbox-item.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-checkbox-item.svelte index 55f5d9e3..1ab54e0b 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -23,17 +23,17 @@ bind:indeterminate data-slot="dropdown-menu-checkbox-item" class={cn( - "focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "ui:focus:bg-accent ui:focus:text-accent-foreground ui:outline-hidden ui:relative ui:flex ui:cursor-default ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-8 ui:pr-2 ui:text-sm ui:data-disabled:pointer-events-none ui:data-disabled:opacity-50 ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0", className )} {...restProps} > {#snippet children({ checked, indeterminate })} - + {#if indeterminate} - + {:else} - + {/if} {@render childrenProp?.()} diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-content.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-content.svelte index d78f4fdc..723d6614 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-content.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-content.svelte @@ -19,7 +19,7 @@ data-slot="dropdown-menu-content" {sideOffset} class={cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none', + 'ui:bg-popover ui:text-popover-foreground ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:data-[side=bottom]:slide-in-from-top-2 ui:data-[side=left]:slide-in-from-right-2 ui:data-[side=right]:slide-in-from-left-2 ui:data-[side=top]:slide-in-from-bottom-2 ui:max-h-(--bits-dropdown-menu-content-available-height) ui:origin-(--bits-dropdown-menu-content-transform-origin) ui:z-50 ui:min-w-32 ui:overflow-y-auto ui:overflow-x-hidden ui:rounded-md ui:border ui:p-1 ui:shadow-md ui:outline-none', className )} {...restProps} diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-group-heading.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-group-heading.svelte index a1862241..481bfefb 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-group-heading.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-group-heading.svelte @@ -17,6 +17,6 @@ bind:ref data-slot="dropdown-menu-group-heading" data-inset={inset} - class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)} + class={cn('ui:px-2 ui:py-1.5 ui:text-sm ui:font-semibold ui:data-inset:pl-8', className)} {...restProps} /> diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-group.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-group.svelte index aca1f7bd..bf972af4 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-group.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-group.svelte @@ -1,7 +1,7 @@ diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-item.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-item.svelte index ac114f77..208fc9cd 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-item.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-item.svelte @@ -20,7 +20,7 @@ data-inset={inset} data-variant={variant} class={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "ui:data-highlighted:bg-accent ui:data-highlighted:text-accent-foreground ui:data-[variant=destructive]:text-destructive ui:data-[variant=destructive]:data-highlighted:bg-destructive/10 ui:dark:ui:data-[variant=destructive]:data-highlighted:bg-destructive/20 ui:data-[variant=destructive]:data-highlighted:text-destructive ui:data-[variant=destructive]:*:[svg]:text-destructive! ui:[&_svg:not([class*='text-'])]:text-muted-foreground ui:outline-hidden ui:relative ui:flex ui:cursor-default ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:px-2 ui:py-1.5 ui:text-sm ui:data-disabled:pointer-events-none ui:data-inset:pl-8 ui:data-disabled:opacity-50 ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0", className )} {...restProps} diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-label.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-label.svelte index 169dcdc8..48f364ab 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-label.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-label.svelte @@ -17,7 +17,7 @@ bind:this={ref} data-slot="dropdown-menu-label" data-inset={inset} - class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)} + class={cn('ui:px-2 ui:py-1.5 ui:text-sm ui:font-semibold ui:data-[inset]:pl-8', className)} {...restProps} > {@render children?.()} diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-group.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-group.svelte index 189aef40..6a0b3e2c 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-group.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-group.svelte @@ -1,16 +1,7 @@ - + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-item.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-item.svelte index 2ffa2640..22a1921f 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-item.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-radio-item.svelte @@ -15,15 +15,15 @@ bind:ref data-slot="dropdown-menu-radio-item" class={cn( - "focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "ui:focus:bg-accent ui:focus:text-accent-foreground ui:outline-hidden ui:relative ui:flex ui:cursor-default ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:py-1.5 ui:pl-8 ui:pr-2 ui:text-sm ui:data-disabled:pointer-events-none ui:data-disabled:opacity-50 ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0", className )} {...restProps} > {#snippet children({ checked })} - + {#if checked} - + {/if} {@render childrenProp?.({ checked })} diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-separator.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-separator.svelte index b6291860..7f0dfa08 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-separator.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-separator.svelte @@ -8,6 +8,6 @@ diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-shortcut.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-shortcut.svelte index 2dc21cf2..a33cd240 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-shortcut.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-shortcut.svelte @@ -13,7 +13,7 @@ {@render children?.()} diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-content.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-content.svelte index feebadfb..c0aa630f 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-content.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-content.svelte @@ -9,7 +9,7 @@ bind:ref data-slot="dropdown-menu-sub-content" class={cn( - 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg', + 'ui:bg-popover ui:text-popover-foreground ui:data-[state=open]:animate-in ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=open]:fade-in-0 ui:data-[state=closed]:zoom-out-95 ui:data-[state=open]:zoom-in-95 ui:data-[side=bottom]:slide-in-from-top-2 ui:data-[side=left]:slide-in-from-right-2 ui:data-[side=right]:slide-in-from-left-2 ui:data-[side=top]:slide-in-from-bottom-2 ui:origin-(--bits-dropdown-menu-content-transform-origin) ui:z-50 ui:min-w-32 ui:overflow-hidden ui:rounded-md ui:border ui:p-1 ui:shadow-lg', className )} {...restProps} diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-trigger.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-trigger.svelte index fcbee0cc..c90037e9 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-trigger.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -19,11 +19,11 @@ data-slot="dropdown-menu-sub-trigger" data-inset={inset} class={cn( - "data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "ui:data-highlighted:bg-accent ui:data-highlighted:text-accent-foreground ui:data-[state=open]:bg-accent ui:data-[state=open]:text-accent-foreground ui:outline-hidden ui:[&_svg:not([class*='text-'])]:text-muted-foreground ui:flex ui:cursor-default ui:select-none ui:items-center ui:gap-2 ui:rounded-sm ui:px-2 ui:py-1.5 ui:text-sm ui:data-disabled:pointer-events-none ui:data-inset:pl-8 ui:data-disabled:opacity-50 ui:[&_svg:not([class*='size-'])]:size-4 ui:[&_svg]:pointer-events-none ui:[&_svg]:shrink-0", className )} {...restProps} > {@render children?.()} - + diff --git a/packages/ui/src/base/dropdown-menu/dropdown-menu-trigger.svelte b/packages/ui/src/base/dropdown-menu/dropdown-menu-trigger.svelte index cb053444..7409c777 100644 --- a/packages/ui/src/base/dropdown-menu/dropdown-menu-trigger.svelte +++ b/packages/ui/src/base/dropdown-menu/dropdown-menu-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/packages/ui/src/base/dropdown-menu/index.ts b/packages/ui/src/base/dropdown-menu/index.ts index 1cf9f701..61488c19 100644 --- a/packages/ui/src/base/dropdown-menu/index.ts +++ b/packages/ui/src/base/dropdown-menu/index.ts @@ -1,49 +1,49 @@ -import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; -import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; -import Content from "./dropdown-menu-content.svelte"; -import Group from "./dropdown-menu-group.svelte"; -import Item from "./dropdown-menu-item.svelte"; -import Label from "./dropdown-menu-label.svelte"; -import RadioGroup from "./dropdown-menu-radio-group.svelte"; -import RadioItem from "./dropdown-menu-radio-item.svelte"; -import Separator from "./dropdown-menu-separator.svelte"; -import Shortcut from "./dropdown-menu-shortcut.svelte"; -import Trigger from "./dropdown-menu-trigger.svelte"; -import SubContent from "./dropdown-menu-sub-content.svelte"; -import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; -import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'; +import CheckboxItem from './dropdown-menu-checkbox-item.svelte'; +import Content from './dropdown-menu-content.svelte'; +import Group from './dropdown-menu-group.svelte'; +import Item from './dropdown-menu-item.svelte'; +import Label from './dropdown-menu-label.svelte'; +import RadioGroup from './dropdown-menu-radio-group.svelte'; +import RadioItem from './dropdown-menu-radio-item.svelte'; +import Separator from './dropdown-menu-separator.svelte'; +import Shortcut from './dropdown-menu-shortcut.svelte'; +import Trigger from './dropdown-menu-trigger.svelte'; +import SubContent from './dropdown-menu-sub-content.svelte'; +import SubTrigger from './dropdown-menu-sub-trigger.svelte'; +import GroupHeading from './dropdown-menu-group-heading.svelte'; const Sub = DropdownMenuPrimitive.Sub; const Root = DropdownMenuPrimitive.Root; export { - CheckboxItem, - Content, - Root as DropdownMenu, - CheckboxItem as DropdownMenuCheckboxItem, - Content as DropdownMenuContent, - Group as DropdownMenuGroup, - Item as DropdownMenuItem, - Label as DropdownMenuLabel, - RadioGroup as DropdownMenuRadioGroup, - RadioItem as DropdownMenuRadioItem, - Separator as DropdownMenuSeparator, - Shortcut as DropdownMenuShortcut, - Sub as DropdownMenuSub, - SubContent as DropdownMenuSubContent, - SubTrigger as DropdownMenuSubTrigger, - Trigger as DropdownMenuTrigger, - GroupHeading as DropdownMenuGroupHeading, - Group, - GroupHeading, - Item, - Label, - RadioGroup, - RadioItem, - Root, - Separator, - Shortcut, - Sub, - SubContent, - SubTrigger, - Trigger, + CheckboxItem, + Content, + Root as DropdownMenu, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger }; diff --git a/packages/ui/src/base/input-group/index.ts b/packages/ui/src/base/input-group/index.ts new file mode 100644 index 00000000..fe1f55d8 --- /dev/null +++ b/packages/ui/src/base/input-group/index.ts @@ -0,0 +1,22 @@ +import Root from "./input-group.svelte"; +import Addon from "./input-group-addon.svelte"; +import Button from "./input-group-button.svelte"; +import Input from "./input-group-input.svelte"; +import Text from "./input-group-text.svelte"; +import Textarea from "./input-group-textarea.svelte"; + +export { + Root, + Addon, + Button, + Input, + Text, + Textarea, + // + Root as InputGroup, + Addon as InputGroupAddon, + Button as InputGroupButton, + Input as InputGroupInput, + Text as InputGroupText, + Textarea as InputGroupTextarea, +}; diff --git a/packages/ui/src/base/input-group/input-group-addon.svelte b/packages/ui/src/base/input-group/input-group-addon.svelte new file mode 100644 index 00000000..4aae1c32 --- /dev/null +++ b/packages/ui/src/base/input-group/input-group-addon.svelte @@ -0,0 +1,54 @@ + + + + +
      { + if ((e.target as HTMLElement).closest('button')) { + return; + } + e.currentTarget.parentElement?.querySelector('input')?.focus(); + }} + {...restProps} +> + {@render children?.()} +
      diff --git a/packages/ui/src/base/input-group/input-group-button.svelte b/packages/ui/src/base/input-group/input-group-button.svelte new file mode 100644 index 00000000..e2a512d2 --- /dev/null +++ b/packages/ui/src/base/input-group/input-group-button.svelte @@ -0,0 +1,49 @@ + + + + + diff --git a/packages/ui/src/base/input-group/input-group-input.svelte b/packages/ui/src/base/input-group/input-group-input.svelte new file mode 100644 index 00000000..d2365b8f --- /dev/null +++ b/packages/ui/src/base/input-group/input-group-input.svelte @@ -0,0 +1,23 @@ + + + diff --git a/packages/ui/src/base/input-group/input-group-text.svelte b/packages/ui/src/base/input-group/input-group-text.svelte new file mode 100644 index 00000000..fd8a78e1 --- /dev/null +++ b/packages/ui/src/base/input-group/input-group-text.svelte @@ -0,0 +1,22 @@ + + + + {@render children?.()} + diff --git a/packages/ui/src/base/input-group/input-group-textarea.svelte b/packages/ui/src/base/input-group/input-group-textarea.svelte new file mode 100644 index 00000000..7ccdda8f --- /dev/null +++ b/packages/ui/src/base/input-group/input-group-textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/packages/ui/src/base/tooltip/index.ts b/packages/ui/src/base/tooltip/index.ts index 313a7f06..3edd0407 100644 --- a/packages/ui/src/base/tooltip/index.ts +++ b/packages/ui/src/base/tooltip/index.ts @@ -1,21 +1,21 @@ -import { Tooltip as TooltipPrimitive } from "bits-ui"; -import Trigger from "./tooltip-trigger.svelte"; -import Content from "./tooltip-content.svelte"; +import { Tooltip as TooltipPrimitive } from 'bits-ui'; +import Trigger from './tooltip-trigger.svelte'; +import Content from './tooltip-content.svelte'; const Root = TooltipPrimitive.Root; const Provider = TooltipPrimitive.Provider; const Portal = TooltipPrimitive.Portal; export { - Root, - Trigger, - Content, - Provider, - Portal, - // - Root as Tooltip, - Content as TooltipContent, - Trigger as TooltipTrigger, - Provider as TooltipProvider, - Portal as TooltipPortal, + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal }; diff --git a/packages/ui/src/base/tooltip/tooltip-content.svelte b/packages/ui/src/base/tooltip/tooltip-content.svelte index f6289021..e0402db3 100644 --- a/packages/ui/src/base/tooltip/tooltip-content.svelte +++ b/packages/ui/src/base/tooltip/tooltip-content.svelte @@ -22,7 +22,7 @@ {sideOffset} {side} class={cn( - 'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs', + 'ui:bg-primary ui:text-primary-foreground ui:animate-in ui:fade-in-0 ui:zoom-in-95 ui:data-[state=closed]:animate-out ui:data-[state=closed]:fade-out-0 ui:data-[state=closed]:zoom-out-95 ui:data-[side=bottom]:slide-in-from-top-2 ui:data-[side=left]:slide-in-from-right-2 ui:data-[side=right]:slide-in-from-left-2 ui:data-[side=top]:slide-in-from-bottom-2 ui:origin-(--bits-tooltip-content-transform-origin) ui:z-50 ui:w-fit ui:text-balance ui:rounded-md ui:px-3 ui:py-1.5 ui:text-xs', className )} {...restProps} @@ -32,11 +32,11 @@ {#snippet child({ props })}
      - import { Tooltip as TooltipPrimitive } from "bits-ui"; + import { Tooltip as TooltipPrimitive } from 'bits-ui'; - let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props(); + let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props(); diff --git a/packages/ui/src/custom/editor/USAGE.md b/packages/ui/src/custom/editor/USAGE.md index b9959937..4fdc43e6 100644 --- a/packages/ui/src/custom/editor/USAGE.md +++ b/packages/ui/src/custom/editor/USAGE.md @@ -68,7 +68,7 @@ You can also set HTML content programmatically:

      This content was loaded after the editor was ready.

      console.log('Hello, world!'); `; - + if (editor) { editor.commands.setContent(htmlString); } @@ -79,14 +79,9 @@ You can also set HTML content programmatically: } - + - + ``` ## Advanced Usage with All Props @@ -105,7 +100,7 @@ You can also set HTML content programmatically: } ] }; - + let editable = true; let showToolBar = true; @@ -120,7 +115,7 @@ You can also set HTML content programmatically: } -

      Article

      - +
      - +

      Notes

      - void` | `undefined` | Callback when content changes | -| `onEditorReady` | `(editor: Editor) => void` | `undefined` | Callback when editor is ready | -| `onEditorDestroy` | `() => void` | `undefined` | Callback when editor is destroyed | +| Prop | Type | Default | Description | +| -------------------- | ---------------------------- | ---------------------------- | ----------------------------------- | +| `content` | `Content` | Default doc with placeholder | Editor content (bindable) | +| `showToolBar` | `boolean` | `true` | Whether to show the toolbar | +| `editable` | `boolean` | `true` | Whether the editor is editable | +| `enablePersistence` | `boolean` | `false` | Enable localStorage persistence | +| `contentStorageKey` | `string` | `'edra-content'` | localStorage key for content | +| `editableStorageKey` | `string` | `'edra-editable'` | localStorage key for editable state | +| `class` | `string` | Default wrapper classes | CSS classes for wrapper | +| `editorClass` | `string` | `'h-[32rem] overflow-auto'` | CSS classes for editor | +| `onContentChange` | `(content: Content) => void` | `undefined` | Callback when content changes | +| `onEditorReady` | `(editor: Editor) => void` | `undefined` | Callback when editor is ready | +| `onEditorDestroy` | `() => void` | `undefined` | Callback when editor is destroyed | ## Callbacks -| Callback | Type | Description | -|----------|------|-------------| +| Callback | Type | Description | +| ----------------- | ---------------------------- | ---------------------------------- | | `onContentChange` | `(content: Content) => void` | Called when editor content changes | -| `onEditorReady` | `(editor: Editor) => void` | Called when editor is ready | -| `onEditorDestroy` | `() => void` | Called when editor is destroyed | +| `onEditorReady` | `(editor: Editor) => void` | Called when editor is ready | +| `onEditorDestroy` | `() => void` | Called when editor is destroyed | ```svelte - { console.log('New content:', content); @@ -225,7 +216,7 @@ You can also set HTML content programmatically: diff --git a/packages/ui/src/custom/editor/commands/types.ts b/packages/ui/src/custom/editor/commands/types.ts index 2c2225bd..b677b7da 100644 --- a/packages/ui/src/custom/editor/commands/types.ts +++ b/packages/ui/src/custom/editor/commands/types.ts @@ -2,11 +2,11 @@ import type { Editor } from '@tiptap/core'; import { Icon } from '@lucide/svelte'; export interface EdraToolBarCommands { - name: string; - icon: typeof Icon; - tooltip?: string; - shortCut?: string; - onClick?: (editor: Editor) => void; - isActive?: (editor: Editor) => boolean; - clickable?: (editor: Editor) => boolean; + name: string; + icon: typeof Icon; + tooltip?: string; + shortCut?: string; + onClick?: (editor: Editor) => void; + isActive?: (editor: Editor) => boolean; + clickable?: (editor: Editor) => boolean; } diff --git a/packages/ui/src/custom/editor/components/BubbleMenu.svelte b/packages/ui/src/custom/editor/components/BubbleMenu.svelte index 09e96c9f..7f9e55d0 100644 --- a/packages/ui/src/custom/editor/components/BubbleMenu.svelte +++ b/packages/ui/src/custom/editor/components/BubbleMenu.svelte @@ -1,67 +1,66 @@
      - {@render children?.()} + {@render children?.()}
      diff --git a/packages/ui/src/custom/editor/components/MediaPlaceHolder.svelte b/packages/ui/src/custom/editor/components/MediaPlaceHolder.svelte index f830d27e..7f1c5fda 100644 --- a/packages/ui/src/custom/editor/components/MediaPlaceHolder.svelte +++ b/packages/ui/src/custom/editor/components/MediaPlaceHolder.svelte @@ -1,30 +1,30 @@ - {#if !children && icon && title} - {@const Icon = icon} - -
      {title}
      - {/if} - {@render children?.()} + {#if !children && icon && title} + {@const Icon = icon} + +
      {title}
      + {/if} + {@render children?.()}
      diff --git a/packages/ui/src/custom/editor/editor.css b/packages/ui/src/custom/editor/editor.css index 08bad4c2..8904efae 100644 --- a/packages/ui/src/custom/editor/editor.css +++ b/packages/ui/src/custom/editor/editor.css @@ -1,16 +1,16 @@ /* Base TipTap Editor Styles with Light/Dark Theme Support */ .tiptap :first-child { - margin-top: 0; + margin-top: 0; } /* For Placeholder */ .tiptap .is-empty::before { - pointer-events: none; - float: left; - height: 0; - color: var(--border-color-hover); - content: attr(data-placeholder); + pointer-events: none; + float: left; + height: 0; + color: var(--border-color-hover); + content: attr(data-placeholder); } /* Heading Styles */ @@ -20,435 +20,435 @@ .tiptap h4, .tiptap h5, .tiptap h6 { - line-height: 1.2; - margin-top: 1rem; - text-wrap: pretty; + line-height: 1.2; + margin-top: 1rem; + text-wrap: pretty; } .tiptap h1, .tiptap h2 { - margin-top: 1rem; - margin-bottom: 1rem; + margin-top: 1rem; + margin-bottom: 1rem; } .tiptap h1 { - scroll-margin: 5rem; - font-size: 2.25rem; - line-height: 2.5rem; - font-weight: 800; - letter-spacing: -0.015em; + scroll-margin: 5rem; + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 800; + letter-spacing: -0.015em; } .tiptap h2 { - font-size: 1.5rem; - font-weight: 700; + font-size: 1.5rem; + font-weight: 700; } .tiptap h3 { - font-size: 1.25rem; - font-weight: 600; + font-size: 1.25rem; + font-weight: 600; } .tiptap h4, .tiptap h5, .tiptap h6 { - font-size: 1rem; - font-weight: 600; + font-size: 1rem; + font-weight: 600; } .tiptap blockquote { - margin: 1rem 0rem; - padding: 0.5rem 0; - padding-left: 0.75rem; - font-style: italic; - color: var(--blockquote-color); - position: relative; + margin: 1rem 0rem; + padding: 0.5rem 0; + padding-left: 0.75rem; + font-style: italic; + color: var(--blockquote-color); + position: relative; } .tiptap blockquote::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 0.25rem; - background-color: var(--blockquote-border); - border-radius: 0.5rem; + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 0.25rem; + background-color: var(--blockquote-border); + border-radius: 0.5rem; } .tiptap blockquote p { - margin: 0; + margin: 0; } /* Horizontal Rule */ .tiptap hr { - border: none; - border-top: 1px solid var(--border-color); - margin: 1rem 0; + border: none; + border-top: 1px solid var(--border-color); + margin: 1rem 0; } /* Inline Code */ .tiptap code:not(pre code) { - background-color: var(--code-bg); - border-radius: 0.25rem; - padding: 0.2rem 0.3rem; - font-family: monospace; - font-size: 0.875rem; - font-weight: bold; + background-color: var(--code-bg); + border-radius: 0.25rem; + padding: 0.2rem 0.3rem; + font-family: monospace; + font-size: 0.875rem; + font-weight: bold; } /* List Styling */ .tiptap ul, .tiptap ol { - padding: 0 1rem; - margin: 0.5rem 1rem 0.5rem 0.4rem; + padding: 0 1rem; + margin: 0.5rem 1rem 0.5rem 0.4rem; } .tiptap ul li p, .tiptap ol li p { - margin-top: 0.25rem; - margin-bottom: 0.25rem; + margin-top: 0.25rem; + margin-bottom: 0.25rem; } /* Task List Styling */ .tiptap ul[data-type='taskList'] { - list-style: none; - margin: 0; - padding: 0; + list-style: none; + margin: 0; + padding: 0; } .tiptap ul[data-type='taskList'] li { - align-items: flex-start; - display: flex; + align-items: flex-start; + display: flex; } .tiptap ul[data-type='taskList'] li > label { - flex: 0 0 auto; - margin-right: 0.5rem; - user-select: none; + flex: 0 0 auto; + margin-right: 0.5rem; + user-select: none; } .tiptap ul[data-type='taskList'] li > div { - flex: 1 1 auto; + flex: 1 1 auto; } .tiptap ul[data-type='taskList'] input[type='checkbox'] { - cursor: pointer; + cursor: pointer; } .tiptap ul[data-type='taskList'] ul[data-type='taskList'] { - margin: 0; + margin: 0; } ul[data-type='taskList'] li[data-checked='true'] div { - color: var(--task-completed-color); - text-decoration: line-through; + color: var(--task-completed-color); + text-decoration: line-through; } input[type='checkbox'] { - position: relative; - top: 0.25rem; - margin: 0; - display: grid; - place-content: center; - cursor: pointer; - width: 1rem; - height: 1rem; - border-radius: 0.25rem; + position: relative; + top: 0.25rem; + margin: 0; + display: grid; + place-content: center; + cursor: pointer; + width: 1rem; + height: 1rem; + border-radius: 0.25rem; } /* Color Swatches */ .color { - white-space: nowrap; + white-space: nowrap; } .color::before { - margin-bottom: 0.15rem; - margin-right: 0.1rem; - display: inline-block; - width: 1rem; - height: 1rem; - border-radius: 0.25rem; - border: 1px solid var(--border-color); - vertical-align: middle; - background-color: var(--color); - content: ' '; + margin-bottom: 0.15rem; + margin-right: 0.1rem; + display: inline-block; + width: 1rem; + height: 1rem; + border-radius: 0.25rem; + border: 1px solid var(--border-color); + vertical-align: middle; + background-color: var(--color); + content: ' '; } /* Code Block Styling */ .tiptap pre { - margin: 0; - display: flex; - height: fit-content; - overflow: auto; - background-color: transparent; - padding: 0; + margin: 0; + display: flex; + height: fit-content; + overflow: auto; + background-color: transparent; + padding: 0; } .tiptap pre code { - flex: 1; - border-radius: 0 !important; - background-color: transparent; - padding: 0; - color: inherit; - font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono'; + flex: 1; + border-radius: 0 !important; + background-color: transparent; + padding: 0; + color: inherit; + font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono'; } /* Drag Handle Styling */ .drag-handle { - position: fixed; - z-index: 50; - width: 1.5rem; - height: 1.5rem; - display: flex; - padding-right: 0.5rem; - flex-direction: column; - justify-content: center; - align-items: center; - cursor: grab; - opacity: 100; - transition-property: opacity; - transition-duration: 200ms; - transition-timing-function: cubic-bezier(0.4, 0, 1, 1); - color: var(--border-color-hover); + position: fixed; + z-index: 50; + width: 1.5rem; + height: 1.5rem; + display: flex; + padding-right: 0.5rem; + flex-direction: column; + justify-content: center; + align-items: center; + cursor: grab; + opacity: 100; + transition-property: opacity; + transition-duration: 200ms; + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); + color: var(--border-color-hover); } .drag-handle:active { - cursor: grabbing; + cursor: grabbing; } .drag-handle.hide { - opacity: 0; - pointer-events: none; + opacity: 0; + pointer-events: none; } @media screen and (max-width: 600px) { - .drag-handle { - display: none; - pointer-events: none; - } + .drag-handle { + display: none; + pointer-events: none; + } } .drag-handle svg { - height: 1rem; - width: 1rem; + height: 1rem; + width: 1rem; } /* Math Equations (KaTeX) */ .katex:hover { - background-color: var(--code-bg); + background-color: var(--code-bg); } .katex.result { - border-bottom: 1px dashed var(--highlight-border); - background-color: var(--highlight-color); + border-bottom: 1px dashed var(--highlight-border); + background-color: var(--highlight-color); } /* Table Styling */ .ProseMirror .tableWrapper { - margin: 0; - overflow: auto; - padding: 1rem; + margin: 0; + overflow: auto; + padding: 1rem; } .ProseMirror table { - margin-top: 1rem; - margin-bottom: 1rem; - box-sizing: border-box; - width: 100%; - border-collapse: collapse; - border-radius: 0.25rem; - border: 1px solid var(--table-border); + margin-top: 1rem; + margin-bottom: 1rem; + box-sizing: border-box; + width: 100%; + border-collapse: collapse; + border-radius: 0.25rem; + border: 1px solid var(--table-border); } .ProseMirror table td, .ProseMirror table th { - position: relative; - min-width: 100px; - border: 1px solid var(--table-border); - padding: 0.5rem; - text-align: left; - vertical-align: top; + position: relative; + min-width: 100px; + border: 1px solid var(--table-border); + padding: 0.5rem; + text-align: left; + vertical-align: top; } .ProseMirror table td:first-of-type:not(a), .ProseMirror table th:first-of-type:not(a) { - margin-top: 0; + margin-top: 0; } .ProseMirror table td p, .ProseMirror table th p { - margin: 0; + margin: 0; } .ProseMirror table td p + p, .ProseMirror table th p + p { - margin-top: 0.75rem; + margin-top: 0.75rem; } .ProseMirror table th { - font-weight: bold; + font-weight: bold; } .ProseMirror table .column-resize-handle { - pointer-events: none; - position: absolute; - top: 0; - right: -0.25rem; - bottom: -2px; - display: flex; - width: 0.5rem; - background-color: var(--table-border); + pointer-events: none; + position: absolute; + top: 0; + right: -0.25rem; + bottom: -2px; + display: flex; + width: 0.5rem; + background-color: var(--table-border); } .ProseMirror table .column-resize-handle::before { - content: ''; - margin-left: 0.5rem; - height: 100%; - width: 1px; - background-color: var(--table-border); + content: ''; + margin-left: 0.5rem; + height: 100%; + width: 1px; + background-color: var(--table-border); } .ProseMirror table .selectedCell { - border-style: double; - border-color: var(--table-border); - background-color: var(--table-bg-selected); + border-style: double; + border-color: var(--table-border); + background-color: var(--table-bg-selected); } .ProseMirror table .grip-column, .ProseMirror table .grip-row { - position: absolute; - z-index: 10; - display: flex; - cursor: pointer; - align-items: center; - justify-content: center; - background-color: var(--table-bg-selected); + position: absolute; + z-index: 10; + display: flex; + cursor: pointer; + align-items: center; + justify-content: center; + background-color: var(--table-bg-selected); } .ProseMirror table .grip-column { - top: -0.75rem; - left: 0; - margin-left: -1px; - height: 0.75rem; - width: calc(100% + 1px); - border-left: 1px solid var(--table-border); + top: -0.75rem; + left: 0; + margin-left: -1px; + height: 0.75rem; + width: calc(100% + 1px); + border-left: 1px solid var(--table-border); } .ProseMirror table .grip-column:hover::before, .ProseMirror table .grip-column.selected::before { - content: ''; - width: 0.625rem; + content: ''; + width: 0.625rem; } .ProseMirror table .grip-column:hover { - background-color: var(--table-bg-hover); + background-color: var(--table-bg-hover); } .ProseMirror table .grip-column:hover::before { - border-bottom: 2px dotted var(--border-color-hover); + border-bottom: 2px dotted var(--border-color-hover); } .ProseMirror table .grip-column.first { - border-top-left-radius: 0.125rem; - border-color: transparent; + border-top-left-radius: 0.125rem; + border-color: transparent; } .ProseMirror table .grip-column.last { - border-top-right-radius: 0.125rem; + border-top-right-radius: 0.125rem; } .ProseMirror table .grip-column.selected { - border-color: var(--table-border); - background-color: var(--table-bg-hover); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + border-color: var(--table-border); + background-color: var(--table-bg-hover); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } .ProseMirror table .grip-column.selected::before { - border-bottom: 2px dotted var(--border-color-hover); + border-bottom: 2px dotted var(--border-color-hover); } .ProseMirror table .grip-row { - left: -0.75rem; - top: 0; - margin-top: -1px; - height: calc(100% + 1px); - width: 0.75rem; - border-top: 1px solid var(--table-border); + left: -0.75rem; + top: 0; + margin-top: -1px; + height: calc(100% + 1px); + width: 0.75rem; + border-top: 1px solid var(--table-border); } .ProseMirror table .grip-row:hover::before, .ProseMirror table .grip-row.selected::before { - content: ''; - height: 0.625rem; + content: ''; + height: 0.625rem; } .ProseMirror table .grip-row:hover { - background-color: var(--table-bg-hover); + background-color: var(--table-bg-hover); } .ProseMirror table .grip-row:hover::before { - border-left: 2px dotted var(--border-color-hover); + border-left: 2px dotted var(--border-color-hover); } .ProseMirror table .grip-row.first { - border-top-left-radius: 0.125rem; - border-color: transparent; + border-top-left-radius: 0.125rem; + border-color: transparent; } .ProseMirror table .grip-row.last { - border-bottom-left-radius: 0.125rem; + border-bottom-left-radius: 0.125rem; } .ProseMirror table .grip-row.selected { - border-color: var(--table-border); - background-color: var(--table-bg-hover); - box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + border-color: var(--table-border); + background-color: var(--table-bg-hover); + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); } .ProseMirror table .grip-row.selected::before { - border-left: 2px dotted var(--border-color-hover); + border-left: 2px dotted var(--border-color-hover); } .tiptap .search-result { - background-color: var(--search-result-bg); - color: black; + background-color: var(--search-result-bg); + color: black; } .tiptap .search-result-current { - background-color: var(--search-result-current-bg); - color: black; + background-color: var(--search-result-current-bg); + color: black; } .code-wrapper { - background-color: var(--codeblock-bg); - border-radius: 0.5rem; - padding: 1rem; - position: relative; - height: fit-content; - width: 100%; - margin: 0.5rem 0; + background-color: var(--codeblock-bg); + border-radius: 0.5rem; + padding: 1rem; + position: relative; + height: fit-content; + width: 100%; + margin: 0.5rem 0; } .code-wrapper-tile { - opacity: 0; - width: 100%; - display: flex; - position: absolute; - top: 0; - padding: 0.25rem; - right: 0; - align-items: center; - justify-content: space-between; - gap: 1rem; - transition: opacity 0.2s ease-in-out; + opacity: 0; + width: 100%; + display: flex; + position: absolute; + top: 0; + padding: 0.25rem; + right: 0; + align-items: center; + justify-content: space-between; + gap: 1rem; + transition: opacity 0.2s ease-in-out; } .code-wrapper:hover .code-wrapper-tile { - opacity: 1; + opacity: 1; } .tiptap iframe { - aspect-ratio: 16 / 9; + aspect-ratio: 16 / 9; } diff --git a/packages/ui/src/custom/editor/editor.svelte b/packages/ui/src/custom/editor/editor.svelte index a9ab165b..d0500190 100644 --- a/packages/ui/src/custom/editor/editor.svelte +++ b/packages/ui/src/custom/editor/editor.svelte @@ -122,12 +122,17 @@ {#if browser} -
      +
      {#if editor && !editor.isDestroyed} {#if showToolBar}
      @@ -137,7 +142,7 @@ {/if} { - search: { - /** - * @description Set search term in extension. - */ - setSearchTerm: (searchTerm: string) => ReturnType; - /** - * @description Set replace term in extension. - */ - setReplaceTerm: (replaceTerm: string) => ReturnType; - /** - * @description Set case sensitivity in extension. - */ - setCaseSensitive: (caseSensitive: boolean) => ReturnType; - /** - * @description Reset current search result to first instance. - */ - resetIndex: () => ReturnType; - /** - * @description Find next instance of search result. - */ - nextSearchResult: () => ReturnType; - /** - * @description Find previous instance of search result. - */ - previousSearchResult: () => ReturnType; - /** - * @description Replace first instance of search result with given replace term. - */ - replace: () => ReturnType; - /** - * @description Replace all instances of search result with given replace term. - */ - replaceAll: () => ReturnType; - }; - } + interface Commands { + search: { + /** + * @description Set search term in extension. + */ + setSearchTerm: (searchTerm: string) => ReturnType; + /** + * @description Set replace term in extension. + */ + setReplaceTerm: (replaceTerm: string) => ReturnType; + /** + * @description Set case sensitivity in extension. + */ + setCaseSensitive: (caseSensitive: boolean) => ReturnType; + /** + * @description Reset current search result to first instance. + */ + resetIndex: () => ReturnType; + /** + * @description Find next instance of search result. + */ + nextSearchResult: () => ReturnType; + /** + * @description Find previous instance of search result. + */ + previousSearchResult: () => ReturnType; + /** + * @description Replace first instance of search result with given replace term. + */ + replace: () => ReturnType; + /** + * @description Replace all instances of search result with given replace term. + */ + replaceAll: () => ReturnType; + }; + } } interface TextNodesWithPosition { - text: string; - pos: number; + text: string; + pos: number; } const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => { - return RegExp( - disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s, - caseSensitive ? 'gu' : 'gui' - ); + return RegExp(disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s, caseSensitive ? 'gu' : 'gui'); }; interface ProcessedSearches { - decorationsToReturn: DecorationSet; - results: Range[]; + decorationsToReturn: DecorationSet; + results: Range[]; } function processSearches( - doc: PMNode, - searchTerm: RegExp, - searchResultClass: string, - resultIndex: number + doc: PMNode, + searchTerm: RegExp, + searchResultClass: string, + resultIndex: number ): ProcessedSearches { - const decorations: Decoration[] = []; - const results: Range[] = []; + const decorations: Decoration[] = []; + const results: Range[] = []; - let textNodesWithPosition: TextNodesWithPosition[] = []; - let index = 0; + let textNodesWithPosition: TextNodesWithPosition[] = []; + let index = 0; - if (!searchTerm) { - return { - decorationsToReturn: DecorationSet.empty, - results: [] - }; - } + if (!searchTerm) { + return { + decorationsToReturn: DecorationSet.empty, + results: [] + }; + } - doc?.descendants((node, pos) => { - if (node.isText) { - if (textNodesWithPosition[index]) { - textNodesWithPosition[index] = { - text: textNodesWithPosition[index].text + node.text, - pos: textNodesWithPosition[index].pos - }; - } else { - textNodesWithPosition[index] = { - text: `${node.text}`, - pos - }; - } - } else { - index += 1; - } - }); + doc?.descendants((node, pos) => { + if (node.isText) { + if (textNodesWithPosition[index]) { + textNodesWithPosition[index] = { + text: textNodesWithPosition[index].text + node.text, + pos: textNodesWithPosition[index].pos + }; + } else { + textNodesWithPosition[index] = { + text: `${node.text}`, + pos + }; + } + } else { + index += 1; + } + }); - textNodesWithPosition = textNodesWithPosition.filter(Boolean); + textNodesWithPosition = textNodesWithPosition.filter(Boolean); - for (const element of textNodesWithPosition) { - const { text, pos } = element; - const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim()); + for (const element of textNodesWithPosition) { + const { text, pos } = element; + const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim()); - for (const m of matches) { - if (m[0] === '') break; + for (const m of matches) { + if (m[0] === '') break; - if (m.index !== undefined) { - results.push({ - from: pos + m.index, - to: pos + m.index + m[0].length - }); - } - } - } + if (m.index !== undefined) { + results.push({ + from: pos + m.index, + to: pos + m.index + m[0].length + }); + } + } + } - for (let i = 0; i < results.length; i += 1) { - const r = results[i]; - const className = - i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass; - const decoration: Decoration = Decoration.inline(r.from, r.to, { - class: className - }); + for (let i = 0; i < results.length; i += 1) { + const r = results[i]; + const className = i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass; + const decoration: Decoration = Decoration.inline(r.from, r.to, { + class: className + }); - decorations.push(decoration); - } + decorations.push(decoration); + } - return { - decorationsToReturn: DecorationSet.create(doc, decorations), - results - }; + return { + decorationsToReturn: DecorationSet.create(doc, decorations), + results + }; } const replace = ( - replaceTerm: string, - results: Range[], - { state, dispatch }: { state: EditorState; dispatch: Dispatch } + replaceTerm: string, + results: Range[], + { state, dispatch }: { state: EditorState; dispatch: Dispatch } ) => { - const firstResult = results[0]; + const firstResult = results[0]; - if (!firstResult) return; + if (!firstResult) return; - const { from, to } = results[0]; + const { from, to } = results[0]; - if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to)); + if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to)); }; const rebaseNextResult = ( - replaceTerm: string, - index: number, - lastOffset: number, - results: Range[] + replaceTerm: string, + index: number, + lastOffset: number, + results: Range[] ): [number, Range[]] | null => { - const nextIndex = index + 1; + const nextIndex = index + 1; - if (!results[nextIndex]) return null; + if (!results[nextIndex]) return null; - const { from: currentFrom, to: currentTo } = results[index]; + const { from: currentFrom, to: currentTo } = results[index]; - const offset = currentTo - currentFrom - replaceTerm.length + lastOffset; + const offset = currentTo - currentFrom - replaceTerm.length + lastOffset; - const { from, to } = results[nextIndex]; + const { from, to } = results[nextIndex]; - results[nextIndex] = { - to: to - offset, - from: from - offset - }; + results[nextIndex] = { + to: to - offset, + from: from - offset + }; - return [offset, results]; + return [offset, results]; }; const replaceAll = ( - replaceTerm: string, - results: Range[], - { tr, dispatch }: { tr: Transaction; dispatch: Dispatch } + replaceTerm: string, + results: Range[], + { tr, dispatch }: { tr: Transaction; dispatch: Dispatch } ) => { - let offset = 0; + let offset = 0; - let resultsCopy = results.slice(); + let resultsCopy = results.slice(); - if (!resultsCopy.length) return; + if (!resultsCopy.length) return; - for (let i = 0; i < resultsCopy.length; i += 1) { - const { from, to } = resultsCopy[i]; + for (let i = 0; i < resultsCopy.length; i += 1) { + const { from, to } = resultsCopy[i]; - tr.insertText(replaceTerm, from, to); + tr.insertText(replaceTerm, from, to); - const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy); + const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy); - if (!rebaseNextResultResponse) continue; + if (!rebaseNextResultResponse) continue; - offset = rebaseNextResultResponse[0]; - resultsCopy = rebaseNextResultResponse[1]; - } + offset = rebaseNextResultResponse[0]; + resultsCopy = rebaseNextResultResponse[1]; + } - if (dispatch) { - dispatch(tr); - } + if (dispatch) { + dispatch(tr); + } }; export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin'); export interface SearchAndReplaceOptions { - searchResultClass: string; - disableRegex: boolean; + searchResultClass: string; + disableRegex: boolean; } export interface SearchAndReplaceStorage { - searchTerm: string; - replaceTerm: string; - results: Range[]; - lastSearchTerm: string; - caseSensitive: boolean; - lastCaseSensitive: boolean; - resultIndex: number; - lastResultIndex: number; + searchTerm: string; + replaceTerm: string; + results: Range[]; + lastSearchTerm: string; + caseSensitive: boolean; + lastCaseSensitive: boolean; + resultIndex: number; + lastResultIndex: number; } export const SearchAndReplace = Extension.create({ - name: 'searchAndReplace', + name: 'searchAndReplace', - addOptions() { - return { - searchResultClass: 'search-result', - disableRegex: true - }; - }, + addOptions() { + return { + searchResultClass: 'search-result', + disableRegex: true + }; + }, - addStorage() { - return { - searchTerm: '', - replaceTerm: '', - results: [], - lastSearchTerm: '', - caseSensitive: false, - lastCaseSensitive: false, - resultIndex: 0, - lastResultIndex: 0 - }; - }, + addStorage() { + return { + searchTerm: '', + replaceTerm: '', + results: [], + lastSearchTerm: '', + caseSensitive: false, + lastCaseSensitive: false, + resultIndex: 0, + lastResultIndex: 0 + }; + }, - addCommands() { - return { - setSearchTerm: - (searchTerm: string) => - ({ editor }) => { - editor.storage.searchAndReplace.searchTerm = searchTerm; + addCommands() { + return { + setSearchTerm: + (searchTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.searchTerm = searchTerm; - return false; - }, - setReplaceTerm: - (replaceTerm: string) => - ({ editor }) => { - editor.storage.searchAndReplace.replaceTerm = replaceTerm; + return false; + }, + setReplaceTerm: + (replaceTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.replaceTerm = replaceTerm; - return false; - }, - setCaseSensitive: - (caseSensitive: boolean) => - ({ editor }) => { - editor.storage.searchAndReplace.caseSensitive = caseSensitive; + return false; + }, + setCaseSensitive: + (caseSensitive: boolean) => + ({ editor }) => { + editor.storage.searchAndReplace.caseSensitive = caseSensitive; - return false; - }, - resetIndex: - () => - ({ editor }) => { - editor.storage.searchAndReplace.resultIndex = 0; + return false; + }, + resetIndex: + () => + ({ editor }) => { + editor.storage.searchAndReplace.resultIndex = 0; - return false; - }, - nextSearchResult: - () => - ({ editor }) => { - const { results, resultIndex } = editor.storage.searchAndReplace; + return false; + }, + nextSearchResult: + () => + ({ editor }) => { + const { results, resultIndex } = editor.storage.searchAndReplace; - const nextIndex = resultIndex + 1; + const nextIndex = resultIndex + 1; - if (results[nextIndex]) { - editor.storage.searchAndReplace.resultIndex = nextIndex; - } else { - editor.storage.searchAndReplace.resultIndex = 0; - } + if (results[nextIndex]) { + editor.storage.searchAndReplace.resultIndex = nextIndex; + } else { + editor.storage.searchAndReplace.resultIndex = 0; + } - return false; - }, - previousSearchResult: - () => - ({ editor }) => { - const { results, resultIndex } = editor.storage.searchAndReplace; + return false; + }, + previousSearchResult: + () => + ({ editor }) => { + const { results, resultIndex } = editor.storage.searchAndReplace; - const prevIndex = resultIndex - 1; + const prevIndex = resultIndex - 1; - if (results[prevIndex]) { - editor.storage.searchAndReplace.resultIndex = prevIndex; - } else { - editor.storage.searchAndReplace.resultIndex = results.length - 1; - } + if (results[prevIndex]) { + editor.storage.searchAndReplace.resultIndex = prevIndex; + } else { + editor.storage.searchAndReplace.resultIndex = results.length - 1; + } - return false; - }, - replace: - () => - ({ editor, state, dispatch }) => { - const { replaceTerm, results } = editor.storage.searchAndReplace; + return false; + }, + replace: + () => + ({ editor, state, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; - replace(replaceTerm, results, { state, dispatch }); + replace(replaceTerm, results, { state, dispatch }); - return false; - }, - replaceAll: - () => - ({ editor, tr, dispatch }) => { - const { replaceTerm, results } = editor.storage.searchAndReplace; + return false; + }, + replaceAll: + () => + ({ editor, tr, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; - replaceAll(replaceTerm, results, { tr, dispatch }); + replaceAll(replaceTerm, results, { tr, dispatch }); - return false; - } - }; - }, + return false; + } + }; + }, - addProseMirrorPlugins() { - const editor = this.editor; - const { searchResultClass, disableRegex } = this.options; + addProseMirrorPlugins() { + const editor = this.editor; + const { searchResultClass, disableRegex } = this.options; - const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t); - const setLastCaseSensitive = (t: boolean) => - (editor.storage.searchAndReplace.lastCaseSensitive = t); - const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t); + const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t); + const setLastCaseSensitive = (t: boolean) => (editor.storage.searchAndReplace.lastCaseSensitive = t); + const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t); - return [ - new Plugin({ - key: searchAndReplacePluginKey, - state: { - init: () => DecorationSet.empty, - apply({ doc, docChanged }, oldState) { - const { - searchTerm, - lastSearchTerm, - caseSensitive, - lastCaseSensitive, - resultIndex, - lastResultIndex - } = editor.storage.searchAndReplace; + return [ + new Plugin({ + key: searchAndReplacePluginKey, + state: { + init: () => DecorationSet.empty, + apply({ doc, docChanged }, oldState) { + const { searchTerm, lastSearchTerm, caseSensitive, lastCaseSensitive, resultIndex, lastResultIndex } = + editor.storage.searchAndReplace; - if ( - !docChanged && - lastSearchTerm === searchTerm && - lastCaseSensitive === caseSensitive && - lastResultIndex === resultIndex - ) - return oldState; + if ( + !docChanged && + lastSearchTerm === searchTerm && + lastCaseSensitive === caseSensitive && + lastResultIndex === resultIndex + ) + return oldState; - setLastSearchTerm(searchTerm); - setLastCaseSensitive(caseSensitive); - setLastResultIndex(resultIndex); + setLastSearchTerm(searchTerm); + setLastCaseSensitive(caseSensitive); + setLastResultIndex(resultIndex); - if (!searchTerm) { - editor.storage.searchAndReplace.results = []; - return DecorationSet.empty; - } + if (!searchTerm) { + editor.storage.searchAndReplace.results = []; + return DecorationSet.empty; + } - const { decorationsToReturn, results } = processSearches( - doc, - getRegex(searchTerm, disableRegex, caseSensitive), - searchResultClass, - resultIndex - ); + const { decorationsToReturn, results } = processSearches( + doc, + getRegex(searchTerm, disableRegex, caseSensitive), + searchResultClass, + resultIndex + ); - editor.storage.searchAndReplace.results = results; + editor.storage.searchAndReplace.results = results; - return decorationsToReturn; - } - }, - props: { - decorations(state) { - return this.getState(state); - } - } - }) - ]; - } + return decorationsToReturn; + } + }, + props: { + decorations(state) { + return this.getState(state); + } + } + }) + ]; + } }); export default SearchAndReplace; diff --git a/packages/ui/src/custom/editor/extensions/audio/AudioExtension.ts b/packages/ui/src/custom/editor/extensions/audio/AudioExtension.ts index 5e2da234..0fb5d404 100644 --- a/packages/ui/src/custom/editor/extensions/audio/AudioExtension.ts +++ b/packages/ui/src/custom/editor/extensions/audio/AudioExtension.ts @@ -2,146 +2,137 @@ import { Node, nodeInputRule } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; export interface AudioOptions { - HTMLAttributes: Record; + HTMLAttributes: Record; } declare module '@tiptap/core' { - interface Commands { - audio: { - /** - * Set a audio node - */ - setAudio: (src: string) => ReturnType; - /** - * Toggle a audio - */ - toggleAudio: (src: string) => ReturnType; - /** - * Remove a audio - */ - removeAudio: () => ReturnType; - }; - } + interface Commands { + audio: { + /** + * Set a audio node + */ + setAudio: (src: string) => ReturnType; + /** + * Toggle a audio + */ + toggleAudio: (src: string) => ReturnType; + /** + * Remove a audio + */ + removeAudio: () => ReturnType; + }; + } } const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/; export const Audio = Node.create({ - name: 'audio', - group: 'block', - draggable: true, - isolating: true, - atom: true, - addOptions() { - return { - HTMLAttributes: {} - }; - }, - addAttributes() { - return { - src: { - default: null, - parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'), - renderHTML: (attrs) => ({ src: attrs.src }) - } - }; - }, - parseHTML() { - return [ - { - tag: 'audio', - getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') }) - } - ]; - }, + name: 'audio', + group: 'block', + draggable: true, + isolating: true, + atom: true, + addOptions() { + return { + HTMLAttributes: {} + }; + }, + addAttributes() { + return { + src: { + default: null, + parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'), + renderHTML: (attrs) => ({ src: attrs.src }) + } + }; + }, + parseHTML() { + return [ + { + tag: 'audio', + getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') }) + } + ]; + }, - renderHTML({ HTMLAttributes }) { - return [ - 'audio', - { controls: 'true', style: 'width: 100%;', ...HTMLAttributes }, - ['source', HTMLAttributes] - ]; - }, - addCommands() { - return { - setAudio: - (src: string) => - ({ commands }) => - commands.insertContent( - `` - ), + renderHTML({ HTMLAttributes }) { + return ['audio', { controls: 'true', style: 'width: 100%;', ...HTMLAttributes }, ['source', HTMLAttributes]]; + }, + addCommands() { + return { + setAudio: + (src: string) => + ({ commands }) => + commands.insertContent(``), - toggleAudio: - () => - ({ commands }) => - commands.toggleNode(this.name, 'paragraph'), - removeAudio: - () => - ({ commands }) => - commands.deleteNode(this.name) - }; - }, - addInputRules() { - return [ - nodeInputRule({ - find: AUDIO_INPUT_REGEX, - type: this.type, - getAttributes: (match) => { - const [, , src] = match; + toggleAudio: + () => + ({ commands }) => + commands.toggleNode(this.name, 'paragraph'), + removeAudio: + () => + ({ commands }) => + commands.deleteNode(this.name) + }; + }, + addInputRules() { + return [ + nodeInputRule({ + find: AUDIO_INPUT_REGEX, + type: this.type, + getAttributes: (match) => { + const [, , src] = match; - return { src }; - } - }) - ]; - }, - addProseMirrorPlugins() { - return [ - new Plugin({ - key: new PluginKey('audioDropPlugin'), + return { src }; + } + }) + ]; + }, + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey('audioDropPlugin'), - props: { - handleDOMEvents: { - drop(view, event) { - const { - state: { schema, tr }, - dispatch - } = view; - const hasFiles = - event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length; + props: { + handleDOMEvents: { + drop(view, event) { + const { + state: { schema, tr }, + dispatch + } = view; + const hasFiles = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length; - if (!hasFiles) return false; + if (!hasFiles) return false; - const audios = Array.from(event.dataTransfer.files).filter((file) => - /audio/i.test(file.type) - ); + const audios = Array.from(event.dataTransfer.files).filter((file) => /audio/i.test(file.type)); - if (audios.length === 0) return false; + if (audios.length === 0) return false; - event.preventDefault(); + event.preventDefault(); - const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); + const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY }); - audios.forEach((audio) => { - const reader = new FileReader(); + audios.forEach((audio) => { + const reader = new FileReader(); - reader.onload = (readerEvent) => { - const node = schema.nodes.audio.create({ src: readerEvent.target?.result }); + reader.onload = (readerEvent) => { + const node = schema.nodes.audio.create({ src: readerEvent.target?.result }); - if (coordinates && typeof coordinates.pos === 'number') { - const transaction = tr.insert(coordinates?.pos, node); + if (coordinates && typeof coordinates.pos === 'number') { + const transaction = tr.insert(coordinates?.pos, node); - dispatch(transaction); - } - }; + dispatch(transaction); + } + }; - reader.readAsDataURL(audio); - }); + reader.readAsDataURL(audio); + }); - return true; - } - } - } - }) - ]; - } + return true; + } + } + } + }) + ]; + } }); diff --git a/packages/ui/src/custom/editor/extensions/audio/AudioPlaceholder.ts b/packages/ui/src/custom/editor/extensions/audio/AudioPlaceholder.ts index ca7b2b3d..29dcd625 100644 --- a/packages/ui/src/custom/editor/extensions/audio/AudioPlaceholder.ts +++ b/packages/ui/src/custom/editor/extensions/audio/AudioPlaceholder.ts @@ -3,62 +3,60 @@ import type { Component } from 'svelte'; import { SvelteNodeViewRenderer } from 'svelte-tiptap'; export interface AudioPlaceholderOptions { - HTMLAttributes: Record; - onDrop: (files: File[], editor: Editor) => void; - onDropRejected?: (files: File[], editor: Editor) => void; - onEmbed: (url: string, editor: Editor) => void; - allowedMimeTypes?: Record; - maxFiles?: number; - maxSize?: number; + HTMLAttributes: Record; + onDrop: (files: File[], editor: Editor) => void; + onDropRejected?: (files: File[], editor: Editor) => void; + onEmbed: (url: string, editor: Editor) => void; + allowedMimeTypes?: Record; + maxFiles?: number; + maxSize?: number; } declare module '@tiptap/core' { - interface Commands { - audioPlaceholder: { - /** - * Inserts an audio placeholder - */ - insertAudioPlaceholder: () => ReturnType; - }; - } + interface Commands { + audioPlaceholder: { + /** + * Inserts an audio placeholder + */ + insertAudioPlaceholder: () => ReturnType; + }; + } } -export const AudioPlaceholder = ( - component: Component -): Node => - Node.create({ - name: 'audio-placeholder', - addOptions() { - return { - HTMLAttributes: {}, - onDrop: () => {}, - onDropRejected: () => {}, - onEmbed: () => {} - }; - }, - parseHTML() { - return [{ tag: `div[data-type="${this.name}"]` }]; - }, +export const AudioPlaceholder = (component: Component): Node => + Node.create({ + name: 'audio-placeholder', + addOptions() { + return { + HTMLAttributes: {}, + onDrop: () => {}, + onDropRejected: () => {}, + onEmbed: () => {} + }; + }, + parseHTML() { + return [{ tag: `div[data-type="${this.name}"]` }]; + }, - renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(HTMLAttributes)]; - }, - group: 'block', - draggable: true, - atom: true, - content: 'inline*', - isolating: true, + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes)]; + }, + group: 'block', + draggable: true, + atom: true, + content: 'inline*', + isolating: true, - addNodeView() { - return SvelteNodeViewRenderer(component); - }, - addCommands() { - return { - insertAudioPlaceholder: () => (props: CommandProps) => { - return props.commands.insertContent({ - type: 'audio-placeholder' - }); - } - }; - } - }); + addNodeView() { + return SvelteNodeViewRenderer(component); + }, + addCommands() { + return { + insertAudioPlaceholder: () => (props: CommandProps) => { + return props.commands.insertContent({ + type: 'audio-placeholder' + }); + } + }; + } + }); diff --git a/packages/ui/src/custom/editor/extensions/drag-handle/ClipboardSerializer.ts b/packages/ui/src/custom/editor/extensions/drag-handle/ClipboardSerializer.ts index e7c8648e..21e2392d 100644 --- a/packages/ui/src/custom/editor/extensions/drag-handle/ClipboardSerializer.ts +++ b/packages/ui/src/custom/editor/extensions/drag-handle/ClipboardSerializer.ts @@ -3,26 +3,26 @@ import { EditorView } from '@tiptap/pm/view'; import * as pmView from '@tiptap/pm/view'; function getPmView() { - try { - return pmView; - } catch (error) { - console.error(error); - return null; - } + try { + return pmView; + } catch (error) { + console.error(error); + return null; + } } export function serializeForClipboard(view: EditorView, slice: Slice) { - // Newer Tiptap/ProseMirror - if (view && typeof view.serializeForClipboard === 'function') { - return view.serializeForClipboard(slice); - } + // Newer Tiptap/ProseMirror + if (view && typeof view.serializeForClipboard === 'function') { + return view.serializeForClipboard(slice); + } - // Older version fallback - const proseMirrorView = getPmView(); + // Older version fallback + const proseMirrorView = getPmView(); - if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') { - return proseMirrorView.__serializeForClipboard(view, slice); - } + if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') { + return proseMirrorView.__serializeForClipboard(view, slice); + } - throw new Error('No supported clipboard serialization method found.'); + throw new Error('No supported clipboard serialization method found.'); } diff --git a/packages/ui/src/custom/editor/extensions/iframe/IFrame.ts b/packages/ui/src/custom/editor/extensions/iframe/IFrame.ts index bf91ece9..eb138b03 100644 --- a/packages/ui/src/custom/editor/extensions/iframe/IFrame.ts +++ b/packages/ui/src/custom/editor/extensions/iframe/IFrame.ts @@ -1,85 +1,85 @@ import { Node } from '@tiptap/core'; export interface IframeOptions { - allowFullscreen: boolean; - HTMLAttributes: { - [key: string]: unknown; - }; + allowFullscreen: boolean; + HTMLAttributes: { + [key: string]: unknown; + }; } declare module '@tiptap/core' { - interface Commands { - iframe: { - /** - * Add an iframe with src - */ - setIframe: (options: { src: string }) => ReturnType; - removeIframe: () => ReturnType; - }; - } + interface Commands { + iframe: { + /** + * Add an iframe with src + */ + setIframe: (options: { src: string }) => ReturnType; + removeIframe: () => ReturnType; + }; + } } export default Node.create({ - name: 'iframe', + name: 'iframe', - group: 'block', + group: 'block', - atom: true, + atom: true, - addOptions() { - return { - allowFullscreen: true, - HTMLAttributes: { - class: 'iframe-wrapper' - } - }; - }, + addOptions() { + return { + allowFullscreen: true, + HTMLAttributes: { + class: 'iframe-wrapper' + } + }; + }, - addAttributes() { - return { - src: { - default: null - }, - frameborder: { - default: 0 - }, - allowfullscreen: { - default: this.options.allowFullscreen, - parseHTML: () => this.options.allowFullscreen - } - }; - }, + addAttributes() { + return { + src: { + default: null + }, + frameborder: { + default: 0 + }, + allowfullscreen: { + default: this.options.allowFullscreen, + parseHTML: () => this.options.allowFullscreen + } + }; + }, - parseHTML() { - return [ - { - tag: 'iframe' - } - ]; - }, + parseHTML() { + return [ + { + tag: 'iframe' + } + ]; + }, - renderHTML({ HTMLAttributes }) { - return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]; - }, + renderHTML({ HTMLAttributes }) { + return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]]; + }, - addCommands() { - return { - setIframe: - (options: { src: string }) => - ({ tr, dispatch }) => { - const { selection } = tr; - const node = this.type.create(options); + addCommands() { + return { + setIframe: + (options: { src: string }) => + ({ tr, dispatch }) => { + const { selection } = tr; + const node = this.type.create(options); - if (dispatch) { - tr.replaceRangeWith(selection.from, selection.to, node); - } + if (dispatch) { + tr.replaceRangeWith(selection.from, selection.to, node); + } - return true; - }, - removeIframe: - () => - ({ commands }) => - commands.deleteNode(this.name) - }; - } + return true; + }, + removeIframe: + () => + ({ commands }) => + commands.deleteNode(this.name) + }; + } }); diff --git a/packages/ui/src/custom/editor/extensions/iframe/IFramePlaceholder.ts b/packages/ui/src/custom/editor/extensions/iframe/IFramePlaceholder.ts index 96ba8bfd..da6caa61 100644 --- a/packages/ui/src/custom/editor/extensions/iframe/IFramePlaceholder.ts +++ b/packages/ui/src/custom/editor/extensions/iframe/IFramePlaceholder.ts @@ -3,54 +3,54 @@ import type { Component } from 'svelte'; import { SvelteNodeViewRenderer } from 'svelte-tiptap'; export interface IFramePlaceholderOptions { - HTMLAttributes: Record; + HTMLAttributes: Record; } declare module '@tiptap/core' { - interface Commands { - iframePlaceholder: { - /** - * Inserts a IFrame placeholder - */ - insertIFramePlaceholder: () => ReturnType; - }; - } + interface Commands { + iframePlaceholder: { + /** + * Inserts a IFrame placeholder + */ + insertIFramePlaceholder: () => ReturnType; + }; + } } export const IFramePlaceholder = (content: Component) => - Node.create({ - name: 'iframe-placeholder', - addOptions() { - return { - HTMLAttributes: {}, - onDrop: () => {}, - onDropRejected: () => {}, - onEmbed: () => {} - }; - }, - parseHTML() { - return [{ tag: `div[data-type="${this.name}"]` }]; - }, + Node.create({ + name: 'iframe-placeholder', + addOptions() { + return { + HTMLAttributes: {}, + onDrop: () => {}, + onDropRejected: () => {}, + onEmbed: () => {} + }; + }, + parseHTML() { + return [{ tag: `div[data-type="${this.name}"]` }]; + }, - renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(HTMLAttributes)]; - }, - group: 'block', - draggable: true, - atom: true, - content: 'inline*', - isolating: true, + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes)]; + }, + group: 'block', + draggable: true, + atom: true, + content: 'inline*', + isolating: true, - addNodeView() { - return SvelteNodeViewRenderer(content); - }, - addCommands() { - return { - insertIFramePlaceholder: () => (props: CommandProps) => { - return props.commands.insertContent({ - type: 'iframe-placeholder' - }); - } - }; - } - }); + addNodeView() { + return SvelteNodeViewRenderer(content); + }, + addCommands() { + return { + insertIFramePlaceholder: () => (props: CommandProps) => { + return props.commands.insertContent({ + type: 'iframe-placeholder' + }); + } + }; + } + }); diff --git a/packages/ui/src/custom/editor/extensions/image/ImageExtended.ts b/packages/ui/src/custom/editor/extensions/image/ImageExtended.ts index e5430956..768bdede 100644 --- a/packages/ui/src/custom/editor/extensions/image/ImageExtended.ts +++ b/packages/ui/src/custom/editor/extensions/image/ImageExtended.ts @@ -4,33 +4,33 @@ import type { Component } from 'svelte'; import type { NodeViewProps, Node } from '@tiptap/core'; export const ImageExtended = (component: Component): Node => { - return Image.extend({ - addAttributes() { - return { - src: { - default: null - }, - alt: { - default: null - }, - title: { - default: null - }, - width: { - default: '100%' - }, - height: { - default: null - }, - align: { - default: 'left' - } - }; - }, - addNodeView: () => { - return SvelteNodeViewRenderer(component); - } - }).configure({ - allowBase64: true - }); + return Image.extend({ + addAttributes() { + return { + src: { + default: null + }, + alt: { + default: null + }, + title: { + default: null + }, + width: { + default: '100%' + }, + height: { + default: null + }, + align: { + default: 'left' + } + }; + }, + addNodeView: () => { + return SvelteNodeViewRenderer(component); + } + }).configure({ + allowBase64: true + }); }; diff --git a/packages/ui/src/custom/editor/extensions/image/ImagePlaceholder.ts b/packages/ui/src/custom/editor/extensions/image/ImagePlaceholder.ts index b031abf0..d02a90b3 100644 --- a/packages/ui/src/custom/editor/extensions/image/ImagePlaceholder.ts +++ b/packages/ui/src/custom/editor/extensions/image/ImagePlaceholder.ts @@ -3,62 +3,60 @@ import type { Component } from 'svelte'; import { SvelteNodeViewRenderer } from 'svelte-tiptap'; export interface ImagePlaceholderOptions { - HTMLAttributes: Record; - onDrop: (files: File[], editor: Editor) => void; - onDropRejected?: (files: File[], editor: Editor) => void; - onEmbed: (url: string, editor: Editor) => void; - allowedMimeTypes?: Record; - maxFiles?: number; - maxSize?: number; + HTMLAttributes: Record; + onDrop: (files: File[], editor: Editor) => void; + onDropRejected?: (files: File[], editor: Editor) => void; + onEmbed: (url: string, editor: Editor) => void; + allowedMimeTypes?: Record; + maxFiles?: number; + maxSize?: number; } declare module '@tiptap/core' { - interface Commands { - imagePlaceholder: { - /** - * Inserts an image placeholder - */ - insertImagePlaceholder: () => ReturnType; - }; - } + interface Commands { + imagePlaceholder: { + /** + * Inserts an image placeholder + */ + insertImagePlaceholder: () => ReturnType; + }; + } } -export const ImagePlaceholder = ( - component: Component -): Node => - Node.create({ - name: 'image-placeholder', - addOptions() { - return { - HTMLAttributes: {}, - onDrop: () => {}, - onDropRejected: () => {}, - onEmbed: () => {} - }; - }, - parseHTML() { - return [{ tag: `div[data-type="${this.name}"]` }]; - }, +export const ImagePlaceholder = (component: Component): Node => + Node.create({ + name: 'image-placeholder', + addOptions() { + return { + HTMLAttributes: {}, + onDrop: () => {}, + onDropRejected: () => {}, + onEmbed: () => {} + }; + }, + parseHTML() { + return [{ tag: `div[data-type="${this.name}"]` }]; + }, - renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes(HTMLAttributes)]; - }, - group: 'block', - draggable: true, - atom: true, - content: 'inline*', - isolating: true, + renderHTML({ HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes)]; + }, + group: 'block', + draggable: true, + atom: true, + content: 'inline*', + isolating: true, - addNodeView() { - return SvelteNodeViewRenderer(component); - }, - addCommands() { - return { - insertImagePlaceholder: () => (props: CommandProps) => { - return props.commands.insertContent({ - type: 'image-placeholder' - }); - } - }; - } - }); + addNodeView() { + return SvelteNodeViewRenderer(component); + }, + addCommands() { + return { + insertImagePlaceholder: () => (props: CommandProps) => { + return props.commands.insertContent({ + type: 'image-placeholder' + }); + } + }; + } + }); diff --git a/packages/ui/src/custom/editor/extensions/table/table-row.ts b/packages/ui/src/custom/editor/extensions/table/table-row.ts index 63cc1e1d..412b52ae 100644 --- a/packages/ui/src/custom/editor/extensions/table/table-row.ts +++ b/packages/ui/src/custom/editor/extensions/table/table-row.ts @@ -1,8 +1,8 @@ import { TableRow as TiptapTableRow } from '@tiptap/extension-table'; export const TableRow = TiptapTableRow.extend({ - allowGapCursor: false, - content: '(tableCell | tableHeader)*' + allowGapCursor: false, + content: '(tableCell | tableHeader)*' }); export default TableRow; diff --git a/packages/ui/src/custom/editor/extensions/table/table.ts b/packages/ui/src/custom/editor/extensions/table/table.ts index e5f825df..4f955c74 100644 --- a/packages/ui/src/custom/editor/extensions/table/table.ts +++ b/packages/ui/src/custom/editor/extensions/table/table.ts @@ -1,9 +1,9 @@ import { Table as TiptapTable } from '@tiptap/extension-table'; export const Table = TiptapTable.configure({ - resizable: true, - lastColumnResizable: true, - allowTableNodeSelection: true + resizable: true, + lastColumnResizable: true, + allowTableNodeSelection: true }); export default Table; diff --git a/packages/ui/src/custom/editor/extensions/video/VideoExtension.ts b/packages/ui/src/custom/editor/extensions/video/VideoExtension.ts index f3b9b2b7..7f5adb6d 100644 --- a/packages/ui/src/custom/editor/extensions/video/VideoExtension.ts +++ b/packages/ui/src/custom/editor/extensions/video/VideoExtension.ts @@ -2,146 +2,137 @@ import { Node, nodeInputRule } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; export interface VideoOptions { - HTMLAttributes: Record; + HTMLAttributes: Record; } declare module '@tiptap/core' { - interface Commands { - video: { - /** - * Set a video node - */ - setVideo: (src: string) => ReturnType; - /** - * Toggle a video - */ - toggleVideo: (src: string) => ReturnType; - /** - * Remove a video - */ - removeVideo: () => ReturnType; - }; - } + interface Commands { + video: { + /** + * Set a video node + */ + setVideo: (src: string) => ReturnType; + /** + * Toggle a video + */ + toggleVideo: (src: string) => ReturnType; + /** + * Remove a video + */ + removeVideo: () => ReturnType; + }; + } } const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/; export const Video = Node.create({ - name: 'video', - group: 'block', - content: 'inline*', - draggable: true, - isolating: true, - addOptions() { - return { - HTMLAttributes: {} - }; - }, - addAttributes() { - return { - src: { - default: null, - parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'), - renderHTML: (attrs) => ({ src: attrs.src }) - } - }; - }, - parseHTML() { - return [ - { - tag: 'video', - getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') }) - } - ]; - }, + name: 'video', + group: 'block', + content: 'inline*', + draggable: true, + isolating: true, + addOptions() { + return { + HTMLAttributes: {} + }; + }, + addAttributes() { + return { + src: { + default: null, + parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'), + renderHTML: (attrs) => ({ src: attrs.src }) + } + }; + }, + parseHTML() { + return [ + { + tag: 'video', + getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') }) + } + ]; + }, - renderHTML({ HTMLAttributes }) { - return [ - 'video', - { controls: 'true', style: 'width: fit-content;', ...HTMLAttributes }, - ['source', HTMLAttributes] - ]; - }, - addCommands() { - return { - setVideo: - (src: string) => - ({ commands }) => - commands.insertContent( - `
      diff --git a/packages/ui/src/custom/editor/ui/components/EdraToolTip.svelte b/packages/ui/src/custom/editor/ui/components/EdraToolTip.svelte index 2ba252d5..6d5b48b5 100644 --- a/packages/ui/src/custom/editor/ui/components/EdraToolTip.svelte +++ b/packages/ui/src/custom/editor/ui/components/EdraToolTip.svelte @@ -17,7 +17,7 @@ {tooltip} - {shortCut} + {shortCut} diff --git a/packages/ui/src/custom/editor/ui/components/IFrameExtended.svelte b/packages/ui/src/custom/editor/ui/components/IFrameExtended.svelte index 6a25b198..d37f3aa6 100644 --- a/packages/ui/src/custom/editor/ui/components/IFrameExtended.svelte +++ b/packages/ui/src/custom/editor/ui/components/IFrameExtended.svelte @@ -1,13 +1,13 @@ - {@const node = rest.node} - + {@const node = rest.node} + diff --git a/packages/ui/src/custom/editor/ui/components/ImageExtended.svelte b/packages/ui/src/custom/editor/ui/components/ImageExtended.svelte index 6a0f2d1b..4f15dbeb 100644 --- a/packages/ui/src/custom/editor/ui/components/ImageExtended.svelte +++ b/packages/ui/src/custom/editor/ui/components/ImageExtended.svelte @@ -1,19 +1,19 @@ - {@const node = rest.node} - {node.attrs.alt} + {@const node = rest.node} + {node.attrs.alt} diff --git a/packages/ui/src/custom/editor/ui/components/MediaExtended.svelte b/packages/ui/src/custom/editor/ui/components/MediaExtended.svelte index 22bc2f84..43c13c06 100644 --- a/packages/ui/src/custom/editor/ui/components/MediaExtended.svelte +++ b/packages/ui/src/custom/editor/ui/components/MediaExtended.svelte @@ -141,7 +141,7 @@ -
      +
      {@render children()} {#if editor.isEditable} @@ -157,7 +157,7 @@ role="button" tabindex="0" aria-label="Back" - class="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-start p-2" + class="ui:absolute ui:inset-y-0 ui:z-20 ui:flex ui:w-[25px] ui:cursor-col-resize ui:items-center ui:justify-start ui:p-2" style="left: 0px" onmousedown={(event: MouseEvent) => { handleResizingPosition(event, 'left'); @@ -167,7 +167,7 @@ }} >
      @@ -175,7 +175,7 @@ role="button" tabindex="0" aria-label="Back" - class="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-end p-2" + class="ui:absolute ui:inset-y-0 ui:z-20 ui:flex ui:w-[25px] ui:cursor-col-resize ui:items-center ui:justify-end ui:p-2" style="right: 0px" onmousedown={(event: MouseEvent) => { handleResizingPosition(event, 'right'); @@ -185,48 +185,48 @@ }} >
      (openedMore = value)}> - + - + { if (node.attrs.title === null || node.attrs.title.trim() === '') @@ -235,14 +235,14 @@ }); }} > - Caption + Caption { duplicateContent(editor, node); }} > - Duplicate + Duplicate { @@ -251,15 +251,15 @@ }); }} > - Full Screen + Full Screen { deleteNode(); }} - class="text-destructive" + class="ui:text-destructive" > - Delete + Delete diff --git a/packages/ui/src/custom/editor/ui/components/SlashCommandList.svelte b/packages/ui/src/custom/editor/ui/components/SlashCommandList.svelte index 14e1135c..2a87caa4 100644 --- a/packages/ui/src/custom/editor/ui/components/SlashCommandList.svelte +++ b/packages/ui/src/custom/editor/ui/components/SlashCommandList.svelte @@ -96,10 +96,10 @@ {#if items.length}
      {#each items as grp, groupIndex (groupIndex)} - {grp.title} + {grp.title} {#each grp.commands as command, commandIndex (commandIndex)} {@const Icon = command.icon} @@ -107,7 +107,7 @@
      diff --git a/packages/ui/src/custom/editor/ui/components/toolbar/FontSize.svelte b/packages/ui/src/custom/editor/ui/components/toolbar/FontSize.svelte index 1ba7f14c..3e268053 100644 --- a/packages/ui/src/custom/editor/ui/components/toolbar/FontSize.svelte +++ b/packages/ui/src/custom/editor/ui/components/toolbar/FontSize.svelte @@ -34,13 +34,13 @@ - - + {#each FONT_SIZE as fontSize (fontSize)} { diff --git a/packages/ui/src/custom/editor/ui/components/toolbar/Headings.svelte b/packages/ui/src/custom/editor/ui/components/toolbar/Headings.svelte index 44122c56..46a8614c 100644 --- a/packages/ui/src/custom/editor/ui/components/toolbar/Headings.svelte +++ b/packages/ui/src/custom/editor/ui/components/toolbar/Headings.svelte @@ -34,12 +34,12 @@ class={buttonVariants({ variant: 'ghost', size: 'icon', - class: cn('gap-0') + class: cn('ui:gap-0') })} class:bg-muted={isActive} > - +
      diff --git a/packages/ui/src/custom/editor/ui/components/toolbar/QuickColors.svelte b/packages/ui/src/custom/editor/ui/components/toolbar/QuickColors.svelte index a86e33e2..43620da6 100644 --- a/packages/ui/src/custom/editor/ui/components/toolbar/QuickColors.svelte +++ b/packages/ui/src/custom/editor/ui/components/toolbar/QuickColors.svelte @@ -36,18 +36,18 @@ class={buttonVariants({ variant: 'ghost', size: 'icon', - class: cn('gap-0.5', className) + class: cn('ui:gap-0.5', className) })} style={`color: ${currentColor}; background-color: ${currentHighlight}50;`} > A - +
      - -
      Text Colors
      -
      + +
      Text Colors
      +
      {#each colors as color (color)}
      -
      Highlight Colors
      -
      +
      Highlight Colors
      +
      {#each colors as color (color)} - + -
      -
      - updateSearchTerm()} class="w-40" /> - {searchCount > 0 ? searchIndex + 1 : 0}/{searchCount} +
      +
      + updateSearchTerm()} class="ui:w-40" /> + {searchCount > 0 ? searchIndex + 1 : 0}/{searchCount}
      {#if showMore} -
      - updateSearchTerm()} class="w-40" /> +
      + updateSearchTerm()} class="ui:w-40" /> @@ -95,7 +95,7 @@ Copy to clipboard - + Delete Node diff --git a/packages/ui/src/custom/editor/ui/editor.svelte b/packages/ui/src/custom/editor/ui/editor.svelte index 9245f2ce..c82e3161 100644 --- a/packages/ui/src/custom/editor/ui/editor.svelte +++ b/packages/ui/src/custom/editor/ui/editor.svelte @@ -109,5 +109,5 @@ focusEditor(editor, event); } }} - class={cn('edra-editor h-full w-full cursor-auto px-4 *:outline-none', className)} + class={cn('edra-editor ui:h-full ui:w-full ui:cursor-auto ui:px-4 *:outline-none', className)} >
      diff --git a/packages/ui/src/custom/editor/ui/menus/Link.svelte b/packages/ui/src/custom/editor/ui/menus/Link.svelte index 3711b275..268b0913 100644 --- a/packages/ui/src/custom/editor/ui/menus/Link.svelte +++ b/packages/ui/src/custom/editor/ui/menus/Link.svelte @@ -25,10 +25,10 @@ options={{ strategy: 'fixed' }} - class="bg-popover flex h-fit w-fit items-center gap-1 rounded border p-1 shadow-lg" + class="ui:bg-popover ui:flex ui:h-fit ui:w-fit ui:items-center ui:gap-1 ui:rounded ui:border ui:p-1 ui:shadow-lg" > - @@ -36,7 +36,7 @@ variant="ghost" title="Copy Link" size="icon" - class="z-50" + class="ui:z-50" onclick={() => { navigator.clipboard.writeText(link); }} diff --git a/packages/ui/src/custom/editor/ui/menus/Menu.svelte b/packages/ui/src/custom/editor/ui/menus/Menu.svelte index 2be83a4e..bdf36a67 100644 --- a/packages/ui/src/custom/editor/ui/menus/Menu.svelte +++ b/packages/ui/src/custom/editor/ui/menus/Menu.svelte @@ -90,7 +90,10 @@ {editor} pluginKey="link-bubble-menu" {shouldShow} - class={cn('edra-bubble-menu bg-popover flex h-fit w-fit items-center rounded-lg p-0.5', className)} + class={cn( + 'edra-bubble-menu ui:bg-popover ui:flex ui:h-fit ui:w-fit ui:items-center ui:rounded-lg ui:p-0.5', + className + )} > {#if children} {@render children()} diff --git a/packages/ui/src/custom/editor/ui/menus/TableCol.svelte b/packages/ui/src/custom/editor/ui/menus/TableCol.svelte index 19ea9a3e..85d8cf62 100644 --- a/packages/ui/src/custom/editor/ui/menus/TableCol.svelte +++ b/packages/ui/src/custom/editor/ui/menus/TableCol.svelte @@ -32,7 +32,7 @@ from: props.from }); }} - class="bg-background flex h-fit w-fit items-center gap-1 rounded border shadow-lg" + class="ui:bg-background ui:flex ui:h-fit ui:w-fit ui:items-center ui:gap-1 ui:rounded ui:border ui:shadow-lg" >