create-nextblock 0.0.1
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/bin/create-nextblock.js +997 -0
- package/package.json +25 -0
- package/scripts/sync-template.js +284 -0
- package/templates/nextblock-template/.env.example +37 -0
- package/templates/nextblock-template/.swcrc +30 -0
- package/templates/nextblock-template/README.md +194 -0
- package/templates/nextblock-template/app/(auth-pages)/forgot-password/page.tsx +57 -0
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -0
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +28 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-in/page.tsx +67 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +70 -0
- package/templates/nextblock-template/app/ToasterProvider.tsx +17 -0
- package/templates/nextblock-template/app/[slug]/PageClientContent.tsx +147 -0
- package/templates/nextblock-template/app/[slug]/page.tsx +145 -0
- package/templates/nextblock-template/app/[slug]/page.utils.ts +183 -0
- package/templates/nextblock-template/app/actions/email.ts +31 -0
- package/templates/nextblock-template/app/actions/formActions.ts +65 -0
- package/templates/nextblock-template/app/actions/languageActions.ts +130 -0
- package/templates/nextblock-template/app/actions/postActions.ts +80 -0
- package/templates/nextblock-template/app/actions.ts +146 -0
- package/templates/nextblock-template/app/api/process-image/route.ts +210 -0
- package/templates/nextblock-template/app/api/revalidate/route.ts +86 -0
- package/templates/nextblock-template/app/api/revalidate-log/route.ts +23 -0
- package/templates/nextblock-template/app/api/upload/presigned-url/route.ts +106 -0
- package/templates/nextblock-template/app/api/upload/proxy/route.ts +84 -0
- package/templates/nextblock-template/app/auth/callback/route.ts +58 -0
- package/templates/nextblock-template/app/blog/[slug]/PostClientContent.tsx +169 -0
- package/templates/nextblock-template/app/blog/[slug]/page.tsx +177 -0
- package/templates/nextblock-template/app/blog/[slug]/page.utils.ts +136 -0
- package/templates/nextblock-template/app/blog/page.tsx +77 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +321 -0
- package/templates/nextblock-template/app/cms/blocks/actions.ts +434 -0
- package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +348 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +567 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorModal.tsx +98 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeCard.tsx +58 -0
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +62 -0
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +276 -0
- package/templates/nextblock-template/app/cms/blocks/components/DeleteBlockButtonClient.tsx +47 -0
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +182 -0
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +120 -0
- package/templates/nextblock-template/app/cms/blocks/components/SectionConfigPanel.tsx +133 -0
- package/templates/nextblock-template/app/cms/blocks/components/SortableBlockItem.tsx +46 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ButtonBlockEditor.tsx +85 -0
- package/templates/nextblock-template/app/cms/blocks/editors/FormBlockEditor.tsx +182 -0
- package/templates/nextblock-template/app/cms/blocks/editors/HeadingBlockEditor.tsx +111 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +150 -0
- package/templates/nextblock-template/app/cms/blocks/editors/PostsGridBlockEditor.tsx +79 -0
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +337 -0
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +81 -0
- package/templates/nextblock-template/app/cms/blocks/editors/VideoEmbedBlockEditor.tsx +64 -0
- package/templates/nextblock-template/app/cms/components/ConfirmationModal.tsx +51 -0
- package/templates/nextblock-template/app/cms/components/ContentLanguageSwitcher.tsx +145 -0
- package/templates/nextblock-template/app/cms/components/CopyContentFromLanguage.tsx +203 -0
- package/templates/nextblock-template/app/cms/components/LanguageFilterSelect.tsx +69 -0
- package/templates/nextblock-template/app/cms/dashboard/page.tsx +247 -0
- package/templates/nextblock-template/app/cms/layout.tsx +10 -0
- package/templates/nextblock-template/app/cms/media/UploadFolderContext.tsx +22 -0
- package/templates/nextblock-template/app/cms/media/[id]/edit/page.tsx +80 -0
- package/templates/nextblock-template/app/cms/media/actions.ts +577 -0
- package/templates/nextblock-template/app/cms/media/components/DeleteMediaButtonClient.tsx +53 -0
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -0
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -0
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +157 -0
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +275 -0
- package/templates/nextblock-template/app/cms/media/components/MediaImage.tsx +70 -0
- package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +195 -0
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +362 -0
- package/templates/nextblock-template/app/cms/media/page.tsx +120 -0
- package/templates/nextblock-template/app/cms/navigation/[id]/edit/page.tsx +101 -0
- package/templates/nextblock-template/app/cms/navigation/actions.ts +358 -0
- package/templates/nextblock-template/app/cms/navigation/components/DeleteNavItemButton.tsx +52 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationItemForm.tsx +248 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationLanguageSwitcher.tsx +132 -0
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +701 -0
- package/templates/nextblock-template/app/cms/navigation/components/SortableNavItem.tsx +98 -0
- package/templates/nextblock-template/app/cms/navigation/new/page.tsx +26 -0
- package/templates/nextblock-template/app/cms/navigation/page.tsx +102 -0
- package/templates/nextblock-template/app/cms/navigation/utils.ts +51 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +121 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +79 -0
- package/templates/nextblock-template/app/cms/pages/actions.ts +241 -0
- package/templates/nextblock-template/app/cms/pages/components/DeletePageButtonClient.tsx +47 -0
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +253 -0
- package/templates/nextblock-template/app/cms/pages/new/page.tsx +52 -0
- package/templates/nextblock-template/app/cms/pages/page.tsx +232 -0
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +183 -0
- package/templates/nextblock-template/app/cms/posts/actions.ts +309 -0
- package/templates/nextblock-template/app/cms/posts/components/DeletePostButtonClient.tsx +55 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +419 -0
- package/templates/nextblock-template/app/cms/posts/new/page.tsx +21 -0
- package/templates/nextblock-template/app/cms/posts/page.tsx +192 -0
- package/templates/nextblock-template/app/cms/revisions/JsonDiffView.tsx +86 -0
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +201 -0
- package/templates/nextblock-template/app/cms/revisions/actions.ts +84 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +344 -0
- package/templates/nextblock-template/app/cms/revisions/utils.ts +127 -0
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +68 -0
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +78 -0
- package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +32 -0
- package/templates/nextblock-template/app/cms/settings/extra-translations/actions.ts +117 -0
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +216 -0
- package/templates/nextblock-template/app/cms/settings/languages/[id]/edit/page.tsx +77 -0
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +261 -0
- package/templates/nextblock-template/app/cms/settings/languages/components/DeleteLanguageButton.tsx +76 -0
- package/templates/nextblock-template/app/cms/settings/languages/components/LanguageForm.tsx +167 -0
- package/templates/nextblock-template/app/cms/settings/languages/new/page.tsx +34 -0
- package/templates/nextblock-template/app/cms/settings/languages/page.tsx +156 -0
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +19 -0
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +114 -0
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +177 -0
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +11 -0
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +118 -0
- package/templates/nextblock-template/app/cms/settings/logos/types.ts +8 -0
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +91 -0
- package/templates/nextblock-template/app/cms/users/actions.ts +156 -0
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +71 -0
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +138 -0
- package/templates/nextblock-template/app/cms/users/page.tsx +183 -0
- package/templates/nextblock-template/app/favicon.ico +0 -0
- package/templates/nextblock-template/app/globals.css +401 -0
- package/templates/nextblock-template/app/layout.tsx +191 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +68 -0
- package/templates/nextblock-template/app/page.tsx +109 -0
- package/templates/nextblock-template/app/providers.tsx +43 -0
- package/templates/nextblock-template/app/robots.txt/route.ts +19 -0
- package/templates/nextblock-template/app/sitemap.xml/route.ts +63 -0
- package/templates/nextblock-template/app/unauthorized/page.tsx +27 -0
- package/templates/nextblock-template/backup/backup_2025-06-19.sql +8057 -0
- package/templates/nextblock-template/backup/backup_2025-06-20.sql +8159 -0
- package/templates/nextblock-template/backup/backup_2025-07-08.sql +8411 -0
- package/templates/nextblock-template/backup/backup_2025-07-09.sql +8442 -0
- package/templates/nextblock-template/backup/backup_2025-07-10.sql +8442 -0
- package/templates/nextblock-template/backup/backup_2025-10-01.sql +8803 -0
- package/templates/nextblock-template/backup/backup_2025-10-02.sql +9749 -0
- package/templates/nextblock-template/components/BlockRenderer.tsx +119 -0
- package/templates/nextblock-template/components/FooterNavigation.tsx +33 -0
- package/templates/nextblock-template/components/Header.tsx +42 -0
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -0
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +103 -0
- package/templates/nextblock-template/components/ResponsiveNav.tsx +372 -0
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +17 -0
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +93 -0
- package/templates/nextblock-template/components/blocks/PostsGridClient.tsx +180 -0
- package/templates/nextblock-template/components/blocks/renderers/ButtonBlockRenderer.tsx +92 -0
- package/templates/nextblock-template/components/blocks/renderers/ClientTextBlockRenderer.tsx +69 -0
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +98 -0
- package/templates/nextblock-template/components/blocks/renderers/HeadingBlockRenderer.tsx +41 -0
- package/templates/nextblock-template/components/blocks/renderers/HeroBlockRenderer.tsx +240 -0
- package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +79 -0
- package/templates/nextblock-template/components/blocks/renderers/PostsGridBlockRenderer.tsx +33 -0
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +189 -0
- package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +31 -0
- package/templates/nextblock-template/components/blocks/renderers/VideoEmbedBlockRenderer.tsx +59 -0
- package/templates/nextblock-template/components/blocks/renderers/inline/AlertWidgetRenderer.tsx +51 -0
- package/templates/nextblock-template/components/blocks/renderers/inline/CtaWidgetRenderer.tsx +40 -0
- package/templates/nextblock-template/components/blocks/types.ts +8 -0
- package/templates/nextblock-template/components/env-var-warning.tsx +33 -0
- package/templates/nextblock-template/components/form-message.tsx +26 -0
- package/templates/nextblock-template/components/header-auth.tsx +71 -0
- package/templates/nextblock-template/components/submit-button.tsx +23 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +78 -0
- package/templates/nextblock-template/context/AuthContext.tsx +138 -0
- package/templates/nextblock-template/context/CurrentContentContext.tsx +42 -0
- package/templates/nextblock-template/context/LanguageContext.tsx +206 -0
- package/templates/nextblock-template/docs/cms-application-overview.md +56 -0
- package/templates/nextblock-template/docs/cms-architecture-overview.md +73 -0
- package/templates/nextblock-template/docs/files-structure.md +426 -0
- package/templates/nextblock-template/docs/tiptap-bundle-optimization-summary.md +174 -0
- package/templates/nextblock-template/eslint.config.mjs +28 -0
- package/templates/nextblock-template/index.d.ts +5 -0
- package/templates/nextblock-template/lib/blocks/README.md +670 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +1001 -0
- package/templates/nextblock-template/lib/ui/ColorPicker.ts +1 -0
- package/templates/nextblock-template/lib/ui/ConfirmationDialog.ts +1 -0
- package/templates/nextblock-template/lib/ui/CustomSelectWithInput.ts +1 -0
- package/templates/nextblock-template/lib/ui/Skeleton.ts +1 -0
- package/templates/nextblock-template/lib/ui/avatar.ts +1 -0
- package/templates/nextblock-template/lib/ui/badge.ts +1 -0
- package/templates/nextblock-template/lib/ui/button.ts +1 -0
- package/templates/nextblock-template/lib/ui/card.ts +1 -0
- package/templates/nextblock-template/lib/ui/checkbox.ts +1 -0
- package/templates/nextblock-template/lib/ui/dialog.ts +1 -0
- package/templates/nextblock-template/lib/ui/dropdown-menu.ts +1 -0
- package/templates/nextblock-template/lib/ui/input.ts +1 -0
- package/templates/nextblock-template/lib/ui/label.ts +1 -0
- package/templates/nextblock-template/lib/ui/popover.ts +1 -0
- package/templates/nextblock-template/lib/ui/progress.ts +1 -0
- package/templates/nextblock-template/lib/ui/select.ts +1 -0
- package/templates/nextblock-template/lib/ui/separator.ts +1 -0
- package/templates/nextblock-template/lib/ui/table.ts +1 -0
- package/templates/nextblock-template/lib/ui/textarea.ts +1 -0
- package/templates/nextblock-template/lib/ui/tooltip.ts +1 -0
- package/templates/nextblock-template/lib/ui/ui.ts +1 -0
- package/templates/nextblock-template/middleware.ts +206 -0
- package/templates/nextblock-template/next-env.d.ts +6 -0
- package/templates/nextblock-template/next.config.js +99 -0
- package/templates/nextblock-template/package.json +52 -0
- package/templates/nextblock-template/postcss.config.js +6 -0
- package/templates/nextblock-template/project.json +7 -0
- package/templates/nextblock-template/public/.gitkeep +0 -0
- package/templates/nextblock-template/scripts/backfill-image-meta.ts +149 -0
- package/templates/nextblock-template/scripts/backup.js +53 -0
- package/templates/nextblock-template/scripts/test-bundle-optimization.js +114 -0
- package/templates/nextblock-template/tailwind.config.ts +19 -0
- package/templates/nextblock-template/tsconfig.json +62 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useTransition, useEffect } from "react";
|
|
4
|
+
import type { Database } from "@nextblock-cms/db";
|
|
5
|
+
import { Button } from "@nextblock-cms/ui";
|
|
6
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@nextblock-cms/ui";
|
|
7
|
+
import { Progress } from "@nextblock-cms/ui";
|
|
8
|
+
|
|
9
|
+
type Media = Database['public']['Tables']['media']['Row'];
|
|
10
|
+
import { Checkbox } from "@nextblock-cms/ui";
|
|
11
|
+
import { Trash2, Edit3, MoreHorizontal, FileText, Image as ImageIconLucideHost, AlertCircle } from "lucide-react";
|
|
12
|
+
import Link from "next/link";
|
|
13
|
+
import {
|
|
14
|
+
DropdownMenu,
|
|
15
|
+
DropdownMenuContent,
|
|
16
|
+
DropdownMenuItem,
|
|
17
|
+
DropdownMenuTrigger,
|
|
18
|
+
DropdownMenuSeparator,
|
|
19
|
+
} from "@nextblock-cms/ui";
|
|
20
|
+
import MediaImage from "./MediaImage";
|
|
21
|
+
import DeleteMediaButtonClient from "./DeleteMediaButtonClient"; // For single item deletion
|
|
22
|
+
import { deleteMultipleMediaItems, moveSingleMediaItem } from "../actions"; // Server actions for bulk ops
|
|
23
|
+
|
|
24
|
+
interface MediaGridClientProps {
|
|
25
|
+
initialMediaItems: Media[];
|
|
26
|
+
r2BaseUrl: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface MessageState {
|
|
30
|
+
type: "success" | "error";
|
|
31
|
+
text: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default function MediaGridClient({ initialMediaItems, r2BaseUrl }: MediaGridClientProps) {
|
|
35
|
+
const [selectedItems, setSelectedItems] = useState<Array<{ id: string; objectKey: string }>>([]);
|
|
36
|
+
const [mediaItems, setMediaItems] = useState<Media[]>(initialMediaItems);
|
|
37
|
+
const [message, setMessage] = useState<MessageState | null>(null);
|
|
38
|
+
const [isPending, startTransition] = useTransition();
|
|
39
|
+
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
|
|
40
|
+
const [moveFolder, setMoveFolder] = useState<string>("");
|
|
41
|
+
const [isMoving, setIsMoving] = useState(false);
|
|
42
|
+
const [moveProgress, setMoveProgress] = useState(0);
|
|
43
|
+
const [moveError, setMoveError] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setMediaItems(initialMediaItems);
|
|
47
|
+
}, [initialMediaItems]);
|
|
48
|
+
|
|
49
|
+
const handleSelectionChange = (itemId: string, objectKey: string, checked: boolean) => {
|
|
50
|
+
setSelectedItems((prevSelected) => {
|
|
51
|
+
if (checked) {
|
|
52
|
+
return [...prevSelected, { id: itemId, objectKey }];
|
|
53
|
+
} else {
|
|
54
|
+
return prevSelected.filter((item) => item.id !== itemId);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const handleBulkDelete = async () => {
|
|
60
|
+
if (selectedItems.length === 0) {
|
|
61
|
+
setMessage({ type: "error", text: "No items selected for deletion." });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setMessage(null);
|
|
66
|
+
startTransition(async () => {
|
|
67
|
+
const result = await deleteMultipleMediaItems(selectedItems);
|
|
68
|
+
if (result.error) {
|
|
69
|
+
setMessage({ type: "error", text: result.error });
|
|
70
|
+
} else if (result.success) {
|
|
71
|
+
setMessage({ type: "success", text: result.success });
|
|
72
|
+
// The page should revalidate and fetch new mediaItems.
|
|
73
|
+
// For immediate UI update, we can filter out deleted items:
|
|
74
|
+
setMediaItems((prevItems) =>
|
|
75
|
+
prevItems.filter((item) => !selectedItems.find((selected) => selected.id === item.id))
|
|
76
|
+
);
|
|
77
|
+
setSelectedItems([]); // Clear selection
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const beginBulkMove = () => {
|
|
83
|
+
if (selectedItems.length === 0) {
|
|
84
|
+
setMessage({ type: "error", text: "No items selected for move." });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setMoveFolder("");
|
|
88
|
+
setMoveDialogOpen(true);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const confirmBulkMove = async () => {
|
|
92
|
+
const dest = moveFolder.trim();
|
|
93
|
+
if (!dest) {
|
|
94
|
+
setMoveError("Please enter a destination folder.");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
setMoveError(null);
|
|
98
|
+
setIsMoving(true);
|
|
99
|
+
setMoveProgress(0);
|
|
100
|
+
try {
|
|
101
|
+
const total = selectedItems.length;
|
|
102
|
+
let moved = 0;
|
|
103
|
+
let hadError = false;
|
|
104
|
+
for (const item of selectedItems) {
|
|
105
|
+
const res = await moveSingleMediaItem(item, dest);
|
|
106
|
+
if ((res as any)?.error) {
|
|
107
|
+
// Accumulate errors but keep going
|
|
108
|
+
hadError = true;
|
|
109
|
+
setMoveError((prev) => (prev ? prev + " | " : "") + (res as any).error);
|
|
110
|
+
} else {
|
|
111
|
+
// Update local list
|
|
112
|
+
setMediaItems((prev) => prev.map((m) => {
|
|
113
|
+
if (m.id !== item.id) return m;
|
|
114
|
+
const filename = m.object_key.substring(m.object_key.lastIndexOf('/') + 1);
|
|
115
|
+
const folder = ensureFolderSlash(dest);
|
|
116
|
+
return { ...m, object_key: `${folder}${filename}`, folder } as any;
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
moved += 1;
|
|
120
|
+
setMoveProgress(Math.round((moved / total) * 100));
|
|
121
|
+
}
|
|
122
|
+
if (!hadError) setMessage({ type: "success", text: `Moved ${selectedItems.length} item(s).` });
|
|
123
|
+
setSelectedItems([]);
|
|
124
|
+
setMoveDialogOpen(false);
|
|
125
|
+
} finally {
|
|
126
|
+
setIsMoving(false);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const ensureFolderSlash = (f: string) => (f.endsWith('/') ? f : `${f}/`);
|
|
131
|
+
|
|
132
|
+
const isSelected = (itemId: string) => {
|
|
133
|
+
return selectedItems.some((item) => item.id === itemId);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="space-y-6">
|
|
138
|
+
{selectedItems.length > 0 && (
|
|
139
|
+
<div className="flex flex-col gap-3 p-4 border rounded-lg bg-card">
|
|
140
|
+
<div className="flex items-center justify-between">
|
|
141
|
+
<p className="text-sm font-medium">{selectedItems.length} item(s) selected</p>
|
|
142
|
+
<div className="flex items-center gap-2">
|
|
143
|
+
<Button onClick={beginBulkMove} disabled={isPending}>
|
|
144
|
+
Move Selected
|
|
145
|
+
</Button>
|
|
146
|
+
<Button
|
|
147
|
+
variant="destructive"
|
|
148
|
+
onClick={handleBulkDelete}
|
|
149
|
+
disabled={isPending}
|
|
150
|
+
>
|
|
151
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
152
|
+
{isPending ? "Deleting..." : "Delete Selected"}
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
<Dialog open={moveDialogOpen} onOpenChange={(open) => { if (!isMoving) setMoveDialogOpen(open); }}>
|
|
160
|
+
<DialogContent>
|
|
161
|
+
<DialogHeader>
|
|
162
|
+
<DialogTitle>Move {selectedItems.length} item(s)</DialogTitle>
|
|
163
|
+
</DialogHeader>
|
|
164
|
+
<div className="space-y-3">
|
|
165
|
+
<input
|
|
166
|
+
type="text"
|
|
167
|
+
value={moveFolder}
|
|
168
|
+
onChange={(e) => setMoveFolder(e.target.value)}
|
|
169
|
+
placeholder="Destination folder e.g. uploads/images/"
|
|
170
|
+
className="w-full border rounded-md px-3 h-9"
|
|
171
|
+
disabled={isMoving}
|
|
172
|
+
/>
|
|
173
|
+
{isMoving && (
|
|
174
|
+
<div className="space-y-2">
|
|
175
|
+
<Progress value={moveProgress} />
|
|
176
|
+
<p className="text-xs text-muted-foreground">Moving... {moveProgress}%</p>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
{moveError && <p className="text-sm text-red-600">{moveError}</p>}
|
|
180
|
+
</div>
|
|
181
|
+
<DialogFooter>
|
|
182
|
+
<Button variant="outline" onClick={() => setMoveDialogOpen(false)} disabled={isMoving}>Cancel</Button>
|
|
183
|
+
<Button onClick={confirmBulkMove} disabled={isMoving || selectedItems.length === 0}>Start Move</Button>
|
|
184
|
+
</DialogFooter>
|
|
185
|
+
</DialogContent>
|
|
186
|
+
</Dialog>
|
|
187
|
+
|
|
188
|
+
{message && (
|
|
189
|
+
<div
|
|
190
|
+
className={`p-4 rounded-md text-sm ${
|
|
191
|
+
message.type === "success"
|
|
192
|
+
? "bg-green-100 border border-green-200 text-green-700"
|
|
193
|
+
: "bg-red-100 border border-red-200 text-red-700"
|
|
194
|
+
}`}
|
|
195
|
+
>
|
|
196
|
+
<div className="flex items-center">
|
|
197
|
+
{message.type === 'error' && <AlertCircle className="h-5 w-5 mr-2" />}
|
|
198
|
+
{message.text}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
|
|
203
|
+
{mediaItems.length === 0 && selectedItems.length === 0 ? (
|
|
204
|
+
<div className="text-center py-10 border rounded-lg mt-6">
|
|
205
|
+
<ImageIconLucideHost className="mx-auto h-12 w-12 text-muted-foreground" />
|
|
206
|
+
<h3 className="mt-2 text-sm font-medium text-foreground">No media found</h3>
|
|
207
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
208
|
+
Upload some files to get started.
|
|
209
|
+
</p>
|
|
210
|
+
</div>
|
|
211
|
+
) : (
|
|
212
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 mt-6">
|
|
213
|
+
{mediaItems.map((item) => (
|
|
214
|
+
<div
|
|
215
|
+
key={item.id}
|
|
216
|
+
className={`group relative border rounded-lg overflow-hidden shadow-sm aspect-square bg-muted/20 transition-all
|
|
217
|
+
${isSelected(item.id) ? "ring-2 ring-primary ring-offset-2" : ""}`}
|
|
218
|
+
>
|
|
219
|
+
<div className="absolute top-2 left-2 z-10">
|
|
220
|
+
<Checkbox
|
|
221
|
+
id={`select-${item.id}`}
|
|
222
|
+
checked={isSelected(item.id)}
|
|
223
|
+
onCheckedChange={(checked) => {
|
|
224
|
+
handleSelectionChange(item.id, item.object_key, !!checked);
|
|
225
|
+
}}
|
|
226
|
+
aria-label={`Select ${item.file_name}`}
|
|
227
|
+
className="bg-white/70 hover:bg-white border-slate-400 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{item.file_type?.startsWith("image/") ? (
|
|
232
|
+
<MediaImage
|
|
233
|
+
src={`${r2BaseUrl}/${item.object_key}`}
|
|
234
|
+
alt={item.description || item.file_name}
|
|
235
|
+
width={item.width || 500} // Default width if null
|
|
236
|
+
height={item.height || 500} // Default height if null
|
|
237
|
+
blurDataURL={item.blur_data_url || undefined}
|
|
238
|
+
className="h-full w-full object-contain transition-transform group-hover:scale-105"
|
|
239
|
+
/>
|
|
240
|
+
) : (
|
|
241
|
+
<div className="h-full w-full bg-muted flex flex-col items-center justify-center p-2">
|
|
242
|
+
<FileText className="h-12 w-12 text-muted-foreground mb-2" />
|
|
243
|
+
<p className="text-xs text-center text-muted-foreground truncate w-full" title={item.file_name}>
|
|
244
|
+
{item.file_name}
|
|
245
|
+
</p>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
<div className="absolute inset-0 bg-gradient-to-t from-black/50 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2">
|
|
249
|
+
<div className="text-xs text-black truncate pt-1 ps-5" title={item.file_name}>{item.file_name}</div>
|
|
250
|
+
<div className="self-end">
|
|
251
|
+
<DropdownMenu>
|
|
252
|
+
<DropdownMenuTrigger asChild>
|
|
253
|
+
<Button variant="secondary" size="icon" className="text-white bg-black/40 hover:bg-black/60 h-7 w-7 rounded-full">
|
|
254
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
255
|
+
</Button>
|
|
256
|
+
</DropdownMenuTrigger>
|
|
257
|
+
<DropdownMenuContent align="end">
|
|
258
|
+
<DropdownMenuItem asChild>
|
|
259
|
+
<Link href={`/cms/media/${item.id}/edit`} className="flex items-center cursor-pointer">
|
|
260
|
+
<Edit3 className="mr-2 h-4 w-4" /> Edit Details
|
|
261
|
+
</Link>
|
|
262
|
+
</DropdownMenuItem>
|
|
263
|
+
<DropdownMenuSeparator />
|
|
264
|
+
<DeleteMediaButtonClient mediaItem={item} />
|
|
265
|
+
</DropdownMenuContent>
|
|
266
|
+
</DropdownMenu>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
))}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// app/cms/media/components/MediaImage.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import React from 'react'
|
|
5
|
+
import Image from 'next/image'
|
|
6
|
+
import { cn } from '@nextblock-cms/utils'
|
|
7
|
+
|
|
8
|
+
interface MediaImageProps {
|
|
9
|
+
src: string
|
|
10
|
+
alt: string
|
|
11
|
+
width?: number | null
|
|
12
|
+
height?: number | null
|
|
13
|
+
blurDataURL?: string | null
|
|
14
|
+
className?: string
|
|
15
|
+
priority?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MediaImage: React.FC<MediaImageProps> = ({
|
|
19
|
+
src,
|
|
20
|
+
alt,
|
|
21
|
+
width,
|
|
22
|
+
height,
|
|
23
|
+
blurDataURL,
|
|
24
|
+
className,
|
|
25
|
+
priority = false,
|
|
26
|
+
}) => {
|
|
27
|
+
const isValid = src && width && height && width > 0 && height > 0
|
|
28
|
+
|
|
29
|
+
if (!isValid) {
|
|
30
|
+
const placeholderWidth = typeof width === 'number' && width > 0 ? width : 100
|
|
31
|
+
const placeholderHeight =
|
|
32
|
+
typeof height === 'number' && height > 0 ? height : 100
|
|
33
|
+
|
|
34
|
+
const hasSizeClass = className?.includes('w-') || className?.includes('h-')
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
className={cn(
|
|
39
|
+
'bg-muted text-muted-foreground flex items-center justify-center',
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
style={
|
|
43
|
+
!hasSizeClass
|
|
44
|
+
? {
|
|
45
|
+
width: placeholderWidth,
|
|
46
|
+
height: placeholderHeight,
|
|
47
|
+
}
|
|
48
|
+
: {}
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
Invalid Image
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Image
|
|
58
|
+
src={src}
|
|
59
|
+
alt={alt}
|
|
60
|
+
width={width}
|
|
61
|
+
height={height}
|
|
62
|
+
className={className}
|
|
63
|
+
placeholder={blurDataURL ? 'blur' : 'empty'}
|
|
64
|
+
blurDataURL={blurDataURL || undefined}
|
|
65
|
+
priority={priority}
|
|
66
|
+
/>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export default MediaImage
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import type { Database } from "@nextblock-cms/db";
|
|
5
|
+
import { createClient as createBrowserClient } from "@nextblock-cms/db";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogHeader,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogTrigger,
|
|
12
|
+
DialogFooter,
|
|
13
|
+
DialogClose,
|
|
14
|
+
Button,
|
|
15
|
+
Input,
|
|
16
|
+
Separator,
|
|
17
|
+
} from "@nextblock-cms/ui";
|
|
18
|
+
import { Search, CheckCircle } from "lucide-react";
|
|
19
|
+
import Image from "next/image";
|
|
20
|
+
import MediaUploadForm from "./MediaUploadForm";
|
|
21
|
+
|
|
22
|
+
type Media = Database["public"]["Tables"]["media"]["Row"];
|
|
23
|
+
|
|
24
|
+
const R2_BASE_URL = process.env.NEXT_PUBLIC_R2_BASE_URL || "";
|
|
25
|
+
|
|
26
|
+
interface MediaPickerDialogProps {
|
|
27
|
+
triggerLabel?: string;
|
|
28
|
+
triggerVariant?: "default" | "outline" | "secondary" | "destructive" | "ghost";
|
|
29
|
+
onSelect: (media: Media) => void;
|
|
30
|
+
accept?: (m: Media) => boolean; // filter, e.g. only images
|
|
31
|
+
title?: string;
|
|
32
|
+
open?: boolean; onOpenChange?: (open: boolean) => void; hideTrigger?: boolean;
|
|
33
|
+
defaultFolder?: string; // optional folder to pre-populate upload
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function MediaPickerDialog({
|
|
37
|
+
triggerLabel = "Select from Library",
|
|
38
|
+
triggerVariant = "outline",
|
|
39
|
+
onSelect,
|
|
40
|
+
accept,
|
|
41
|
+
title = "Select or Upload Media",
|
|
42
|
+
open,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
hideTrigger,
|
|
45
|
+
defaultFolder,
|
|
46
|
+
}: MediaPickerDialogProps) {
|
|
47
|
+
const [internalOpen, setInternalOpen] = useState(false);
|
|
48
|
+
const isControlled = typeof open === "boolean";
|
|
49
|
+
const isOpen = isControlled ? (open as boolean) : internalOpen;
|
|
50
|
+
const setIsOpen = (v: boolean) => {
|
|
51
|
+
if (!isControlled) setInternalOpen(v);
|
|
52
|
+
onOpenChange?.(v);
|
|
53
|
+
};
|
|
54
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
55
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
56
|
+
const [items, setItems] = useState<Media[]>([]);
|
|
57
|
+
const supabase = useMemo(() => createBrowserClient(), []);
|
|
58
|
+
|
|
59
|
+
const fetchLibrary = useCallback(async () => {
|
|
60
|
+
setIsLoading(true);
|
|
61
|
+
try {
|
|
62
|
+
let query = supabase
|
|
63
|
+
.from("media")
|
|
64
|
+
.select("*")
|
|
65
|
+
.order("created_at", { ascending: false })
|
|
66
|
+
.limit(50);
|
|
67
|
+
if (searchTerm) {
|
|
68
|
+
query = query.ilike("file_name", `%${searchTerm}%`);
|
|
69
|
+
}
|
|
70
|
+
const { data, error } = await query;
|
|
71
|
+
if (error) {
|
|
72
|
+
console.error("Error fetching media library:", error);
|
|
73
|
+
setItems([]);
|
|
74
|
+
} else {
|
|
75
|
+
setItems(data || []);
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
setIsLoading(false);
|
|
79
|
+
}
|
|
80
|
+
}, [supabase, searchTerm]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (isOpen) fetchLibrary();
|
|
84
|
+
}, [isOpen, fetchLibrary]);
|
|
85
|
+
|
|
86
|
+
const filtered = useMemo(() => {
|
|
87
|
+
return accept ? items.filter(accept) : items;
|
|
88
|
+
}, [items, accept]);
|
|
89
|
+
|
|
90
|
+
const handleSelect = (media: Media) => {
|
|
91
|
+
onSelect(media);
|
|
92
|
+
setIsOpen(false);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
97
|
+
{!hideTrigger && (
|
|
98
|
+
<DialogTrigger asChild>
|
|
99
|
+
<Button type="button" variant={triggerVariant} size="sm">
|
|
100
|
+
{triggerLabel}
|
|
101
|
+
</Button>
|
|
102
|
+
</DialogTrigger>
|
|
103
|
+
)}
|
|
104
|
+
<DialogContent className="sm:max-w-[650px] md:max-w-[800px] lg:max-w-[1000px] max-h-[90vh] flex flex-col">
|
|
105
|
+
<DialogHeader>
|
|
106
|
+
<DialogTitle>{title}</DialogTitle>
|
|
107
|
+
</DialogHeader>
|
|
108
|
+
|
|
109
|
+
<div className="p-1">
|
|
110
|
+
<MediaUploadForm
|
|
111
|
+
returnJustData={true}
|
|
112
|
+
defaultFolder={defaultFolder}
|
|
113
|
+
onUploadSuccess={(newMedia) => {
|
|
114
|
+
setItems((prev) => [newMedia, ...prev.filter((m) => m.id !== newMedia.id)]);
|
|
115
|
+
handleSelect(newMedia);
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<Separator className="my-4" />
|
|
121
|
+
|
|
122
|
+
<div className="flex flex-col flex-grow overflow-hidden">
|
|
123
|
+
<h3 className="text-lg font-medium mb-3 text-center">Or Select from Library</h3>
|
|
124
|
+
<div className="relative mb-2">
|
|
125
|
+
<Input
|
|
126
|
+
type="search"
|
|
127
|
+
placeholder="Search library..."
|
|
128
|
+
value={searchTerm}
|
|
129
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
130
|
+
className="pl-10"
|
|
131
|
+
/>
|
|
132
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
133
|
+
</div>
|
|
134
|
+
{isLoading ? (
|
|
135
|
+
<div className="flex-grow flex items-center justify-center">
|
|
136
|
+
<p>Loading media...</p>
|
|
137
|
+
</div>
|
|
138
|
+
) : filtered.length === 0 ? (
|
|
139
|
+
<div className="flex-grow flex items-center justify-center">
|
|
140
|
+
<p>No media found.</p>
|
|
141
|
+
</div>
|
|
142
|
+
) : (
|
|
143
|
+
<div className="flex flex-wrap gap-3 overflow-y-auto min-h-0 pr-2 pb-2">
|
|
144
|
+
{filtered.map((media) => (
|
|
145
|
+
<button
|
|
146
|
+
key={media.id}
|
|
147
|
+
type="button"
|
|
148
|
+
className="relative aspect-square border rounded-md overflow-hidden group focus:outline-none focus:ring-2 focus:ring-primary min-w-0 w-1/3 sm:w-1/4 md:w-1/5 lg:w-1/6"
|
|
149
|
+
onClick={() => handleSelect(media)}
|
|
150
|
+
>
|
|
151
|
+
{media.file_type?.startsWith("image/") && typeof media.width === "number" && typeof media.height === "number" && media.width > 0 && media.height > 0 ? (
|
|
152
|
+
<>
|
|
153
|
+
<Image
|
|
154
|
+
src={`${R2_BASE_URL}/${media.object_key}`}
|
|
155
|
+
alt={media.description || media.file_name || "Media library image"}
|
|
156
|
+
width={media.width}
|
|
157
|
+
height={media.height}
|
|
158
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
159
|
+
placeholder={media.blur_data_url ? "blur" : "empty"}
|
|
160
|
+
blurDataURL={media.blur_data_url || undefined}
|
|
161
|
+
sizes="(max-width: 639px) 33vw, (max-width: 767px) 25vw, (max-width: 1023px) 20vw, 17vw"
|
|
162
|
+
/>
|
|
163
|
+
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 group-focus:opacity-100 transition-opacity flex items-center justify-center">
|
|
164
|
+
<CheckCircle className="h-8 w-8 text-white" />
|
|
165
|
+
</div>
|
|
166
|
+
</>
|
|
167
|
+
) : (
|
|
168
|
+
<div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground p-1 text-center">
|
|
169
|
+
Preview unavailable
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
<p className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 truncate text-center">
|
|
173
|
+
{media.file_name}
|
|
174
|
+
</p>
|
|
175
|
+
</button>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
<DialogFooter className="mt-auto pt-4">
|
|
181
|
+
<DialogClose asChild>
|
|
182
|
+
<Button type="button" variant="outline">
|
|
183
|
+
Close
|
|
184
|
+
</Button>
|
|
185
|
+
</DialogClose>
|
|
186
|
+
</DialogFooter>
|
|
187
|
+
</DialogContent>
|
|
188
|
+
</Dialog>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
|