create-ej-app 25.0.0 → 26.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/cli.js +4 -0
- package/cli.ts +5 -0
- package/package.json +2 -2
- package/template/api/package.json +2 -2
- package/template/nextjs/gitignore +1 -0
- package/template/nextjs/package.json +18 -18
- package/template/saas-starter/.dockerignore +7 -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/Dockerfile +15 -0
- package/template/saas-starter/README.md +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 +23 -0
- package/template/saas-starter/gitignore +9 -0
- package/template/saas-starter/next-env.d.ts +6 -0
- package/template/saas-starter/next.config.ts +14 -0
- package/template/saas-starter/package.json +92 -0
- package/template/saas-starter/postcss.config.mjs +7 -0
- package/template/saas-starter/prettier.config.mjs +11 -0
- package/template/saas-starter/public/placeholder.svg +1 -0
- package/template/saas-starter/src/actions/uploads.ts +28 -0
- package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -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 +40 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
- package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
- package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
- package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
- package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -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/favicon.ico +0 -0
- package/template/saas-starter/src/app/globals.css +240 -0
- package/template/saas-starter/src/app/layout.tsx +53 -0
- package/template/saas-starter/src/app/not-found.tsx +27 -0
- package/template/saas-starter/src/app/proxy.ts +28 -0
- package/template/saas-starter/src/app/robots.ts +32 -0
- package/template/saas-starter/src/app/sitemap.ts +20 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
- package/template/saas-starter/src/components/data-table/enum.ts +5 -0
- package/template/saas-starter/src/components/data-table/index.tsx +960 -0
- package/template/saas-starter/src/components/data-table/types.ts +75 -0
- package/template/saas-starter/src/components/error.tsx +26 -0
- package/template/saas-starter/src/components/file-upload.tsx +215 -0
- package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
- package/template/saas-starter/src/components/loader.tsx +25 -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 +32 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
- package/template/saas-starter/src/components/screen-size.tsx +40 -0
- package/template/saas-starter/src/components/site-footer.tsx +23 -0
- package/template/saas-starter/src/components/theme-provider.tsx +67 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
- package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
- package/template/saas-starter/src/components/ui/badge.tsx +49 -0
- package/template/saas-starter/src/components/ui/button.tsx +67 -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 +90 -0
- package/template/saas-starter/src/db/common.ts +31 -0
- package/template/saas-starter/src/db/enums.ts +26 -0
- package/template/saas-starter/src/db/index.ts +51 -0
- package/template/saas-starter/src/db/relations.ts +75 -0
- package/template/saas-starter/src/db/schema.ts +191 -0
- package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
- package/template/saas-starter/src/emails/invitation.tsx +56 -0
- package/template/saas-starter/src/emails/magic-link.tsx +49 -0
- package/template/saas-starter/src/emails/test.tsx +32 -0
- package/template/saas-starter/src/emails/welcome.tsx +34 -0
- package/template/saas-starter/src/env.ts +66 -0
- package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
- package/template/saas-starter/src/instrumentation.ts +26 -0
- package/template/saas-starter/src/lib/actions.ts +21 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
- package/template/saas-starter/src/lib/app-navigation.ts +59 -0
- package/template/saas-starter/src/lib/auth-client.ts +29 -0
- package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
- package/template/saas-starter/src/lib/auth.ts +211 -0
- package/template/saas-starter/src/lib/billing.ts +117 -0
- package/template/saas-starter/src/lib/cn.ts +4 -0
- package/template/saas-starter/src/lib/constants.ts +22 -0
- package/template/saas-starter/src/lib/delay.ts +2 -0
- package/template/saas-starter/src/lib/email-service.ts +89 -0
- package/template/saas-starter/src/lib/format-currency.ts +27 -0
- package/template/saas-starter/src/lib/format-date.ts +12 -0
- package/template/saas-starter/src/lib/nodemailer.ts +43 -0
- package/template/saas-starter/src/lib/organization-access.ts +93 -0
- package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
- package/template/saas-starter/src/lib/otel.ts +27 -0
- package/template/saas-starter/src/lib/plans.ts +63 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
- package/template/saas-starter/src/lib/safe-action.ts +67 -0
- package/template/saas-starter/src/lib/siteConfig.ts +4 -0
- package/template/saas-starter/src/lib/storage.ts +181 -0
- package/template/saas-starter/src/lib/stripe.ts +6 -0
- package/template/saas-starter/src/lib/toast-message.ts +15 -0
- package/template/saas-starter/src/lib/validations.ts +12 -0
- package/template/saas-starter/src/scripts/reset-db.ts +22 -0
- package/template/saas-starter/src/scripts/seed.ts +11 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
- package/template/saas-starter/src/scripts/test-mail.ts +35 -0
- package/template/saas-starter/tsconfig.json +51 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ColumnDef, SortingState } from '@tanstack/react-table'
|
|
2
|
+
import { SortOrderEnum } from './enum'
|
|
3
|
+
|
|
4
|
+
/** A single selectable option inside a filter dropdown. */
|
|
5
|
+
interface DataTableFilterOption {
|
|
6
|
+
label: string
|
|
7
|
+
value: string | boolean
|
|
8
|
+
/** Optional icon rendered next to the option label. */
|
|
9
|
+
icon?: React.ComponentType<{ className?: string }>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Describes a filter that appears in the DataTable toolbar.
|
|
14
|
+
* Filters can either be static (pre-defined `options`) or async
|
|
15
|
+
* (fetched via a server `action` on search).
|
|
16
|
+
*/
|
|
17
|
+
export interface DataTableFilter {
|
|
18
|
+
/** Unique key used to store the filter's selected values. */
|
|
19
|
+
id: string
|
|
20
|
+
/** Human-readable label shown in the filter dropdown trigger. */
|
|
21
|
+
label: string
|
|
22
|
+
/** Pre-defined options for static filters. Omit for async filters. */
|
|
23
|
+
options?: DataTableFilterOption[]
|
|
24
|
+
/** Server action called to fetch options dynamically (async filters). */
|
|
25
|
+
action?: any
|
|
26
|
+
/** Custom React-Query key for async filter caching. Defaults to `id`. */
|
|
27
|
+
queryKey?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Props accepted by the generic `DataTable` component.
|
|
32
|
+
*
|
|
33
|
+
* Supports both client-side and server-side (manual) pagination & sorting.
|
|
34
|
+
* When `manualPagination` / `manualSorting` are enabled the parent is
|
|
35
|
+
* responsible for fetching data and passing results back via `data` & `totalCount`.
|
|
36
|
+
*/
|
|
37
|
+
export interface DataTableProps<TData, TValue> {
|
|
38
|
+
/** Column definitions (TanStack Table). */
|
|
39
|
+
columns: ColumnDef<TData, TValue>[]
|
|
40
|
+
/** Row data to display. */
|
|
41
|
+
data?: TData[]
|
|
42
|
+
/** Show skeleton loading rows while data is being fetched. */
|
|
43
|
+
isLoading?: boolean
|
|
44
|
+
/** Render the search input in the toolbar. */
|
|
45
|
+
enableSearch?: boolean
|
|
46
|
+
/** Placeholder text for the search input. */
|
|
47
|
+
searchPlaceholder?: string
|
|
48
|
+
/** Initial value for the search input (e.g. restored from URL query params). */
|
|
49
|
+
initialSearch?: string
|
|
50
|
+
/** Total row count for server-side pagination info. */
|
|
51
|
+
totalCount?: number
|
|
52
|
+
/** Callback fired when pagination, sorting, search or filters change. */
|
|
53
|
+
onParamsChange?: (params: {
|
|
54
|
+
page: number
|
|
55
|
+
limit: number
|
|
56
|
+
sortBy?: string
|
|
57
|
+
sortOrder?: SortOrderEnum
|
|
58
|
+
search?: string
|
|
59
|
+
filters?: Record<string, (string | boolean)[]>
|
|
60
|
+
}) => void
|
|
61
|
+
/** Enable server-side pagination (parent controls data fetching). */
|
|
62
|
+
manualPagination?: boolean
|
|
63
|
+
/** Enable server-side sorting (parent controls data fetching). */
|
|
64
|
+
manualSorting?: boolean
|
|
65
|
+
/** Current 0-based page index (controlled). */
|
|
66
|
+
pageIndex?: number
|
|
67
|
+
/** Number of rows per page (controlled). */
|
|
68
|
+
pageSize?: number
|
|
69
|
+
/** Default sorting state applied on mount. */
|
|
70
|
+
initialSorting?: SortingState
|
|
71
|
+
/** Filter definitions rendered in the toolbar filter dropdown. */
|
|
72
|
+
filters?: DataTableFilter[]
|
|
73
|
+
/** Currently active filter selections keyed by filter id. */
|
|
74
|
+
activeFilters?: Record<string, (string | boolean)[]>
|
|
75
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type LucideIcon, AlertTriangle } from 'lucide-react'
|
|
2
|
+
import Link from 'next/link'
|
|
3
|
+
import { FC } from 'react'
|
|
4
|
+
import { Button } from '~/components/ui/button'
|
|
5
|
+
|
|
6
|
+
export const ErrorDisplay: FC<{
|
|
7
|
+
title: string
|
|
8
|
+
message: string
|
|
9
|
+
icon?: LucideIcon
|
|
10
|
+
link?: {
|
|
11
|
+
href?: string
|
|
12
|
+
text?: string
|
|
13
|
+
}
|
|
14
|
+
}> = ({ title, message, icon, link }) => {
|
|
15
|
+
const Icon = icon || AlertTriangle
|
|
16
|
+
return (
|
|
17
|
+
<div className="flex w-full flex-col items-center justify-center py-16">
|
|
18
|
+
<Icon className="text-muted-foreground/50 size-16" />
|
|
19
|
+
<h2 className="mt-4 text-xl font-semibold">{title}</h2>
|
|
20
|
+
<p className="text-muted-foreground mt-2">{message}</p>
|
|
21
|
+
<Button className="mt-4" asChild>
|
|
22
|
+
<Link href={link?.href || '/'}>{link?.text || 'Go Home'}</Link>
|
|
23
|
+
</Button>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
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 { toast } from 'sonner'
|
|
7
|
+
import { generateUploadUrlAction } from '~/actions/uploads'
|
|
8
|
+
import { Button } from '~/components/ui/button'
|
|
9
|
+
import { cn } from '~/lib/cn'
|
|
10
|
+
import { MAX_FILE_SIZE_BYTES, MAX_FILE_SIZE_MB } from '~/lib/constants'
|
|
11
|
+
|
|
12
|
+
type PreviewState =
|
|
13
|
+
| { kind: 'image'; url: string }
|
|
14
|
+
| { kind: 'file'; name: string; mimeType: string }
|
|
15
|
+
| null
|
|
16
|
+
|
|
17
|
+
function getInitialPreview(currentUrl?: string | null): PreviewState {
|
|
18
|
+
if (!currentUrl) return null
|
|
19
|
+
return { kind: 'image', url: currentUrl }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function FileTypeIcon({ mimeType }: { mimeType: string }) {
|
|
23
|
+
if (mimeType.startsWith('video/'))
|
|
24
|
+
return <Video className="text-muted-foreground h-8 w-8" />
|
|
25
|
+
if (mimeType.startsWith('audio/'))
|
|
26
|
+
return <Music className="text-muted-foreground h-8 w-8" />
|
|
27
|
+
if (mimeType === 'application/pdf' || mimeType.includes('text'))
|
|
28
|
+
return <FileText className="text-muted-foreground h-8 w-8" />
|
|
29
|
+
return <File className="text-muted-foreground h-8 w-8" />
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type Props = {
|
|
33
|
+
/** MIME type filter passed to the file input, e.g. `"image/*"` or `"image/*,application/pdf"`. */
|
|
34
|
+
accept: string
|
|
35
|
+
/** Scopes the upload to a organization for cleanup tracking. */
|
|
36
|
+
organizationId: string
|
|
37
|
+
/**
|
|
38
|
+
* Called with the R2 object key (prod) or base64 data URL (dev mode) once
|
|
39
|
+
* the upload finishes. Called with `null` when the user clears the value.
|
|
40
|
+
*/
|
|
41
|
+
onClientUploadFinish: (key: string | null) => void
|
|
42
|
+
/**
|
|
43
|
+
* Pre-resolved URL for the current value - pass the output of
|
|
44
|
+
* `resolveImageUrl` from the parent server component so the preview renders
|
|
45
|
+
* correctly for existing R2 keys.
|
|
46
|
+
*/
|
|
47
|
+
currentUrl?: string | null
|
|
48
|
+
className?: string
|
|
49
|
+
/** Passed to next/image `sizes` - should match the rendered widget width. */
|
|
50
|
+
sizes?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function FileUpload({
|
|
54
|
+
accept,
|
|
55
|
+
organizationId,
|
|
56
|
+
onClientUploadFinish,
|
|
57
|
+
currentUrl,
|
|
58
|
+
className,
|
|
59
|
+
sizes = '128px'
|
|
60
|
+
}: Props) {
|
|
61
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
62
|
+
const [preview, setPreview] = useState<PreviewState>(
|
|
63
|
+
getInitialPreview(currentUrl)
|
|
64
|
+
)
|
|
65
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
66
|
+
|
|
67
|
+
const handleFile = async (file: File) => {
|
|
68
|
+
if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
69
|
+
toast.error(`File must be under ${MAX_FILE_SIZE_MB}MB`)
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setIsUploading(true)
|
|
74
|
+
|
|
75
|
+
if (file.type.startsWith('image/')) {
|
|
76
|
+
setPreview({ kind: 'image', url: URL.createObjectURL(file) })
|
|
77
|
+
} else {
|
|
78
|
+
setPreview({ kind: 'file', name: file.name, mimeType: file.type })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = await generateUploadUrlAction({
|
|
82
|
+
filename: file.name,
|
|
83
|
+
mimeType: file.type,
|
|
84
|
+
size: file.size,
|
|
85
|
+
organizationId
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
if (!result?.data) {
|
|
89
|
+
toast.error('Failed to prepare upload. Please try again.')
|
|
90
|
+
setPreview(getInitialPreview(currentUrl))
|
|
91
|
+
setIsUploading(false)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { data } = result
|
|
96
|
+
|
|
97
|
+
if (data.mode === 'r2') {
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(data.uploadUrl, {
|
|
100
|
+
method: 'PUT',
|
|
101
|
+
body: file,
|
|
102
|
+
headers: { 'Content-Type': file.type }
|
|
103
|
+
})
|
|
104
|
+
if (!res.ok) throw new Error(`R2 upload failed: ${res.status}`)
|
|
105
|
+
onClientUploadFinish(data.key)
|
|
106
|
+
} catch {
|
|
107
|
+
toast.error('Upload failed. Please try again.')
|
|
108
|
+
setPreview(getInitialPreview(currentUrl))
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
const reader = new FileReader()
|
|
112
|
+
reader.onload = e => {
|
|
113
|
+
const base64 = e.target?.result as string
|
|
114
|
+
if (file.type.startsWith('image/')) {
|
|
115
|
+
setPreview({ kind: 'image', url: base64 })
|
|
116
|
+
}
|
|
117
|
+
onClientUploadFinish(base64)
|
|
118
|
+
}
|
|
119
|
+
reader.onerror = () => {
|
|
120
|
+
toast.error('Failed to read file. Please try again.')
|
|
121
|
+
setPreview(getInitialPreview(currentUrl))
|
|
122
|
+
}
|
|
123
|
+
reader.readAsDataURL(file)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setIsUploading(false)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const handleClear = (e: React.MouseEvent) => {
|
|
130
|
+
e.stopPropagation()
|
|
131
|
+
setPreview(null)
|
|
132
|
+
onClientUploadFinish(null)
|
|
133
|
+
if (inputRef.current) inputRef.current.value = ''
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className={cn('relative', className)}>
|
|
138
|
+
<input
|
|
139
|
+
ref={inputRef}
|
|
140
|
+
type="file"
|
|
141
|
+
accept={accept}
|
|
142
|
+
className="hidden"
|
|
143
|
+
onChange={e => {
|
|
144
|
+
const file = e.target.files?.[0]
|
|
145
|
+
if (file) handleFile(file)
|
|
146
|
+
e.target.value = ''
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => !isUploading && inputRef.current?.click()}
|
|
153
|
+
disabled={isUploading}
|
|
154
|
+
className={cn(
|
|
155
|
+
'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',
|
|
156
|
+
isUploading && 'cursor-not-allowed'
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
{preview?.kind === 'image' && (
|
|
160
|
+
<Image
|
|
161
|
+
src={preview.url}
|
|
162
|
+
alt="Upload preview"
|
|
163
|
+
fill
|
|
164
|
+
sizes={sizes}
|
|
165
|
+
className="object-contain"
|
|
166
|
+
unoptimized={
|
|
167
|
+
preview.url.startsWith('data:') || preview.url.startsWith('blob:')
|
|
168
|
+
}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{preview?.kind === 'file' && (
|
|
173
|
+
<div className="flex flex-col items-center gap-2 px-4">
|
|
174
|
+
<FileTypeIcon mimeType={preview.mimeType} />
|
|
175
|
+
<span className="text-muted-foreground max-w-full truncate text-xs">
|
|
176
|
+
{preview.name}
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
{!preview && (
|
|
182
|
+
<div className="flex flex-col items-center gap-2">
|
|
183
|
+
<div className="bg-muted rounded-md p-2">
|
|
184
|
+
<Upload className="text-muted-foreground h-5 w-5" />
|
|
185
|
+
</div>
|
|
186
|
+
<span className="text-muted-foreground text-xs font-medium">
|
|
187
|
+
Click to upload
|
|
188
|
+
</span>
|
|
189
|
+
<span className="text-muted-foreground/60 text-[11px]">
|
|
190
|
+
Max {MAX_FILE_SIZE_MB}MB
|
|
191
|
+
</span>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
|
|
195
|
+
{isUploading && (
|
|
196
|
+
<div className="bg-background/70 absolute inset-0 flex items-center justify-center backdrop-blur-sm">
|
|
197
|
+
<Loader2 className="text-primary h-6 w-6 animate-spin" />
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</button>
|
|
201
|
+
|
|
202
|
+
{preview && !isUploading && (
|
|
203
|
+
<Button
|
|
204
|
+
type="button"
|
|
205
|
+
variant="destructive"
|
|
206
|
+
size="icon"
|
|
207
|
+
className="absolute -top-2 -right-2 h-6 w-6 rounded-full shadow-sm"
|
|
208
|
+
onClick={handleClear}
|
|
209
|
+
>
|
|
210
|
+
<X className="h-3 w-3" />
|
|
211
|
+
</Button>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { UserX } from 'lucide-react'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { FC, useState } from 'react'
|
|
6
|
+
import { Button } from '~/components/ui/button'
|
|
7
|
+
import { admin } from '~/lib/auth-client'
|
|
8
|
+
|
|
9
|
+
export const ImpersonationBanner: FC = () => {
|
|
10
|
+
const router = useRouter()
|
|
11
|
+
const [isStopping, setIsStopping] = useState(false)
|
|
12
|
+
|
|
13
|
+
const handleStopImpersonation = async () => {
|
|
14
|
+
try {
|
|
15
|
+
setIsStopping(true)
|
|
16
|
+
await admin.stopImpersonating()
|
|
17
|
+
router.refresh()
|
|
18
|
+
} catch (error) {
|
|
19
|
+
console.error('Failed to stop impersonation:', error)
|
|
20
|
+
setIsStopping(false)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="bg-primary text-primary-foreground w-full border-b text-sm">
|
|
26
|
+
<div className="container flex min-h-10 items-center justify-between gap-4 py-2">
|
|
27
|
+
<div className="flex items-center gap-2">
|
|
28
|
+
<UserX className="size-4" />
|
|
29
|
+
<span className="font-medium">Impersonating User</span>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="flex items-center gap-3">
|
|
32
|
+
<Button
|
|
33
|
+
variant="ghost"
|
|
34
|
+
size="sm"
|
|
35
|
+
onClick={handleStopImpersonation}
|
|
36
|
+
disabled={isStopping}
|
|
37
|
+
>
|
|
38
|
+
{isStopping ? 'Stopping...' : 'Stop Impersonation'}
|
|
39
|
+
</Button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import NextTopLoader from 'nextjs-toploader'
|
|
4
|
+
|
|
5
|
+
import { FC } from 'react'
|
|
6
|
+
|
|
7
|
+
export const Loader: FC = () => {
|
|
8
|
+
return (
|
|
9
|
+
<NextTopLoader
|
|
10
|
+
color="#777777"
|
|
11
|
+
initialPosition={0.08}
|
|
12
|
+
crawlSpeed={200}
|
|
13
|
+
height={3}
|
|
14
|
+
crawl={true}
|
|
15
|
+
showSpinner={true}
|
|
16
|
+
easing="ease"
|
|
17
|
+
speed={200}
|
|
18
|
+
shadow={`0 0 10px '#777777',0 0 5px '#777777'`}
|
|
19
|
+
template='<div class="bar" role="bar"><div class="peg"></div></div>
|
|
20
|
+
<div class="spinner" role="spinner"><div class="spinner-icon"></div></div>'
|
|
21
|
+
zIndex={10000}
|
|
22
|
+
showAtBottom={false}
|
|
23
|
+
/>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -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
|
+
}
|