create-nextblock 0.2.31 → 0.2.34
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/scripts/sync-template.js +70 -52
- package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +67 -67
- package/templates/nextblock-template/app/[slug]/page.tsx +4 -4
- package/templates/nextblock-template/app/cms/blocks/actions.ts +10 -10
- package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +348 -348
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +8 -8
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +10 -10
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +81 -81
- package/templates/nextblock-template/app/cms/media/actions.ts +35 -35
- package/templates/nextblock-template/app/cms/media/page.tsx +120 -120
- package/templates/nextblock-template/app/cms/revisions/JsonDiffView.tsx +86 -86
- package/templates/nextblock-template/app/cms/revisions/service.ts +344 -344
- package/templates/nextblock-template/app/providers.tsx +2 -2
- package/templates/nextblock-template/components/BlockRenderer.tsx +9 -9
- package/templates/nextblock-template/components/ResponsiveNav.tsx +22 -22
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +26 -26
- package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +41 -41
- package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +7 -7
- package/templates/nextblock-template/components/theme-switcher.tsx +78 -78
- package/templates/nextblock-template/eslint.config.mjs +35 -37
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +19 -19
- package/templates/nextblock-template/next-env.d.ts +6 -6
- package/templates/nextblock-template/package.json +1 -1
|
@@ -129,13 +129,13 @@ export default function BlockEditorArea({ parentId, parentType, initialBlocks, l
|
|
|
129
129
|
debouncedSave(updatedBlock);
|
|
130
130
|
};
|
|
131
131
|
|
|
132
|
-
const DynamicTextBlockEditor = dynamic(() => import('../editors/TextBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
133
|
-
const DynamicHeadingBlockEditor = dynamic(() => import('../editors/HeadingBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
134
|
-
const DynamicImageBlockEditor = dynamic(() => import('../editors/ImageBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
135
|
-
const DynamicButtonBlockEditor = dynamic(() => import('../editors/ButtonBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
136
|
-
const DynamicPostsGridBlockEditor = dynamic(() => import('../editors/PostsGridBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
137
|
-
const DynamicVideoEmbedBlockEditor = dynamic(() => import('../editors/VideoEmbedBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
138
|
-
const DynamicSectionBlockEditor = dynamic(() => import('../editors/SectionBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
132
|
+
const DynamicTextBlockEditor = dynamic(() => import('../editors/TextBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
133
|
+
const DynamicHeadingBlockEditor = dynamic(() => import('../editors/HeadingBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
134
|
+
const DynamicImageBlockEditor = dynamic(() => import('../editors/ImageBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
135
|
+
const DynamicButtonBlockEditor = dynamic(() => import('../editors/ButtonBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
136
|
+
const DynamicPostsGridBlockEditor = dynamic(() => import('../editors/PostsGridBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
137
|
+
const DynamicVideoEmbedBlockEditor = dynamic(() => import('../editors/VideoEmbedBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
138
|
+
const DynamicSectionBlockEditor = dynamic(() => import('../editors/SectionBlockEditor').then(mod => mod.default), { loading: () => <p>Loading editor...</p> });
|
|
139
139
|
|
|
140
140
|
useEffect(() => {
|
|
141
141
|
if (editingNestedBlockInfo) {
|
|
@@ -444,7 +444,7 @@ export default function BlockEditorArea({ parentId, parentType, initialBlocks, l
|
|
|
444
444
|
onContentChange={handleContentChange}
|
|
445
445
|
onDelete={async (blockIdToDelete) => {
|
|
446
446
|
startTransition(async () => {
|
|
447
|
-
const result = await import("../actions").then(({ deleteBlock }) =>
|
|
447
|
+
const result = await import("../actions").then(({ deleteBlock }) =>
|
|
448
448
|
deleteBlock(
|
|
449
449
|
blockIdToDelete,
|
|
450
450
|
parentType === "page" ? parentId : null,
|
|
@@ -22,16 +22,16 @@ export interface EditableBlockProps {
|
|
|
22
22
|
className?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export default function EditableBlock({
|
|
26
|
-
block,
|
|
27
|
-
onDelete,
|
|
28
|
-
onContentChange,
|
|
29
|
-
dragHandleProps,
|
|
30
|
-
onEditNestedBlock,
|
|
31
|
-
className,
|
|
32
|
-
}: EditableBlockProps) {
|
|
33
|
-
void onEditNestedBlock;
|
|
34
|
-
// Move all hooks to the top before any conditional returns
|
|
25
|
+
export default function EditableBlock({
|
|
26
|
+
block,
|
|
27
|
+
onDelete,
|
|
28
|
+
onContentChange,
|
|
29
|
+
dragHandleProps,
|
|
30
|
+
onEditNestedBlock,
|
|
31
|
+
className,
|
|
32
|
+
}: EditableBlockProps) {
|
|
33
|
+
void onEditNestedBlock;
|
|
34
|
+
// Move all hooks to the top before any conditional returns
|
|
35
35
|
const [isConfigPanelOpen, setIsConfigPanelOpen] = useState(false);
|
|
36
36
|
const [editingBlock, setEditingBlock] = useState<Block | null>(null);
|
|
37
37
|
const [LazyEditor, setLazyEditor] = useState<LazyExoticComponent<ComponentType<any>> | null>(null);
|
|
@@ -1,81 +1,81 @@
|
|
|
1
|
-
// app/cms/blocks/editors/TextBlockEditor.tsx
|
|
2
|
-
'use client';
|
|
3
|
-
|
|
4
|
-
import React, { useId, useState, useRef, useCallback } from 'react';
|
|
5
|
-
import dynamic from 'next/dynamic';
|
|
6
|
-
import MediaPickerDialog from '@/app/cms/media/components/MediaPickerDialog';
|
|
7
|
-
import { Label } from '@nextblock-cms/ui';
|
|
8
|
-
import { BlockEditorProps } from '../components/BlockEditorModal';
|
|
9
|
-
|
|
10
|
-
// Props expected by NotionEditor
|
|
11
|
-
type NotionEditorProps = {
|
|
12
|
-
content: string;
|
|
13
|
-
onChange: (html: string) => void;
|
|
14
|
-
openImagePicker?: () => Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
// Use the alias that resolves in your repo; if you mapped @nextblock-cms/editor, swap it here.
|
|
18
|
-
const NotionEditor = dynamic<NotionEditorProps>(
|
|
19
|
-
() => import('@nextblock-cms/editor').then((m) => m.NotionEditor),
|
|
20
|
-
{ ssr: false }
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
export type TextBlockContent = {
|
|
24
|
-
html_content?: string;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export default function TextBlockEditor({
|
|
28
|
-
content,
|
|
29
|
-
onChange,
|
|
30
|
-
}: BlockEditorProps<Partial<TextBlockContent>>) {
|
|
31
|
-
const labelId = useId();
|
|
32
|
-
const [pickerOpen, setPickerOpen] = useState(false);
|
|
33
|
-
const resolverRef = useRef<null | ((v: any) => void)>(null);
|
|
34
|
-
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
35
|
-
const openImagePicker = useCallback(() => {
|
|
36
|
-
setPickerOpen(true);
|
|
37
|
-
return new Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>((resolve) => {
|
|
38
|
-
resolverRef.current = resolve;
|
|
39
|
-
});
|
|
40
|
-
}, []);
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<div className="h-full flex flex-col">
|
|
44
|
-
<Label htmlFor={labelId} className="sr-only">
|
|
45
|
-
Text Content
|
|
46
|
-
</Label>
|
|
47
|
-
|
|
48
|
-
<div id={labelId} role="group" aria-labelledby={labelId} className="flex-1 min-h-0 flex flex-col">
|
|
49
|
-
<NotionEditor
|
|
50
|
-
content={content?.html_content ?? ''}
|
|
51
|
-
onChange={(html) => onChange({ html_content: html })}
|
|
52
|
-
openImagePicker={openImagePicker}
|
|
53
|
-
/>
|
|
54
|
-
|
|
55
|
-
{/* Hidden controlled MediaPickerDialog for image selection */}
|
|
56
|
-
<div className="sr-only" aria-hidden>
|
|
57
|
-
<MediaPickerDialog
|
|
58
|
-
hideTrigger
|
|
59
|
-
open={pickerOpen}
|
|
60
|
-
onOpenChange={setPickerOpen}
|
|
61
|
-
title="Select or Upload Image"
|
|
62
|
-
accept={(m) => !!m.file_type?.startsWith('image/')}
|
|
63
|
-
onSelect={(media) => {
|
|
64
|
-
const src = `${R2_BASE_URL}/${media.object_key}`;
|
|
65
|
-
resolverRef.current?.({
|
|
66
|
-
src,
|
|
67
|
-
alt: media.description || media.file_name || undefined,
|
|
68
|
-
width: media.width ?? null,
|
|
69
|
-
height: media.height ?? null,
|
|
70
|
-
blurDataURL: media.blur_data_url ?? null,
|
|
71
|
-
});
|
|
72
|
-
resolverRef.current = null;
|
|
73
|
-
setPickerOpen(false);
|
|
74
|
-
}}
|
|
75
|
-
/>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
</div>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
1
|
+
// app/cms/blocks/editors/TextBlockEditor.tsx
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import React, { useId, useState, useRef, useCallback } from 'react';
|
|
5
|
+
import dynamic from 'next/dynamic';
|
|
6
|
+
import MediaPickerDialog from '@/app/cms/media/components/MediaPickerDialog';
|
|
7
|
+
import { Label } from '@nextblock-cms/ui';
|
|
8
|
+
import { BlockEditorProps } from '../components/BlockEditorModal';
|
|
9
|
+
|
|
10
|
+
// Props expected by NotionEditor
|
|
11
|
+
type NotionEditorProps = {
|
|
12
|
+
content: string;
|
|
13
|
+
onChange: (html: string) => void;
|
|
14
|
+
openImagePicker?: () => Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Use the alias that resolves in your repo; if you mapped @nextblock-cms/editor, swap it here.
|
|
18
|
+
const NotionEditor = dynamic<NotionEditorProps>(
|
|
19
|
+
() => import('@nextblock-cms/editor').then((m) => m.NotionEditor),
|
|
20
|
+
{ ssr: false }
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
export type TextBlockContent = {
|
|
24
|
+
html_content?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default function TextBlockEditor({
|
|
28
|
+
content,
|
|
29
|
+
onChange,
|
|
30
|
+
}: BlockEditorProps<Partial<TextBlockContent>>) {
|
|
31
|
+
const labelId = useId();
|
|
32
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
33
|
+
const resolverRef = useRef<null | ((v: any) => void)>(null);
|
|
34
|
+
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
35
|
+
const openImagePicker = useCallback(() => {
|
|
36
|
+
setPickerOpen(true);
|
|
37
|
+
return new Promise<{ src: string; alt?: string; width?: number | null; height?: number | null; blurDataURL?: string | null } | null>((resolve) => {
|
|
38
|
+
resolverRef.current = resolve;
|
|
39
|
+
});
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="h-full flex flex-col">
|
|
44
|
+
<Label htmlFor={labelId} className="sr-only">
|
|
45
|
+
Text Content
|
|
46
|
+
</Label>
|
|
47
|
+
|
|
48
|
+
<div id={labelId} role="group" aria-labelledby={labelId} className="flex-1 min-h-0 flex flex-col">
|
|
49
|
+
<NotionEditor
|
|
50
|
+
content={content?.html_content ?? ''}
|
|
51
|
+
onChange={(html) => onChange({ html_content: html })}
|
|
52
|
+
openImagePicker={openImagePicker}
|
|
53
|
+
/>
|
|
54
|
+
|
|
55
|
+
{/* Hidden controlled MediaPickerDialog for image selection */}
|
|
56
|
+
<div className="sr-only" aria-hidden>
|
|
57
|
+
<MediaPickerDialog
|
|
58
|
+
hideTrigger
|
|
59
|
+
open={pickerOpen}
|
|
60
|
+
onOpenChange={setPickerOpen}
|
|
61
|
+
title="Select or Upload Image"
|
|
62
|
+
accept={(m) => !!m.file_type?.startsWith('image/')}
|
|
63
|
+
onSelect={(media) => {
|
|
64
|
+
const src = `${R2_BASE_URL}/${media.object_key}`;
|
|
65
|
+
resolverRef.current?.({
|
|
66
|
+
src,
|
|
67
|
+
alt: media.description || media.file_name || undefined,
|
|
68
|
+
width: media.width ?? null,
|
|
69
|
+
height: media.height ?? null,
|
|
70
|
+
blurDataURL: media.blur_data_url ?? null,
|
|
71
|
+
});
|
|
72
|
+
resolverRef.current = null;
|
|
73
|
+
setPickerOpen(false);
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
@@ -201,24 +201,24 @@ export async function deleteMediaItem(mediaId: string, objectKey: string) {
|
|
|
201
201
|
return encodedRedirect("error", "/cms/media", "Forbidden: Insufficient permissions.");
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
|
|
205
|
-
const { getS3Client } = await import("@nextblock-cms/utils/server");
|
|
206
|
-
const s3Client = await getS3Client();
|
|
207
|
-
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
|
|
208
|
-
|
|
209
|
-
if (!R2_BUCKET_NAME) {
|
|
210
|
-
return encodedRedirect("error", "/cms/media", "R2 Bucket not configured for deletion.");
|
|
211
|
-
}
|
|
212
|
-
if (!s3Client) {
|
|
213
|
-
return encodedRedirect("error", "/cms/media", "R2 client is not configured for deletion.");
|
|
214
|
-
}
|
|
204
|
+
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
|
|
205
|
+
const { getS3Client } = await import("@nextblock-cms/utils/server");
|
|
206
|
+
const s3Client = await getS3Client();
|
|
207
|
+
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
|
|
208
|
+
|
|
209
|
+
if (!R2_BUCKET_NAME) {
|
|
210
|
+
return encodedRedirect("error", "/cms/media", "R2 Bucket not configured for deletion.");
|
|
211
|
+
}
|
|
212
|
+
if (!s3Client) {
|
|
213
|
+
return encodedRedirect("error", "/cms/media", "R2 client is not configured for deletion.");
|
|
214
|
+
}
|
|
215
215
|
|
|
216
216
|
try {
|
|
217
217
|
const deleteCommand = new DeleteObjectCommand({
|
|
218
218
|
Bucket: R2_BUCKET_NAME,
|
|
219
219
|
Key: objectKey,
|
|
220
220
|
});
|
|
221
|
-
await s3Client.send(deleteCommand);
|
|
221
|
+
await s3Client.send(deleteCommand);
|
|
222
222
|
} catch (r2Error: unknown) {
|
|
223
223
|
console.error("Error deleting from R2:", r2Error);
|
|
224
224
|
// Decide if you want to proceed with DB deletion if R2 deletion fails
|
|
@@ -255,17 +255,17 @@ export async function deleteMultipleMediaItems(items: Array<{ id: string; object
|
|
|
255
255
|
return { error: "No items selected for deletion." };
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
const { DeleteObjectsCommand } = await import("@aws-sdk/client-s3"); // Use DeleteObjects for batch
|
|
259
|
-
const { getS3Client } = await import("@nextblock-cms/utils/server");
|
|
260
|
-
const s3Client = await getS3Client();
|
|
261
|
-
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
|
|
262
|
-
|
|
263
|
-
if (!R2_BUCKET_NAME) {
|
|
264
|
-
return { error: "R2 Bucket not configured for deletion." };
|
|
265
|
-
}
|
|
266
|
-
if (!s3Client) {
|
|
267
|
-
return { error: "R2 client is not configured for deletion." };
|
|
268
|
-
}
|
|
258
|
+
const { DeleteObjectsCommand } = await import("@aws-sdk/client-s3"); // Use DeleteObjects for batch
|
|
259
|
+
const { getS3Client } = await import("@nextblock-cms/utils/server");
|
|
260
|
+
const s3Client = await getS3Client();
|
|
261
|
+
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
|
|
262
|
+
|
|
263
|
+
if (!R2_BUCKET_NAME) {
|
|
264
|
+
return { error: "R2 Bucket not configured for deletion." };
|
|
265
|
+
}
|
|
266
|
+
if (!s3Client) {
|
|
267
|
+
return { error: "R2 client is not configured for deletion." };
|
|
268
|
+
}
|
|
269
269
|
|
|
270
270
|
const r2ObjectsToDelete = items.map(item => ({ Key: item.objectKey }));
|
|
271
271
|
const itemIdsToDelete = items.map(item => item.id);
|
|
@@ -345,18 +345,18 @@ export async function moveMultipleMediaItems(
|
|
|
345
345
|
};
|
|
346
346
|
const folder = sanitizeFolder(destinationFolder);
|
|
347
347
|
|
|
348
|
-
const { CopyObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand } = await import("@aws-sdk/client-s3");
|
|
349
|
-
const { getS3Client } = await import("@nextblock-cms/utils/server");
|
|
350
|
-
const s3Client = await getS3Client();
|
|
351
|
-
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
|
|
352
|
-
const R2_PUBLIC_URL_BASE = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
353
|
-
|
|
354
|
-
if (!R2_BUCKET_NAME) {
|
|
355
|
-
return { error: "R2 Bucket not configured for move." };
|
|
356
|
-
}
|
|
357
|
-
if (!s3Client) {
|
|
358
|
-
return { error: "R2 client is not configured for move." };
|
|
359
|
-
}
|
|
348
|
+
const { CopyObjectCommand, DeleteObjectCommand, ListObjectsV2Command, HeadObjectCommand } = await import("@aws-sdk/client-s3");
|
|
349
|
+
const { getS3Client } = await import("@nextblock-cms/utils/server");
|
|
350
|
+
const s3Client = await getS3Client();
|
|
351
|
+
const R2_BUCKET_NAME = process.env.R2_BUCKET_NAME;
|
|
352
|
+
const R2_PUBLIC_URL_BASE = process.env.NEXT_PUBLIC_R2_BASE_URL || '';
|
|
353
|
+
|
|
354
|
+
if (!R2_BUCKET_NAME) {
|
|
355
|
+
return { error: "R2 Bucket not configured for move." };
|
|
356
|
+
}
|
|
357
|
+
if (!s3Client) {
|
|
358
|
+
return { error: "R2 client is not configured for move." };
|
|
359
|
+
}
|
|
360
360
|
|
|
361
361
|
if (!items || items.length === 0) {
|
|
362
362
|
return { error: "No items selected for move." };
|
|
@@ -1,120 +1,120 @@
|
|
|
1
|
-
// app/cms/media/page.tsx
|
|
2
|
-
import React from 'react';
|
|
3
|
-
import { createClient } from "@nextblock-cms/db/server";
|
|
4
|
-
// import Link from "next/link"; // Unused, MediaGridClient handles item links
|
|
5
|
-
import type { Database } from "@nextblock-cms/db";
|
|
6
|
-
// DropdownMenu related imports are now handled within MediaGridClient or its sub-components if needed individually.
|
|
7
|
-
|
|
8
|
-
type Media = Database['public']['Tables']['media']['Row'];
|
|
9
|
-
// If page.tsx itself doesn't directly use DropdownMenu, these can be removed from here.
|
|
10
|
-
// For now, assuming MediaGridClient handles its own dropdowns.
|
|
11
|
-
import MediaUploadForm from "./components/MediaUploadForm";
|
|
12
|
-
// MediaImage and DeleteMediaButtonClient are used by MediaGridClient, not directly here anymore.
|
|
13
|
-
import MediaGridClient from "./components/MediaGridClient"; // Import the new client component
|
|
14
|
-
import FolderNavigator from "./components/FolderNavigator";
|
|
15
|
-
|
|
16
|
-
async function getMediaItems(folder?: string, folderPrefix?: string, search?: string): Promise<Media[]> {
|
|
17
|
-
const supabase = createClient();
|
|
18
|
-
let query = supabase
|
|
19
|
-
.from("media")
|
|
20
|
-
.select("*")
|
|
21
|
-
.order("created_at", { ascending: false });
|
|
22
|
-
|
|
23
|
-
if (folder && folder.trim()) {
|
|
24
|
-
query = query.eq('folder', folder);
|
|
25
|
-
} else if (folderPrefix && folderPrefix.trim()) {
|
|
26
|
-
query = query.ilike('folder', `${folderPrefix}%`);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (search && search.trim()) {
|
|
30
|
-
const term = search.trim();
|
|
31
|
-
query = query.or(`file_name.ilike.%${term}%,description.ilike.%${term}%`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const { data, error } = await query;
|
|
35
|
-
|
|
36
|
-
if (error) {
|
|
37
|
-
console.error("Error fetching media items:", error);
|
|
38
|
-
return [];
|
|
39
|
-
}
|
|
40
|
-
return data || [];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function getDistinctFolders(search?: string): Promise<string[]> {
|
|
44
|
-
const supabase = createClient();
|
|
45
|
-
const { data, error } = await supabase
|
|
46
|
-
.from("media")
|
|
47
|
-
.select("folder")
|
|
48
|
-
.order("folder", { ascending: true });
|
|
49
|
-
if (error) {
|
|
50
|
-
console.error("Error fetching folders:", error);
|
|
51
|
-
return [];
|
|
52
|
-
}
|
|
53
|
-
let folders = (data || [])
|
|
54
|
-
.map((r: any) => r.folder)
|
|
55
|
-
.filter((f: any) => typeof f === 'string' && f.length > 0);
|
|
56
|
-
if (search && search.trim()) {
|
|
57
|
-
const t = search.trim().toLowerCase();
|
|
58
|
-
folders = folders.filter((f: string) => f.toLowerCase().includes(t));
|
|
59
|
-
}
|
|
60
|
-
// Ensure trailing slash for consistency
|
|
61
|
-
return Array.from(new Set(folders.map((f: string) => (f.endsWith('/') ? f : f + '/'))));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function getFolderCounts(): Promise<Record<string, number>> {
|
|
65
|
-
const supabase = createClient();
|
|
66
|
-
const { data, error } = await supabase
|
|
67
|
-
.from("media")
|
|
68
|
-
.select("folder");
|
|
69
|
-
if (error) {
|
|
70
|
-
console.error("Error fetching folder counts:", error);
|
|
71
|
-
return {};
|
|
72
|
-
}
|
|
73
|
-
const counts: Record<string, number> = {};
|
|
74
|
-
(data || []).forEach((row: any) => {
|
|
75
|
-
const f: string | null = row.folder;
|
|
76
|
-
if (!f || typeof f !== 'string' || f.length === 0) return;
|
|
77
|
-
const norm = f.endsWith('/') ? f : `${f}/`;
|
|
78
|
-
// accumulate counts for each prefix in the path
|
|
79
|
-
const parts = norm.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
80
|
-
let prefix = '';
|
|
81
|
-
for (let i = 0; i < parts.length; i++) {
|
|
82
|
-
prefix += (i === 0 ? '' : '/') + parts[i];
|
|
83
|
-
const key = `${prefix}/`;
|
|
84
|
-
counts[key] = (counts[key] || 0) + 1;
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
return counts;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
|
|
91
|
-
|
|
92
|
-
export default async function CmsMediaLibraryPage(props: { searchParams?: Promise<{ folder?: string; folderPrefix?: string; q?: string }> }) {
|
|
93
|
-
const searchParams = (await props.searchParams) || {};
|
|
94
|
-
const selectedFolder = searchParams.folder;
|
|
95
|
-
const selectedFolderPrefix = searchParams.folderPrefix;
|
|
96
|
-
const searchQuery = searchParams.q;
|
|
97
|
-
const [mediaItems, folders, folderCounts] = await Promise.all([
|
|
98
|
-
getMediaItems(selectedFolder, selectedFolderPrefix, searchQuery),
|
|
99
|
-
getDistinctFolders(searchQuery),
|
|
100
|
-
getFolderCounts(),
|
|
101
|
-
]);
|
|
102
|
-
|
|
103
|
-
return (
|
|
104
|
-
<div className="w-full max-w-screen-2xl mx-auto px-4 overflow-x-hidden space-y-6">
|
|
105
|
-
<div className="flex justify-between items-center">
|
|
106
|
-
<h1 className="text-2xl font-semibold">Media Library</h1>
|
|
107
|
-
</div>
|
|
108
|
-
|
|
109
|
-
<MediaUploadForm />
|
|
110
|
-
|
|
111
|
-
{/* Compact folder navigator with top tabs and subfolder pills */}
|
|
112
|
-
<div className="mt-2">
|
|
113
|
-
<FolderNavigator basePath="/cms/media" folders={folders} selectedFolder={selectedFolder || ''} selectedPrefix={selectedFolderPrefix || ''} counts={folderCounts} searchTerm={searchQuery || ''} />
|
|
114
|
-
</div>
|
|
115
|
-
|
|
116
|
-
{/* The media grid and empty state are now handled by MediaGridClient */}
|
|
117
|
-
<MediaGridClient initialMediaItems={mediaItems} r2BaseUrl={R2_BASE_URL} />
|
|
118
|
-
</div>
|
|
119
|
-
);
|
|
120
|
-
}
|
|
1
|
+
// app/cms/media/page.tsx
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { createClient } from "@nextblock-cms/db/server";
|
|
4
|
+
// import Link from "next/link"; // Unused, MediaGridClient handles item links
|
|
5
|
+
import type { Database } from "@nextblock-cms/db";
|
|
6
|
+
// DropdownMenu related imports are now handled within MediaGridClient or its sub-components if needed individually.
|
|
7
|
+
|
|
8
|
+
type Media = Database['public']['Tables']['media']['Row'];
|
|
9
|
+
// If page.tsx itself doesn't directly use DropdownMenu, these can be removed from here.
|
|
10
|
+
// For now, assuming MediaGridClient handles its own dropdowns.
|
|
11
|
+
import MediaUploadForm from "./components/MediaUploadForm";
|
|
12
|
+
// MediaImage and DeleteMediaButtonClient are used by MediaGridClient, not directly here anymore.
|
|
13
|
+
import MediaGridClient from "./components/MediaGridClient"; // Import the new client component
|
|
14
|
+
import FolderNavigator from "./components/FolderNavigator";
|
|
15
|
+
|
|
16
|
+
async function getMediaItems(folder?: string, folderPrefix?: string, search?: string): Promise<Media[]> {
|
|
17
|
+
const supabase = createClient();
|
|
18
|
+
let query = supabase
|
|
19
|
+
.from("media")
|
|
20
|
+
.select("*")
|
|
21
|
+
.order("created_at", { ascending: false });
|
|
22
|
+
|
|
23
|
+
if (folder && folder.trim()) {
|
|
24
|
+
query = query.eq('folder', folder);
|
|
25
|
+
} else if (folderPrefix && folderPrefix.trim()) {
|
|
26
|
+
query = query.ilike('folder', `${folderPrefix}%`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (search && search.trim()) {
|
|
30
|
+
const term = search.trim();
|
|
31
|
+
query = query.or(`file_name.ilike.%${term}%,description.ilike.%${term}%`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const { data, error } = await query;
|
|
35
|
+
|
|
36
|
+
if (error) {
|
|
37
|
+
console.error("Error fetching media items:", error);
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return data || [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function getDistinctFolders(search?: string): Promise<string[]> {
|
|
44
|
+
const supabase = createClient();
|
|
45
|
+
const { data, error } = await supabase
|
|
46
|
+
.from("media")
|
|
47
|
+
.select("folder")
|
|
48
|
+
.order("folder", { ascending: true });
|
|
49
|
+
if (error) {
|
|
50
|
+
console.error("Error fetching folders:", error);
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
let folders = (data || [])
|
|
54
|
+
.map((r: any) => r.folder)
|
|
55
|
+
.filter((f: any) => typeof f === 'string' && f.length > 0);
|
|
56
|
+
if (search && search.trim()) {
|
|
57
|
+
const t = search.trim().toLowerCase();
|
|
58
|
+
folders = folders.filter((f: string) => f.toLowerCase().includes(t));
|
|
59
|
+
}
|
|
60
|
+
// Ensure trailing slash for consistency
|
|
61
|
+
return Array.from(new Set(folders.map((f: string) => (f.endsWith('/') ? f : f + '/'))));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function getFolderCounts(): Promise<Record<string, number>> {
|
|
65
|
+
const supabase = createClient();
|
|
66
|
+
const { data, error } = await supabase
|
|
67
|
+
.from("media")
|
|
68
|
+
.select("folder");
|
|
69
|
+
if (error) {
|
|
70
|
+
console.error("Error fetching folder counts:", error);
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
const counts: Record<string, number> = {};
|
|
74
|
+
(data || []).forEach((row: any) => {
|
|
75
|
+
const f: string | null = row.folder;
|
|
76
|
+
if (!f || typeof f !== 'string' || f.length === 0) return;
|
|
77
|
+
const norm = f.endsWith('/') ? f : `${f}/`;
|
|
78
|
+
// accumulate counts for each prefix in the path
|
|
79
|
+
const parts = norm.replace(/^\/+/, '').split('/').filter(Boolean);
|
|
80
|
+
let prefix = '';
|
|
81
|
+
for (let i = 0; i < parts.length; i++) {
|
|
82
|
+
prefix += (i === 0 ? '' : '/') + parts[i];
|
|
83
|
+
const key = `${prefix}/`;
|
|
84
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return counts;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
|
|
91
|
+
|
|
92
|
+
export default async function CmsMediaLibraryPage(props: { searchParams?: Promise<{ folder?: string; folderPrefix?: string; q?: string }> }) {
|
|
93
|
+
const searchParams = (await props.searchParams) || {};
|
|
94
|
+
const selectedFolder = searchParams.folder;
|
|
95
|
+
const selectedFolderPrefix = searchParams.folderPrefix;
|
|
96
|
+
const searchQuery = searchParams.q;
|
|
97
|
+
const [mediaItems, folders, folderCounts] = await Promise.all([
|
|
98
|
+
getMediaItems(selectedFolder, selectedFolderPrefix, searchQuery),
|
|
99
|
+
getDistinctFolders(searchQuery),
|
|
100
|
+
getFolderCounts(),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div className="w-full max-w-screen-2xl mx-auto px-4 overflow-x-hidden space-y-6">
|
|
105
|
+
<div className="flex justify-between items-center">
|
|
106
|
+
<h1 className="text-2xl font-semibold">Media Library</h1>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<MediaUploadForm />
|
|
110
|
+
|
|
111
|
+
{/* Compact folder navigator with top tabs and subfolder pills */}
|
|
112
|
+
<div className="mt-2">
|
|
113
|
+
<FolderNavigator basePath="/cms/media" folders={folders} selectedFolder={selectedFolder || ''} selectedPrefix={selectedFolderPrefix || ''} counts={folderCounts} searchTerm={searchQuery || ''} />
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* The media grid and empty state are now handled by MediaGridClient */}
|
|
117
|
+
<MediaGridClient initialMediaItems={mediaItems} r2BaseUrl={R2_BASE_URL} />
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|