create-nextjs-cms 0.9.31 → 0.9.33
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/package.json +1 -1
- package/templates/default/app/(rootLayout)/(plugins)/[...slug]/error.tsx +64 -0
- package/templates/default/app/(rootLayout)/(plugins)/[...slug]/page.tsx +59 -47
- package/templates/default/app/(rootLayout)/(plugins)/[...slug]/plugin-server-registry.ts +14 -20
- package/templates/default/app/(rootLayout)/dashboard/page.tsx +10 -12
- package/templates/default/app/(rootLayout)/log/page.tsx +0 -2
- package/templates/default/app/(rootLayout)/settings/page.tsx +0 -2
- package/templates/default/app/api/document/route.ts +20 -11
- package/templates/default/app/api/photo/route.ts +14 -1
- package/templates/default/app/api/video/route.ts +47 -36
- package/templates/default/components/media/protected-document.tsx +2 -4
- package/templates/default/package.json +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import { AlertTriangle, Bug, RefreshCw } from 'lucide-react'
|
|
6
|
+
import { useI18n } from 'nextjs-cms/translations/client'
|
|
7
|
+
import { Badge } from '@/components/ui/badge'
|
|
8
|
+
|
|
9
|
+
export default function PluginError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
|
10
|
+
const t = useI18n()
|
|
11
|
+
const errorMessage = error.message || t('unexpectedPluginError')
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
// Surface plugin failures in the console; error.digest correlates with server logs.
|
|
15
|
+
console.error('[plugin]', error)
|
|
16
|
+
}, [error])
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className='flex min-h-[360px] items-center justify-center p-4 sm:p-6'>
|
|
20
|
+
<section className='bg-card text-card-foreground w-full max-w-3xl overflow-hidden rounded-lg border shadow-sm'>
|
|
21
|
+
<div className='from-destructive/10 via-background to-background border-b bg-linear-to-r px-5 py-5 sm:px-6'>
|
|
22
|
+
<div className='flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between'>
|
|
23
|
+
<div className='flex items-start gap-4'>
|
|
24
|
+
<div className='bg-destructive/10 dark:bg-destructive/30 text-destructive ring-destructive/20 flex size-12 shrink-0 items-center justify-center rounded-lg ring-1'>
|
|
25
|
+
<AlertTriangle className='size-6' aria-hidden='true' />
|
|
26
|
+
</div>
|
|
27
|
+
<div>
|
|
28
|
+
<Badge variant='destructive' className='mb-2'>
|
|
29
|
+
{t('pluginError')}
|
|
30
|
+
</Badge>
|
|
31
|
+
<h2 className='text-xl font-semibold tracking-tight'>{t('pluginFailedToLoad')}</h2>
|
|
32
|
+
<p className='text-muted-foreground mt-2 max-w-2xl text-sm'>
|
|
33
|
+
{t('pluginFailedToLoadDescription')}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<Button type='button' size='sm' onClick={() => reset()} className='self-start'>
|
|
39
|
+
<RefreshCw className='size-4' aria-hidden='true' />
|
|
40
|
+
{t('retry')}
|
|
41
|
+
</Button>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<dl className='divide-y text-sm'>
|
|
46
|
+
<div className='grid gap-2 px-5 py-4 sm:grid-cols-[10rem_1fr] sm:px-6'>
|
|
47
|
+
<dt className='text-foreground flex items-center gap-2 font-medium'>
|
|
48
|
+
<Bug className='text-muted-foreground size-4' aria-hidden='true' />
|
|
49
|
+
{t('details')}
|
|
50
|
+
</dt>
|
|
51
|
+
<dd className='text-muted-foreground wrap-break-word'>{errorMessage}</dd>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{error.digest && (
|
|
55
|
+
<div className='grid gap-2 px-5 py-4 sm:grid-cols-[10rem_1fr] sm:px-6'>
|
|
56
|
+
<dt className='text-foreground font-medium'>{t('errorDigest')}</dt>
|
|
57
|
+
<dd className='text-muted-foreground font-mono text-xs break-all'>{error.digest}</dd>
|
|
58
|
+
</div>
|
|
59
|
+
)}
|
|
60
|
+
</dl>
|
|
61
|
+
</section>
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -1,47 +1,59 @@
|
|
|
1
|
-
import { notFound } from 'next/navigation'
|
|
2
|
-
import auth from 'nextjs-cms/auth'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
1
|
+
import { notFound } from 'next/navigation'
|
|
2
|
+
import auth from 'nextjs-cms/auth'
|
|
3
|
+
import { Suspense } from 'react'
|
|
4
|
+
import { getAdminPrivileges } from 'nextjs-cms/api/server/actions'
|
|
5
|
+
import { findPluginRouteByPath, isDashboardOverridePlugin, runRoutePrefetches } from 'nextjs-cms/plugins/server'
|
|
6
|
+
import { api, HydrateClient } from '@/app/_trpc/server'
|
|
7
|
+
import { getPluginServerComponent } from './plugin-server-registry'
|
|
8
|
+
import LoadingSpinners from '@/components/feedback/loading-spinners'
|
|
9
|
+
|
|
10
|
+
type Params = Promise<{ slug?: string[] }>
|
|
11
|
+
|
|
12
|
+
export default async function Page(props: { params: Params }) {
|
|
13
|
+
const params = await props.params
|
|
14
|
+
const slug = params.slug ?? []
|
|
15
|
+
|
|
16
|
+
const path = `/${slug.join('/')}`
|
|
17
|
+
const session = await auth()
|
|
18
|
+
if (!session?.user) {
|
|
19
|
+
notFound()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const plugin = await findPluginRouteByPath(path)
|
|
23
|
+
if (!plugin) {
|
|
24
|
+
notFound()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Bypass privilege check for dashboard override plugin
|
|
28
|
+
const isDashboardPlugin = await isDashboardOverridePlugin(plugin.pluginName)
|
|
29
|
+
if (!isDashboardPlugin) {
|
|
30
|
+
const privilegeSet = await getAdminPrivileges(session.user.id)
|
|
31
|
+
if (!privilegeSet.has(plugin.pluginName)) {
|
|
32
|
+
notFound()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Fire-and-forget: HydrateClient streams resolved query data as it becomes available,
|
|
37
|
+
// so awaiting here would block the shell on slow upstream APIs (cPanel, GA, etc.)
|
|
38
|
+
void runRoutePrefetches(api, plugin)
|
|
39
|
+
|
|
40
|
+
const PluginComponent = await getPluginServerComponent(plugin.pluginName, plugin.component)
|
|
41
|
+
if (!PluginComponent) {
|
|
42
|
+
notFound()
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
<HydrateClient>
|
|
46
|
+
{/* This way, or use useSuspenseQuery
|
|
47
|
+
in each plugin client component and remove the <Suspense /> here */}
|
|
48
|
+
<Suspense
|
|
49
|
+
fallback={
|
|
50
|
+
<div className='p-20'>
|
|
51
|
+
<LoadingSpinners />
|
|
52
|
+
</div>
|
|
53
|
+
}
|
|
54
|
+
>
|
|
55
|
+
<PluginComponent />
|
|
56
|
+
</Suspense>
|
|
57
|
+
</HydrateClient>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -1,26 +1,20 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
|
|
4
3
|
export const pluginNamesMap: Record<string, string> = {
|
|
5
4
|
'cpanel-dashboard': '@nextjscms/plugin-cpanel-dashboard/server',
|
|
6
5
|
'cpanel-emails': '@nextjscms/plugin-cpanel-emails/server',
|
|
7
6
|
'google-analytics': '@nextjscms/plugin-google-analytics/server',
|
|
8
7
|
// A workaround to avoid importing error if no plugins are installed
|
|
9
8
|
blank: 'nextjs-cms/plugins/blank-component',
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export const getPluginServerComponent = async (
|
|
13
|
-
pluginName: string,
|
|
14
|
-
componentName: string | undefined,
|
|
15
|
-
): Promise<ComponentType | null> => {
|
|
16
|
-
const modulePath = pluginNamesMap[pluginName]
|
|
17
|
-
if (!modulePath) return null
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
),
|
|
23
|
-
{ ssr: true },
|
|
24
|
-
)
|
|
25
|
-
return Component
|
|
26
|
-
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const getPluginServerComponent = async (
|
|
12
|
+
pluginName: string,
|
|
13
|
+
componentName: string | undefined,
|
|
14
|
+
): Promise<ComponentType | null> => {
|
|
15
|
+
const modulePath = pluginNamesMap[pluginName]
|
|
16
|
+
if (!modulePath) return null
|
|
17
|
+
const mod = await import(modulePath)
|
|
18
|
+
const Component = componentName ? mod[componentName] : mod.default
|
|
19
|
+
return (Component as ComponentType) ?? null
|
|
20
|
+
}
|
|
@@ -2,42 +2,40 @@ import { getDashboardOverride, runRoutePrefetches } from 'nextjs-cms/plugins/ser
|
|
|
2
2
|
import { api, HydrateClient } from '@/app/_trpc/server'
|
|
3
3
|
import { getPluginServerComponent } from '../(plugins)/[...slug]/plugin-server-registry'
|
|
4
4
|
|
|
5
|
-
export const dynamic = 'force-dynamic'
|
|
6
|
-
|
|
7
5
|
function DefaultDashboard() {
|
|
8
6
|
return (
|
|
9
7
|
<div className='w-full'>
|
|
10
|
-
<div className='bg-linear-to-r from-amber-200 via-orange-200 to-rose-200 p-8
|
|
8
|
+
<div className='text-foreground bg-linear-to-r from-amber-200 via-orange-200 to-rose-200 p-8 dark:from-amber-900 dark:via-orange-900 dark:to-rose-900'>
|
|
11
9
|
<h1 className='text-3xl font-extrabold tracking-tight'>Welcome to Mission Control</h1>
|
|
12
|
-
<p className='mt-2 text-base
|
|
10
|
+
<p className='text-muted-foreground mt-2 text-base'>
|
|
13
11
|
Your CMS is ready. Chart a course, publish boldly, and keep the lights green.
|
|
14
12
|
</p>
|
|
15
13
|
</div>
|
|
16
14
|
|
|
17
15
|
<div className='space-y-6 p-6'>
|
|
18
|
-
<section className='rounded-lg border
|
|
16
|
+
<section className='bg-card text-card-foreground rounded-lg border p-6 shadow-sm'>
|
|
19
17
|
<h2 className='text-xl font-semibold'>Today's focus</h2>
|
|
20
|
-
<p className='mt-2 text-sm
|
|
18
|
+
<p className='text-muted-foreground mt-2 text-sm'>
|
|
21
19
|
Start with one small win: verify a section, ship a quick update, or polish a headline.
|
|
22
20
|
</p>
|
|
23
21
|
</section>
|
|
24
22
|
|
|
25
23
|
<section className='grid gap-4 md:grid-cols-3'>
|
|
26
|
-
<div className='rounded-lg border
|
|
24
|
+
<div className='bg-card text-card-foreground rounded-lg border p-4 shadow-sm'>
|
|
27
25
|
<div className='text-sm font-semibold'>Ship with confidence</div>
|
|
28
|
-
<div className='mt-2 text-sm
|
|
26
|
+
<div className='text-muted-foreground mt-2 text-sm'>
|
|
29
27
|
Keep changes tight and reversible. Small releases, fast feedback.
|
|
30
28
|
</div>
|
|
31
29
|
</div>
|
|
32
|
-
<div className='rounded-lg border
|
|
30
|
+
<div className='bg-card text-card-foreground rounded-lg border p-4 shadow-sm'>
|
|
33
31
|
<div className='text-sm font-semibold'>Stay organized</div>
|
|
34
|
-
<div className='mt-2 text-sm
|
|
32
|
+
<div className='text-muted-foreground mt-2 text-sm'>
|
|
35
33
|
Group your content by intent, not just by type.
|
|
36
34
|
</div>
|
|
37
35
|
</div>
|
|
38
|
-
<div className='rounded-lg border
|
|
36
|
+
<div className='bg-card text-card-foreground rounded-lg border p-4 shadow-sm'>
|
|
39
37
|
<div className='text-sm font-semibold'>Delight users</div>
|
|
40
|
-
<div className='mt-2 text-sm
|
|
38
|
+
<div className='text-muted-foreground mt-2 text-sm'>
|
|
41
39
|
A clean headline and a single strong image can do the heavy lifting.
|
|
42
40
|
</div>
|
|
43
41
|
</div>
|
|
@@ -19,6 +19,17 @@ import { getCMSConfig } from 'nextjs-cms/core/config'
|
|
|
19
19
|
|
|
20
20
|
export async function GET(request: NextRequest) {
|
|
21
21
|
const session = await auth()
|
|
22
|
+
|
|
23
|
+
// Check if the session is valid
|
|
24
|
+
if (!session || !session.user) {
|
|
25
|
+
return NextResponse.json(
|
|
26
|
+
{
|
|
27
|
+
error: 'Invalid token',
|
|
28
|
+
},
|
|
29
|
+
{ status: 401 },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
const searchParams = request.nextUrl.searchParams
|
|
23
34
|
|
|
24
35
|
const name = searchParams.get('name')
|
|
@@ -34,16 +45,6 @@ export async function GET(request: NextRequest) {
|
|
|
34
45
|
)
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
// Check if the session is valid
|
|
38
|
-
if (!session || !session.user) {
|
|
39
|
-
return NextResponse.json(
|
|
40
|
-
{
|
|
41
|
-
error: 'Invalid token',
|
|
42
|
-
},
|
|
43
|
-
{ status: 401 },
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
|
|
47
48
|
const uploadsFolder: string = (await getCMSConfig()).media.upload.path
|
|
48
49
|
|
|
49
50
|
// Sanitize the inputs
|
|
@@ -152,13 +153,21 @@ export async function GET(request: NextRequest) {
|
|
|
152
153
|
const fileSize = fileStats.size
|
|
153
154
|
const fileMimeType = fileType.mime
|
|
154
155
|
|
|
155
|
-
|
|
156
|
+
let data: ReadableStream<Uint8Array>
|
|
157
|
+
try {
|
|
158
|
+
data = await streamFile(pathToFile)
|
|
159
|
+
} catch {
|
|
160
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
|
161
|
+
}
|
|
156
162
|
|
|
157
163
|
return new NextResponse(data, {
|
|
158
164
|
headers: {
|
|
159
165
|
'Content-Length': fileSize.toString(),
|
|
160
166
|
'Content-Type': fileMimeType,
|
|
161
167
|
'Content-Disposition': 'inline',
|
|
168
|
+
'X-Content-Type-Options': 'nosniff',
|
|
169
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
170
|
+
'Referrer-Policy': 'no-referrer',
|
|
162
171
|
},
|
|
163
172
|
status: 200,
|
|
164
173
|
})
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server'
|
|
2
2
|
import { getPhoto } from 'nextjs-cms/api/server/actions'
|
|
3
|
+
import auth from 'nextjs-cms/auth'
|
|
3
4
|
|
|
4
5
|
export async function GET(request: NextRequest) {
|
|
6
|
+
const session = await auth()
|
|
7
|
+
|
|
8
|
+
// Check if the session is valid
|
|
9
|
+
if (!session || !session.user) {
|
|
10
|
+
return NextResponse.json(
|
|
11
|
+
{
|
|
12
|
+
error: 'Invalid token',
|
|
13
|
+
},
|
|
14
|
+
{ status: 401 },
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
const searchParams = request.nextUrl.searchParams
|
|
6
19
|
|
|
7
20
|
const name = searchParams.get('name')
|
|
@@ -17,7 +30,7 @@ export async function GET(request: NextRequest) {
|
|
|
17
30
|
)
|
|
18
31
|
}
|
|
19
32
|
|
|
20
|
-
const base64String =
|
|
33
|
+
const base64String = await getPhoto({
|
|
21
34
|
name,
|
|
22
35
|
folder,
|
|
23
36
|
isThumb: isThumb === 'true',
|
|
@@ -20,6 +20,17 @@ import { getCMSConfig } from 'nextjs-cms/core/config'
|
|
|
20
20
|
|
|
21
21
|
export async function GET(request: NextRequest) {
|
|
22
22
|
const session = await auth()
|
|
23
|
+
|
|
24
|
+
// Check if the session is valid
|
|
25
|
+
if (!session || !session.user) {
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{
|
|
28
|
+
error: 'Invalid token',
|
|
29
|
+
},
|
|
30
|
+
{ status: 401 },
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
const searchParams = request.nextUrl.searchParams
|
|
24
35
|
|
|
25
36
|
const name = searchParams.get('name')
|
|
@@ -35,16 +46,6 @@ export async function GET(request: NextRequest) {
|
|
|
35
46
|
)
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
// Check if the session is valid
|
|
39
|
-
if (!session || !session.user) {
|
|
40
|
-
return NextResponse.json(
|
|
41
|
-
{
|
|
42
|
-
error: 'Invalid token',
|
|
43
|
-
},
|
|
44
|
-
{ status: 401 },
|
|
45
|
-
)
|
|
46
|
-
}
|
|
47
|
-
|
|
48
49
|
const uploadsFolder: string = (await getCMSConfig()).media.upload.path
|
|
49
50
|
|
|
50
51
|
// Sanitize the inputs
|
|
@@ -142,32 +143,42 @@ export async function GET(request: NextRequest) {
|
|
|
142
143
|
const videoMimeType = fileType.mime
|
|
143
144
|
|
|
144
145
|
let res = null
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
146
|
+
try {
|
|
147
|
+
if (range) {
|
|
148
|
+
const parts = range.replace(/bytes=/, '').split('-')
|
|
149
|
+
const start = parseInt(parts[0] ?? '0', 10)
|
|
150
|
+
const end = parts[1] ? parseInt(parts[1], 10) : videoSize - 1
|
|
151
|
+
const chunkSize = end - start + 1
|
|
152
|
+
|
|
153
|
+
const data: ReadableStream<Uint8Array> = await streamFile(pathToFile, { start, end })
|
|
154
|
+
|
|
155
|
+
res = new NextResponse(data, {
|
|
156
|
+
headers: {
|
|
157
|
+
'Content-Range': `bytes ${start}-${end}/${videoSize}`,
|
|
158
|
+
'Accept-Ranges': 'bytes',
|
|
159
|
+
'Content-Length': chunkSize.toString(),
|
|
160
|
+
'Content-Type': videoMimeType,
|
|
161
|
+
'X-Content-Type-Options': 'nosniff',
|
|
162
|
+
'Content-Security-Policy': "sandbox; default-src 'none'",
|
|
163
|
+
'Referrer-Policy': 'no-referrer',
|
|
164
|
+
},
|
|
165
|
+
status: 206,
|
|
166
|
+
})
|
|
167
|
+
} else {
|
|
168
|
+
const data: ReadableStream<Uint8Array> = await streamFile(pathToFile)
|
|
169
|
+
res = new NextResponse(data, {
|
|
170
|
+
headers: {
|
|
171
|
+
'Content-Length': videoSize.toString(),
|
|
172
|
+
'Content-Type': videoMimeType,
|
|
173
|
+
'X-Content-Type-Options': 'nosniff',
|
|
174
|
+
'Content-Security-Policy': "sandbox; default-src 'none'",
|
|
175
|
+
'Referrer-Policy': 'no-referrer',
|
|
176
|
+
},
|
|
177
|
+
status: 200,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
|
171
182
|
}
|
|
172
183
|
|
|
173
184
|
return res
|
|
@@ -26,10 +26,8 @@ const ProtectedDocument = ({
|
|
|
26
26
|
|
|
27
27
|
return (
|
|
28
28
|
<div className={className}>
|
|
29
|
-
{loading &&
|
|
30
|
-
|
|
31
|
-
)}
|
|
32
|
-
<embed
|
|
29
|
+
{loading && <div className='animate-pulse bg-gray-500' style={{ width, height }} />}
|
|
30
|
+
<iframe
|
|
33
31
|
src={url}
|
|
34
32
|
className='max-w-full'
|
|
35
33
|
width={width}
|