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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/scripts/sync-template.js +70 -52
  3. package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +67 -67
  4. package/templates/nextblock-template/app/[slug]/page.tsx +4 -4
  5. package/templates/nextblock-template/app/cms/blocks/actions.ts +10 -10
  6. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +348 -348
  7. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +8 -8
  8. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +10 -10
  9. package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +81 -81
  10. package/templates/nextblock-template/app/cms/media/actions.ts +35 -35
  11. package/templates/nextblock-template/app/cms/media/page.tsx +120 -120
  12. package/templates/nextblock-template/app/cms/revisions/JsonDiffView.tsx +86 -86
  13. package/templates/nextblock-template/app/cms/revisions/service.ts +344 -344
  14. package/templates/nextblock-template/app/providers.tsx +2 -2
  15. package/templates/nextblock-template/components/BlockRenderer.tsx +9 -9
  16. package/templates/nextblock-template/components/ResponsiveNav.tsx +22 -22
  17. package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
  18. package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +26 -26
  19. package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +41 -41
  20. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +7 -7
  21. package/templates/nextblock-template/components/theme-switcher.tsx +78 -78
  22. package/templates/nextblock-template/eslint.config.mjs +35 -37
  23. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +19 -19
  24. package/templates/nextblock-template/next-env.d.ts +6 -6
  25. 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
+ }