create-ej-app 25.0.0 → 27.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +125 -226
- package/cli.ts +83 -28
- package/package.json +2 -2
- package/template/api/.vscode/settings.json +1 -1
- package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
- package/template/api/CLAUDE.md +1 -0
- package/template/api/package.json +10 -10
- package/template/nextjs/.dockerignore +1 -0
- package/template/nextjs/AGENTS.md +260 -0
- package/template/nextjs/CLAUDE.md +1 -0
- package/template/nextjs/README.md +109 -0
- package/template/nextjs/bunfig.toml +3 -0
- package/template/nextjs/eslint.config.mjs +6 -5
- package/template/nextjs/gitignore +6 -0
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +47 -40
- package/template/nextjs/public/logo-dark.svg +4 -0
- package/template/nextjs/public/logo.svg +4 -0
- package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
- package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
- package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
- package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
- package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
- package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
- package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
- package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
- package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
- package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
- package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
- package/template/nextjs/src/app/error.tsx +37 -0
- package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/index.ts +60 -0
- package/template/nextjs/src/app/global-error.tsx +38 -0
- package/template/nextjs/src/app/globals.css +29 -0
- package/template/nextjs/src/app/layout.tsx +45 -18
- package/template/nextjs/src/app/not-found.tsx +25 -3
- package/template/nextjs/src/app/robots.ts +29 -0
- package/template/nextjs/src/app/sitemap.ts +21 -0
- package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
- package/template/nextjs/src/components/dashboard-header.tsx +70 -0
- package/template/nextjs/src/components/data-table/enum.ts +5 -0
- package/template/nextjs/src/components/data-table/features.ts +49 -0
- package/template/nextjs/src/components/data-table/index.tsx +961 -0
- package/template/nextjs/src/components/data-table/types.ts +76 -0
- package/template/nextjs/src/components/error.tsx +71 -0
- package/template/nextjs/src/components/loader.tsx +18 -2
- package/template/nextjs/src/components/logo.tsx +49 -0
- package/template/nextjs/src/components/navigation-search.tsx +140 -0
- package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
- package/template/nextjs/src/components/page-heading.tsx +38 -0
- package/template/nextjs/src/components/react-query-provider.tsx +41 -2
- package/template/nextjs/src/components/route-error.tsx +55 -0
- package/template/nextjs/src/components/screen-size.tsx +36 -0
- package/template/nextjs/src/components/site-footer.tsx +20 -4
- package/template/nextjs/src/components/theme-provider.tsx +48 -0
- package/template/nextjs/src/components/theme-toggle.tsx +58 -5
- package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
- package/template/nextjs/src/components/ui/avatar.tsx +112 -0
- package/template/nextjs/src/components/ui/badge.tsx +54 -0
- package/template/nextjs/src/components/ui/button.tsx +3 -1
- package/template/nextjs/src/components/ui/card.tsx +4 -4
- package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
- package/template/nextjs/src/components/ui/command.tsx +192 -0
- package/template/nextjs/src/components/ui/dialog.tsx +164 -0
- package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/nextjs/src/components/ui/input-group.tsx +156 -0
- package/template/nextjs/src/components/ui/select.tsx +195 -0
- package/template/nextjs/src/components/ui/sheet.tsx +143 -0
- package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
- package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
- package/template/nextjs/src/components/ui/table.tsx +116 -0
- package/template/nextjs/src/components/ui/textarea.tsx +18 -0
- package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
- package/template/nextjs/src/components/user-menu.tsx +206 -0
- package/template/nextjs/src/db/common.ts +10 -0
- package/template/nextjs/src/db/index.ts +16 -6
- package/template/nextjs/src/db/relations.ts +21 -0
- package/template/nextjs/src/db/schema.ts +85 -46
- package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
- package/template/nextjs/src/emails/magic-link.tsx +15 -13
- package/template/nextjs/src/emails/test.tsx +11 -9
- package/template/nextjs/src/emails/welcome.tsx +12 -9
- package/template/nextjs/src/env.ts +1 -1
- package/template/nextjs/src/hooks/use-mobile.ts +14 -12
- package/template/nextjs/src/instrumentation.ts +20 -2
- package/template/nextjs/src/lib/actions.ts +55 -0
- package/template/nextjs/src/lib/admin-navigation.ts +43 -0
- package/template/nextjs/src/lib/auth-client.ts +20 -9
- package/template/nextjs/src/lib/auth-permissions.ts +61 -0
- package/template/nextjs/src/lib/auth.ts +33 -3
- package/template/nextjs/src/lib/callback-url.ts +28 -0
- package/template/nextjs/src/lib/constants.ts +21 -0
- package/template/nextjs/src/lib/delay.ts +10 -0
- package/template/nextjs/src/lib/email-service.ts +21 -0
- package/template/nextjs/src/lib/errors.ts +74 -0
- package/template/nextjs/src/lib/format-currency.ts +22 -24
- package/template/nextjs/src/lib/format-date.ts +69 -6
- package/template/nextjs/src/lib/nodemailer.ts +27 -3
- package/template/nextjs/src/lib/otel.ts +92 -2
- package/template/nextjs/src/lib/safe-action-client.ts +60 -9
- package/template/nextjs/src/lib/safe-action.ts +152 -16
- package/template/nextjs/src/lib/search.ts +16 -0
- package/template/nextjs/src/lib/seo.ts +7 -0
- package/template/nextjs/src/lib/siteConfig.ts +17 -1
- package/template/nextjs/src/lib/toast-message.ts +87 -1
- package/template/nextjs/src/lib/validations.ts +30 -0
- package/template/nextjs/src/proxy.ts +48 -0
- package/template/nextjs/src/scripts/reset-db.ts +22 -0
- package/template/nextjs/src/scripts/seed.ts +59 -8
- package/template/nextjs/src/scripts/test-mail.ts +28 -20
- package/template/nextjs/tests/admin/users.test.ts +64 -0
- package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
- package/template/nextjs/tests/db/seed.test.ts +49 -0
- package/template/nextjs/tests/helpers/action.ts +71 -0
- package/template/nextjs/tests/helpers/fixtures.ts +78 -0
- package/template/nextjs/tests/helpers/index.ts +2 -0
- package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
- package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/nextjs/tests/lib/errors.test.ts +10 -0
- package/template/nextjs/tests/lib/search.test.ts +23 -0
- package/template/nextjs/tests/setup/pglite-db.ts +63 -0
- package/template/nextjs/tests/setup/preload.ts +95 -0
- package/template/nextjs/tsconfig.json +1 -0
- package/template/saas-starter/.dockerignore +8 -0
- package/template/saas-starter/.env.example +25 -0
- package/template/saas-starter/.vscode/extensions.json +11 -0
- package/template/saas-starter/.vscode/settings.json +3 -0
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/Dockerfile +15 -0
- package/template/saas-starter/README.md +125 -0
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/components.json +25 -0
- package/template/saas-starter/drizzle.config.ts +10 -0
- package/template/saas-starter/eslint.config.mjs +24 -0
- package/template/saas-starter/gitignore +14 -0
- package/template/saas-starter/next.config.ts +32 -0
- package/template/saas-starter/package.json +97 -0
- package/template/saas-starter/postcss.config.mjs +7 -0
- package/template/saas-starter/prettier.config.mjs +11 -0
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/public/placeholder.svg +1 -0
- package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
- package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +183 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
- package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +60 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +216 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +189 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +31 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +92 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +357 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +130 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +222 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +115 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +168 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +604 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +33 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +55 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +31 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +26 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +30 -0
- package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +37 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +200 -0
- package/template/saas-starter/src/app/(app)/app/layout.tsx +64 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +117 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +40 -0
- package/template/saas-starter/src/app/(app)/app/page.tsx +76 -0
- package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +131 -0
- package/template/saas-starter/src/app/(auth)/login/page.tsx +105 -0
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +137 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +94 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +153 -0
- package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +48 -0
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +24 -0
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +63 -0
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +41 -0
- package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
- package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
- package/template/saas-starter/src/app/actions/uploads.ts +36 -0
- package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
- package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/favicon.ico +0 -0
- package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/index.ts +60 -0
- package/template/saas-starter/src/app/global-error.tsx +38 -0
- package/template/saas-starter/src/app/globals.css +269 -0
- package/template/saas-starter/src/app/layout.tsx +77 -0
- package/template/saas-starter/src/app/not-found.tsx +49 -0
- package/template/saas-starter/src/app/robots.ts +36 -0
- package/template/saas-starter/src/app/sitemap.ts +21 -0
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +70 -0
- package/template/saas-starter/src/components/data-table/enum.ts +5 -0
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +961 -0
- package/template/saas-starter/src/components/data-table/types.ts +76 -0
- package/template/saas-starter/src/components/error.tsx +71 -0
- package/template/saas-starter/src/components/file-upload.tsx +254 -0
- package/template/saas-starter/src/components/loader.tsx +41 -0
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +140 -0
- package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
- package/template/saas-starter/src/components/page-heading.tsx +38 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +51 -0
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +76 -0
- package/template/saas-starter/src/components/site-footer.tsx +39 -0
- package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
- package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
- package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
- package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
- package/template/saas-starter/src/components/theme-provider.tsx +115 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +75 -0
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
- package/template/saas-starter/src/components/ui/badge.tsx +54 -0
- package/template/saas-starter/src/components/ui/button.tsx +69 -0
- package/template/saas-starter/src/components/ui/card.tsx +103 -0
- package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
- package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
- package/template/saas-starter/src/components/ui/command.tsx +192 -0
- package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
- package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/saas-starter/src/components/ui/field.tsx +238 -0
- package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
- package/template/saas-starter/src/components/ui/input.tsx +19 -0
- package/template/saas-starter/src/components/ui/label.tsx +24 -0
- package/template/saas-starter/src/components/ui/popover.tsx +89 -0
- package/template/saas-starter/src/components/ui/select.tsx +195 -0
- package/template/saas-starter/src/components/ui/separator.tsx +28 -0
- package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
- package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
- package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
- package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
- package/template/saas-starter/src/components/ui/switch.tsx +33 -0
- package/template/saas-starter/src/components/ui/table.tsx +116 -0
- package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
- package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
- package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
- package/template/saas-starter/src/components/user-menu.tsx +213 -0
- package/template/saas-starter/src/db/common.ts +63 -0
- package/template/saas-starter/src/db/enums.ts +50 -0
- package/template/saas-starter/src/db/index.ts +60 -0
- package/template/saas-starter/src/db/relations.ts +112 -0
- package/template/saas-starter/src/db/schema.ts +342 -0
- package/template/saas-starter/src/emails/components/email-layout.tsx +79 -0
- package/template/saas-starter/src/emails/invitation.tsx +69 -0
- package/template/saas-starter/src/emails/magic-link.tsx +51 -0
- package/template/saas-starter/src/emails/test.tsx +34 -0
- package/template/saas-starter/src/emails/welcome.tsx +37 -0
- package/template/saas-starter/src/env.ts +66 -0
- package/template/saas-starter/src/hooks/use-mobile.ts +21 -0
- package/template/saas-starter/src/instrumentation.ts +44 -0
- package/template/saas-starter/src/lib/actions.ts +55 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +63 -0
- package/template/saas-starter/src/lib/app-navigation.ts +63 -0
- package/template/saas-starter/src/lib/auth-client.ts +34 -0
- package/template/saas-starter/src/lib/auth-permissions.ts +61 -0
- package/template/saas-starter/src/lib/auth.ts +312 -0
- package/template/saas-starter/src/lib/billing.ts +152 -0
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/cn.ts +4 -0
- package/template/saas-starter/src/lib/constants.ts +91 -0
- package/template/saas-starter/src/lib/delay.ts +12 -0
- package/template/saas-starter/src/lib/email-service.ts +118 -0
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +25 -0
- package/template/saas-starter/src/lib/format-date.ts +75 -0
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +67 -0
- package/template/saas-starter/src/lib/organization-access.ts +73 -0
- package/template/saas-starter/src/lib/otel.ts +117 -0
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +90 -0
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +126 -0
- package/template/saas-starter/src/lib/safe-action.ts +187 -0
- package/template/saas-starter/src/lib/search.ts +16 -0
- package/template/saas-starter/src/lib/seo.ts +7 -0
- package/template/saas-starter/src/lib/siteConfig.ts +20 -0
- package/template/saas-starter/src/lib/storage.ts +311 -0
- package/template/saas-starter/src/lib/stripe.ts +9 -0
- package/template/saas-starter/src/lib/toast-message.ts +101 -0
- package/template/saas-starter/src/lib/validations.ts +95 -0
- package/template/saas-starter/src/proxy.ts +48 -0
- package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
- package/template/saas-starter/src/scripts/reset-db.ts +22 -0
- package/template/saas-starter/src/scripts/seed.ts +85 -0
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +206 -0
- package/template/saas-starter/src/scripts/test-mail.ts +43 -0
- package/template/saas-starter/tests/admin/users.test.ts +64 -0
- package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
- package/template/saas-starter/tests/db/seed.test.ts +57 -0
- package/template/saas-starter/tests/helpers/action.ts +71 -0
- package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
- package/template/saas-starter/tests/helpers/index.ts +2 -0
- package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
- package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/saas-starter/tests/lib/errors.test.ts +10 -0
- package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
- package/template/saas-starter/tests/lib/search.test.ts +23 -0
- package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
- package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
- package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
- package/template/saas-starter/tests/setup/preload.ts +108 -0
- package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
- package/template/saas-starter/tsconfig.json +52 -0
- package/cli.js +0 -143
- package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
- package/template/nextjs/src/app/(app)/layout.tsx +0 -20
- package/template/nextjs/terraform/artifact-registry.tf +0 -5
- package/template/nextjs/terraform/backend-service.tf +0 -13
- package/template/nextjs/terraform/cleanup.sh +0 -9
- package/template/nextjs/terraform/cloudbuild.tf +0 -70
- package/template/nextjs/terraform/cloudrun.tf +0 -68
- package/template/nextjs/terraform/database.tf +0 -60
- package/template/nextjs/terraform/forwarding-rule.tf +0 -43
- package/template/nextjs/terraform/gitignore +0 -4
- package/template/nextjs/terraform/ip-address.tf +0 -11
- package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
- package/template/nextjs/terraform/network.tf +0 -21
- package/template/nextjs/terraform/pre.sh +0 -3
- package/template/nextjs/terraform/providers.tf +0 -18
- package/template/nextjs/terraform/secret.txt +0 -11
- package/template/nextjs/terraform/setup.sh +0 -25
- package/template/nextjs/terraform/ssl-certs.tf +0 -32
- package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
- package/template/nextjs/terraform/target-https-proxy.tf +0 -14
- package/template/nextjs/terraform/terraform.tfvars +0 -4
- package/template/nextjs/terraform/url-map.tf +0 -23
- package/template/nextjs/terraform/variables.tf +0 -21
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RowData, SortingState } from '@tanstack/react-table'
|
|
2
|
+
import { SortOrderEnum } from './enum'
|
|
3
|
+
import type { DataTableColumnDef } from './features'
|
|
4
|
+
|
|
5
|
+
/** A single selectable option inside a filter dropdown. */
|
|
6
|
+
interface DataTableFilterOption {
|
|
7
|
+
label: string
|
|
8
|
+
value: string | boolean
|
|
9
|
+
/** Optional icon rendered next to the option label. */
|
|
10
|
+
icon?: React.ComponentType<{ className?: string }>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Describes a filter that appears in the DataTable toolbar.
|
|
15
|
+
* Filters can either be static (pre-defined `options`) or async
|
|
16
|
+
* (fetched via a server `action` on search).
|
|
17
|
+
*/
|
|
18
|
+
export interface DataTableFilter {
|
|
19
|
+
/** Unique key used to store the filter's selected values. */
|
|
20
|
+
id: string
|
|
21
|
+
/** Human-readable label shown in the filter dropdown trigger. */
|
|
22
|
+
label: string
|
|
23
|
+
/** Pre-defined options for static filters. Omit for async filters. */
|
|
24
|
+
options?: DataTableFilterOption[]
|
|
25
|
+
/** Server action called to fetch options dynamically (async filters). */
|
|
26
|
+
action?: any
|
|
27
|
+
/** Custom React-Query key for async filter caching. Defaults to `id`. */
|
|
28
|
+
queryKey?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Props accepted by the generic `DataTable` component.
|
|
33
|
+
*
|
|
34
|
+
* Supports both client-side and server-side (manual) pagination & sorting.
|
|
35
|
+
* When `manualPagination` / `manualSorting` are enabled the parent is
|
|
36
|
+
* responsible for fetching data and passing results back via `data` & `totalCount`.
|
|
37
|
+
*/
|
|
38
|
+
export interface DataTableProps<TData extends RowData> {
|
|
39
|
+
/** Column definitions (TanStack Table v9, typed to DataTable features). */
|
|
40
|
+
columns: DataTableColumnDef<TData, any>[]
|
|
41
|
+
/** Row data to display. */
|
|
42
|
+
data?: TData[]
|
|
43
|
+
/** Show skeleton loading rows while data is being fetched. */
|
|
44
|
+
isLoading?: boolean
|
|
45
|
+
/** Render the search input in the toolbar. */
|
|
46
|
+
enableSearch?: boolean
|
|
47
|
+
/** Placeholder text for the search input. */
|
|
48
|
+
searchPlaceholder?: string
|
|
49
|
+
/** Initial value for the search input (e.g. restored from URL query params). */
|
|
50
|
+
initialSearch?: string
|
|
51
|
+
/** Total row count for server-side pagination info. */
|
|
52
|
+
totalCount?: number
|
|
53
|
+
/** Callback fired when pagination, sorting, search or filters change. */
|
|
54
|
+
onParamsChange?: (params: {
|
|
55
|
+
page: number
|
|
56
|
+
limit: number
|
|
57
|
+
sortBy?: string
|
|
58
|
+
sortOrder?: SortOrderEnum
|
|
59
|
+
search?: string
|
|
60
|
+
filters?: Record<string, (string | boolean)[]>
|
|
61
|
+
}) => void
|
|
62
|
+
/** Enable server-side pagination (parent controls data fetching). */
|
|
63
|
+
manualPagination?: boolean
|
|
64
|
+
/** Enable server-side sorting (parent controls data fetching). */
|
|
65
|
+
manualSorting?: boolean
|
|
66
|
+
/** Current 0-based page index (controlled). */
|
|
67
|
+
pageIndex?: number
|
|
68
|
+
/** Number of rows per page (controlled). */
|
|
69
|
+
pageSize?: number
|
|
70
|
+
/** Default sorting state applied on mount. */
|
|
71
|
+
initialSorting?: SortingState
|
|
72
|
+
/** Filter definitions rendered in the toolbar filter dropdown. */
|
|
73
|
+
filters?: DataTableFilter[]
|
|
74
|
+
/** Currently active filter selections keyed by filter id. */
|
|
75
|
+
activeFilters?: Record<string, (string | boolean)[]>
|
|
76
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Reusable Error Display Component
|
|
3
|
+
*
|
|
4
|
+
* Generic error/empty state component for displaying user-friendly
|
|
5
|
+
* error messages with optional icon and action button.
|
|
6
|
+
*
|
|
7
|
+
* **Use Cases:**
|
|
8
|
+
* - Empty search results
|
|
9
|
+
* - Data fetch failures
|
|
10
|
+
* - Access denied states
|
|
11
|
+
* - 404-like content not found
|
|
12
|
+
*
|
|
13
|
+
* @module components/error
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { type LucideIcon, AlertTriangle } from 'lucide-react'
|
|
17
|
+
import Link from 'next/link'
|
|
18
|
+
import { FC } from 'react'
|
|
19
|
+
import { Button } from '~/components/ui/button'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Error Display Component
|
|
23
|
+
*
|
|
24
|
+
* Renders a centered error state with icon, message, and action.
|
|
25
|
+
*
|
|
26
|
+
* **Layout:**
|
|
27
|
+
* - Large icon (customizable)
|
|
28
|
+
* - Bold title
|
|
29
|
+
* - Descriptive message
|
|
30
|
+
* - Action button (customizable link)
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} props - Component props
|
|
33
|
+
* @param {string} props.title - Error heading text
|
|
34
|
+
* @param {string} props.message - Descriptive error message
|
|
35
|
+
* @param {LucideIcon} [props.icon] - Custom icon (defaults to AlertTriangle)
|
|
36
|
+
* @param {Object} [props.link] - Optional action button configuration
|
|
37
|
+
* @param {string} [props.link.href] - Button link destination (defaults to '/')
|
|
38
|
+
* @param {string} [props.link.text] - Button text (defaults to 'Browse Products')
|
|
39
|
+
* @returns {JSX.Element} Centered error display
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```tsx
|
|
43
|
+
* <ErrorDisplay
|
|
44
|
+
* title="No Results Found"
|
|
45
|
+
* message="Try adjusting your search or filters"
|
|
46
|
+
* icon={Search}
|
|
47
|
+
* link={{ href: '/search', text: 'Clear Filters' }}
|
|
48
|
+
* />
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export const ErrorDisplay: FC<{
|
|
52
|
+
title: string
|
|
53
|
+
message: string
|
|
54
|
+
icon?: LucideIcon
|
|
55
|
+
link?: {
|
|
56
|
+
href?: string
|
|
57
|
+
text?: string
|
|
58
|
+
}
|
|
59
|
+
}> = ({ title, message, icon, link }) => {
|
|
60
|
+
const Icon = icon || AlertTriangle
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex w-full flex-col items-center justify-center py-16">
|
|
63
|
+
<Icon className="text-muted-foreground/50 size-16" />
|
|
64
|
+
<h2 className="mt-4 text-xl font-semibold">{title}</h2>
|
|
65
|
+
<p className="text-muted-foreground mt-2">{message}</p>
|
|
66
|
+
<Button className="mt-4" asChild>
|
|
67
|
+
<Link href={link?.href || '/'}>{link?.text || 'Go Home'}</Link>
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { File, FileText, Loader2, Music, Upload, Video, X } from 'lucide-react'
|
|
4
|
+
import Image from 'next/image'
|
|
5
|
+
import { useRef, useState } from 'react'
|
|
6
|
+
import { generateUploadUrlAction } from '~/app/actions/uploads'
|
|
7
|
+
import { Button } from '~/components/ui/button'
|
|
8
|
+
import { cn } from '~/lib/cn'
|
|
9
|
+
import {
|
|
10
|
+
MAX_FILE_SIZE_BYTES,
|
|
11
|
+
MAX_FILE_SIZE_MB,
|
|
12
|
+
R2_UPLOAD_EXTENSION_BY_MIME_TYPE,
|
|
13
|
+
type R2UploadMimeType
|
|
14
|
+
} from '~/lib/constants'
|
|
15
|
+
import { toastErrorMessage, toastIfActionFailed } from '~/lib/toast-message'
|
|
16
|
+
|
|
17
|
+
function isAllowedMimeType(type: string): type is R2UploadMimeType {
|
|
18
|
+
return type in R2_UPLOAD_EXTENSION_BY_MIME_TYPE
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readFileAsDataUrl(file: File): Promise<string> {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const reader = new FileReader()
|
|
24
|
+
reader.onload = () => {
|
|
25
|
+
if (typeof reader.result === 'string') {
|
|
26
|
+
resolve(reader.result)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
reject(new Error('Failed to read file'))
|
|
30
|
+
}
|
|
31
|
+
reader.onerror = () => {
|
|
32
|
+
reject(reader.error ?? new Error('Failed to read file'))
|
|
33
|
+
}
|
|
34
|
+
reader.readAsDataURL(file)
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Discriminated union so the preview renderer knows exactly what to show
|
|
39
|
+
// without guessing from a raw URL string.
|
|
40
|
+
type PreviewState =
|
|
41
|
+
| { kind: 'image'; url: string }
|
|
42
|
+
| { kind: 'file'; name: string; mimeType: string }
|
|
43
|
+
| null
|
|
44
|
+
|
|
45
|
+
function getInitialPreview(currentUrl?: string | null): PreviewState {
|
|
46
|
+
if (!currentUrl) return null
|
|
47
|
+
// base64 data URLs and image paths/URLs both render as images
|
|
48
|
+
return { kind: 'image', url: currentUrl }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function FileTypeIcon({ mimeType }: { mimeType: string }) {
|
|
52
|
+
if (mimeType.startsWith('video/'))
|
|
53
|
+
return <Video className="text-muted-foreground h-8 w-8" />
|
|
54
|
+
if (mimeType.startsWith('audio/'))
|
|
55
|
+
return <Music className="text-muted-foreground h-8 w-8" />
|
|
56
|
+
if (mimeType === 'application/pdf' || mimeType.includes('text'))
|
|
57
|
+
return <FileText className="text-muted-foreground h-8 w-8" />
|
|
58
|
+
return <File className="text-muted-foreground h-8 w-8" />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type Props = {
|
|
62
|
+
/** MIME type filter passed to the file input, e.g. `"image/*"` or `"image/*,application/pdf"`. */
|
|
63
|
+
accept: string
|
|
64
|
+
/** Organization that owns the upload; the server checks you manage it. */
|
|
65
|
+
organizationId: string
|
|
66
|
+
/**
|
|
67
|
+
* Called with the R2 object key (prod) or base64 data URL (dev mode) once
|
|
68
|
+
* the upload finishes. Called with `null` when the user clears the value.
|
|
69
|
+
*/
|
|
70
|
+
onClientUploadFinish: (key: string | null) => void
|
|
71
|
+
/**
|
|
72
|
+
* Pre-resolved URL for the current value. Pass the output of
|
|
73
|
+
* `resolveImageUrl` from the parent server component so the preview renders
|
|
74
|
+
* correctly for existing R2 keys.
|
|
75
|
+
*/
|
|
76
|
+
currentUrl?: string | null
|
|
77
|
+
className?: string
|
|
78
|
+
/** Passed to next/image `sizes`. It should match the rendered widget width. */
|
|
79
|
+
sizes?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function FileUpload({
|
|
83
|
+
accept,
|
|
84
|
+
organizationId,
|
|
85
|
+
onClientUploadFinish,
|
|
86
|
+
currentUrl,
|
|
87
|
+
className,
|
|
88
|
+
sizes = '128px'
|
|
89
|
+
}: Props) {
|
|
90
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
91
|
+
const [preview, setPreview] = useState<PreviewState>(
|
|
92
|
+
getInitialPreview(currentUrl)
|
|
93
|
+
)
|
|
94
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
95
|
+
|
|
96
|
+
const handleFile = async (file: File) => {
|
|
97
|
+
if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
98
|
+
toastErrorMessage(`Choose a file under ${MAX_FILE_SIZE_MB} MB.`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!isAllowedMimeType(file.type)) {
|
|
103
|
+
toastErrorMessage('Choose a JPEG, PNG, WebP, or GIF image.')
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setIsUploading(true)
|
|
108
|
+
|
|
109
|
+
// Set the preview from the file type without a network round trip.
|
|
110
|
+
if (file.type.startsWith('image/')) {
|
|
111
|
+
setPreview({ kind: 'image', url: URL.createObjectURL(file) })
|
|
112
|
+
} else {
|
|
113
|
+
setPreview({ kind: 'file', name: file.name, mimeType: file.type })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const result = await generateUploadUrlAction({
|
|
117
|
+
filename: file.name,
|
|
118
|
+
mimeType: file.type,
|
|
119
|
+
size: file.size,
|
|
120
|
+
organizationId
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
if (toastIfActionFailed(result)) {
|
|
124
|
+
setPreview(getInitialPreview(currentUrl))
|
|
125
|
+
setIsUploading(false)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (!result?.data) {
|
|
129
|
+
toastErrorMessage('The upload could not start. Try again.')
|
|
130
|
+
setPreview(getInitialPreview(currentUrl))
|
|
131
|
+
setIsUploading(false)
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { data } = result
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
if (data.mode === 'r2') {
|
|
139
|
+
const res = await fetch(data.uploadUrl, {
|
|
140
|
+
method: 'PUT',
|
|
141
|
+
body: file,
|
|
142
|
+
headers: { 'Content-Type': file.type }
|
|
143
|
+
})
|
|
144
|
+
if (!res.ok) throw new Error(`R2 upload failed: ${res.status}`)
|
|
145
|
+
onClientUploadFinish(data.key)
|
|
146
|
+
} else {
|
|
147
|
+
// Development mode has no R2 credentials, so store base64 data directly.
|
|
148
|
+
// Await the read so isUploading stays true until onClientUploadFinish runs.
|
|
149
|
+
const base64 = await readFileAsDataUrl(file)
|
|
150
|
+
if (file.type.startsWith('image/')) {
|
|
151
|
+
setPreview({ kind: 'image', url: base64 })
|
|
152
|
+
}
|
|
153
|
+
onClientUploadFinish(base64)
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
toastErrorMessage(
|
|
157
|
+
data.mode === 'r2'
|
|
158
|
+
? 'The upload failed. Check your connection and try again.'
|
|
159
|
+
: 'The file could not be read. Choose it again.'
|
|
160
|
+
)
|
|
161
|
+
setPreview(getInitialPreview(currentUrl))
|
|
162
|
+
} finally {
|
|
163
|
+
setIsUploading(false)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const handleClear = (e: React.MouseEvent) => {
|
|
168
|
+
e.stopPropagation()
|
|
169
|
+
setPreview(null)
|
|
170
|
+
onClientUploadFinish(null)
|
|
171
|
+
if (inputRef.current) inputRef.current.value = ''
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className={cn('relative', className)}>
|
|
176
|
+
<input
|
|
177
|
+
ref={inputRef}
|
|
178
|
+
type="file"
|
|
179
|
+
accept={accept}
|
|
180
|
+
className="hidden"
|
|
181
|
+
onChange={e => {
|
|
182
|
+
const file = e.target.files?.[0]
|
|
183
|
+
if (file) handleFile(file)
|
|
184
|
+
e.target.value = ''
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={() => !isUploading && inputRef.current?.click()}
|
|
191
|
+
disabled={isUploading}
|
|
192
|
+
className={cn(
|
|
193
|
+
'border-border bg-muted/30 hover:bg-muted/50 relative flex aspect-square w-full cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-dashed transition-colors',
|
|
194
|
+
isUploading && 'cursor-not-allowed'
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
{preview?.kind === 'image' && (
|
|
198
|
+
<Image
|
|
199
|
+
src={preview.url}
|
|
200
|
+
alt="Upload preview"
|
|
201
|
+
fill
|
|
202
|
+
sizes={sizes}
|
|
203
|
+
className="object-contain"
|
|
204
|
+
unoptimized={
|
|
205
|
+
preview.url.startsWith('data:') || preview.url.startsWith('blob:')
|
|
206
|
+
}
|
|
207
|
+
/>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{preview?.kind === 'file' && (
|
|
211
|
+
<div className="flex flex-col items-center gap-2 px-4">
|
|
212
|
+
<FileTypeIcon mimeType={preview.mimeType} />
|
|
213
|
+
<span className="text-muted-foreground max-w-full truncate text-xs">
|
|
214
|
+
{preview.name}
|
|
215
|
+
</span>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{!preview && (
|
|
220
|
+
<div className="flex flex-col items-center gap-2">
|
|
221
|
+
<div className="bg-muted rounded-md p-2">
|
|
222
|
+
<Upload className="text-muted-foreground h-5 w-5" />
|
|
223
|
+
</div>
|
|
224
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
225
|
+
Click to upload
|
|
226
|
+
</span>
|
|
227
|
+
<span className="text-muted-foreground/60 text-[11px]">
|
|
228
|
+
Max {MAX_FILE_SIZE_MB} MB
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
{isUploading && (
|
|
234
|
+
<div className="bg-background/70 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
|
|
235
|
+
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
|
236
|
+
</div>
|
|
237
|
+
)}
|
|
238
|
+
</button>
|
|
239
|
+
|
|
240
|
+
{preview && !isUploading && (
|
|
241
|
+
<Button
|
|
242
|
+
type="button"
|
|
243
|
+
variant="destructive"
|
|
244
|
+
size="icon"
|
|
245
|
+
className="absolute -top-2 -right-2 h-6 w-6 rounded-full shadow-sm"
|
|
246
|
+
onClick={handleClear}
|
|
247
|
+
aria-label="Remove file"
|
|
248
|
+
>
|
|
249
|
+
<X className="h-3 w-3" />
|
|
250
|
+
</Button>
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Page Transition Loader Component
|
|
3
|
+
*
|
|
4
|
+
* Top progress bar shown during navigation, using the theme primary color.
|
|
5
|
+
*
|
|
6
|
+
* @module components/loader
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
'use client'
|
|
10
|
+
|
|
11
|
+
import NextTopLoader from 'nextjs-toploader'
|
|
12
|
+
|
|
13
|
+
import { FC } from 'react'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Page Loader Component
|
|
17
|
+
*
|
|
18
|
+
* Renders the NextTopLoader with preset configuration.
|
|
19
|
+
* Should be included once near the root layout.
|
|
20
|
+
*
|
|
21
|
+
* @returns {JSX.Element} Top loading bar component
|
|
22
|
+
*/
|
|
23
|
+
export const Loader: FC = () => {
|
|
24
|
+
return (
|
|
25
|
+
<NextTopLoader
|
|
26
|
+
color="var(--primary)"
|
|
27
|
+
initialPosition={0.08}
|
|
28
|
+
crawlSpeed={200}
|
|
29
|
+
height={3}
|
|
30
|
+
crawl={true}
|
|
31
|
+
showSpinner={true}
|
|
32
|
+
easing="ease"
|
|
33
|
+
speed={200}
|
|
34
|
+
shadow="0 0 10px var(--primary), 0 0 5px var(--primary)"
|
|
35
|
+
template='<div class="bar" role="bar"><div class="peg"></div></div>
|
|
36
|
+
<div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'
|
|
37
|
+
zIndex={10000}
|
|
38
|
+
showAtBottom={false}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Image from 'next/image'
|
|
2
|
+
import { cn } from '~/lib/cn'
|
|
3
|
+
import { siteConfig } from '~/lib/siteConfig'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Brand mark, with the wordmark beside it unless `showName` is false.
|
|
7
|
+
*
|
|
8
|
+
* Two SVG files rather than one: the mark uses two colors, so `currentColor`
|
|
9
|
+
* cannot recolor it for both themes.
|
|
10
|
+
*/
|
|
11
|
+
export function Logo({
|
|
12
|
+
className,
|
|
13
|
+
size = 36,
|
|
14
|
+
showName = true,
|
|
15
|
+
priority = false
|
|
16
|
+
}: {
|
|
17
|
+
className?: string
|
|
18
|
+
size?: number
|
|
19
|
+
showName?: boolean
|
|
20
|
+
priority?: boolean
|
|
21
|
+
}) {
|
|
22
|
+
const dimensions = { width: size, height: size }
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<span className={cn('flex items-center gap-2.5', className)}>
|
|
26
|
+
<Image
|
|
27
|
+
src={siteConfig.logo}
|
|
28
|
+
alt=""
|
|
29
|
+
aria-hidden
|
|
30
|
+
{...dimensions}
|
|
31
|
+
style={dimensions}
|
|
32
|
+
className="shrink-0 dark:hidden"
|
|
33
|
+
priority={priority}
|
|
34
|
+
/>
|
|
35
|
+
<Image
|
|
36
|
+
src={siteConfig.logoDark}
|
|
37
|
+
alt=""
|
|
38
|
+
aria-hidden
|
|
39
|
+
{...dimensions}
|
|
40
|
+
style={dimensions}
|
|
41
|
+
className="hidden shrink-0 dark:block"
|
|
42
|
+
priority={priority}
|
|
43
|
+
/>
|
|
44
|
+
<span className={cn('font-bold tracking-tight', !showName && 'sr-only')}>
|
|
45
|
+
{siteConfig.name}
|
|
46
|
+
</span>
|
|
47
|
+
</span>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Search } from 'lucide-react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
6
|
+
import {
|
|
7
|
+
SidebarNavGroup,
|
|
8
|
+
SidebarNavItem,
|
|
9
|
+
SidebarNavSubItem
|
|
10
|
+
} from '~/components/navigation-sidebar'
|
|
11
|
+
import { Button } from '~/components/ui/button'
|
|
12
|
+
import {
|
|
13
|
+
Command,
|
|
14
|
+
CommandDialog,
|
|
15
|
+
CommandEmpty,
|
|
16
|
+
CommandGroup,
|
|
17
|
+
CommandInput,
|
|
18
|
+
CommandItem,
|
|
19
|
+
CommandList,
|
|
20
|
+
CommandSeparator
|
|
21
|
+
} from '~/components/ui/command'
|
|
22
|
+
|
|
23
|
+
type NavigationSearchProps = {
|
|
24
|
+
groups: SidebarNavGroup[]
|
|
25
|
+
dialogDescription: string
|
|
26
|
+
emptyMessage?: string
|
|
27
|
+
buttonLabel?: string
|
|
28
|
+
buttonClassName?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function NavigationSearch({
|
|
32
|
+
groups,
|
|
33
|
+
dialogDescription,
|
|
34
|
+
emptyMessage = 'No results found.',
|
|
35
|
+
buttonLabel = 'Search…',
|
|
36
|
+
buttonClassName = 'text-muted-foreground relative h-9 w-36 justify-start rounded-md text-sm sm:w-64 sm:pr-12'
|
|
37
|
+
}: NavigationSearchProps) {
|
|
38
|
+
const router = useRouter()
|
|
39
|
+
const [searchOpen, setSearchOpen] = useState(false)
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
43
|
+
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
|
|
44
|
+
event.preventDefault()
|
|
45
|
+
setSearchOpen(true)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
document.addEventListener('keydown', onKeyDown)
|
|
50
|
+
return () => document.removeEventListener('keydown', onKeyDown)
|
|
51
|
+
}, [])
|
|
52
|
+
|
|
53
|
+
const navigate = useCallback(
|
|
54
|
+
(url: string) => {
|
|
55
|
+
setSearchOpen(false)
|
|
56
|
+
router.push(url)
|
|
57
|
+
},
|
|
58
|
+
[router]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const hasGroups = groups.length > 0
|
|
62
|
+
|
|
63
|
+
const normalizedGroups = useMemo(
|
|
64
|
+
() =>
|
|
65
|
+
groups.map(group => ({
|
|
66
|
+
label: group.label,
|
|
67
|
+
items: group.items.flatMap((item: SidebarNavItem) => {
|
|
68
|
+
if (item.items?.length) {
|
|
69
|
+
return item.items.map((subItem: SidebarNavSubItem) => ({
|
|
70
|
+
key: subItem.url,
|
|
71
|
+
label: `${item.title} › ${subItem.title}`,
|
|
72
|
+
value: `${group.label} ${item.title} ${subItem.title}`,
|
|
73
|
+
url: subItem.url,
|
|
74
|
+
icon: item.icon
|
|
75
|
+
}))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return [
|
|
79
|
+
{
|
|
80
|
+
key: item.url,
|
|
81
|
+
label: item.title,
|
|
82
|
+
value: `${group.label} ${item.title}`,
|
|
83
|
+
url: item.url,
|
|
84
|
+
icon: item.icon
|
|
85
|
+
}
|
|
86
|
+
]
|
|
87
|
+
})
|
|
88
|
+
})),
|
|
89
|
+
[groups]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<Button
|
|
95
|
+
variant="outline"
|
|
96
|
+
className={buttonClassName}
|
|
97
|
+
onClick={() => setSearchOpen(true)}
|
|
98
|
+
disabled={!hasGroups}
|
|
99
|
+
>
|
|
100
|
+
<Search className="mr-2 size-4" />
|
|
101
|
+
<span>{buttonLabel}</span>
|
|
102
|
+
<kbd className="bg-muted pointer-events-none absolute top-[50%] right-1.5 hidden h-5 -translate-y-1/2 items-center gap-1 rounded border px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
|
|
103
|
+
<span className="text-xs">⌘</span>K
|
|
104
|
+
</kbd>
|
|
105
|
+
</Button>
|
|
106
|
+
|
|
107
|
+
<CommandDialog
|
|
108
|
+
open={searchOpen}
|
|
109
|
+
onOpenChange={setSearchOpen}
|
|
110
|
+
title="Search"
|
|
111
|
+
description={dialogDescription}
|
|
112
|
+
>
|
|
113
|
+
<Command>
|
|
114
|
+
<CommandInput placeholder="Type to search…" />
|
|
115
|
+
<CommandList>
|
|
116
|
+
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
|
117
|
+
|
|
118
|
+
{normalizedGroups.map((group, groupIndex) => (
|
|
119
|
+
<div key={group.label}>
|
|
120
|
+
{groupIndex > 0 ? <CommandSeparator /> : null}
|
|
121
|
+
<CommandGroup heading={group.label}>
|
|
122
|
+
{group.items.map(item => (
|
|
123
|
+
<CommandItem
|
|
124
|
+
key={item.key}
|
|
125
|
+
value={item.value}
|
|
126
|
+
onSelect={() => navigate(item.url)}
|
|
127
|
+
>
|
|
128
|
+
{item.icon ? <item.icon className="mr-2 size-4" /> : null}
|
|
129
|
+
<span>{item.label}</span>
|
|
130
|
+
</CommandItem>
|
|
131
|
+
))}
|
|
132
|
+
</CommandGroup>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</CommandList>
|
|
136
|
+
</Command>
|
|
137
|
+
</CommandDialog>
|
|
138
|
+
</>
|
|
139
|
+
)
|
|
140
|
+
}
|