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,577 @@
|
|
|
1
|
+
// app/cms/media/actions.ts
|
|
2
|
+
"use server";
|
|
3
|
+
|
|
4
|
+
import { createClient } from "@nextblock-cms/db/server";
|
|
5
|
+
import { revalidatePath } from "next/cache";
|
|
6
|
+
import type { Database } from "@nextblock-cms/db";
|
|
7
|
+
import { encodedRedirect } from "@nextblock-cms/utils/server";
|
|
8
|
+
|
|
9
|
+
type Media = Database['public']['Tables']['media']['Row'];
|
|
10
|
+
|
|
11
|
+
// --- recordMediaUpload and updateMediaItem functions to be updated similarly ---
|
|
12
|
+
|
|
13
|
+
// Define the structure for a single variant, mirroring what /api/process-image returns
|
|
14
|
+
interface ImageVariant {
|
|
15
|
+
objectKey: string;
|
|
16
|
+
url: string;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
fileType: string;
|
|
20
|
+
sizeBytes: number;
|
|
21
|
+
variantLabel: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function recordMediaUpload(payload: {
|
|
25
|
+
fileName: string; // Original filename, can still be useful
|
|
26
|
+
// objectKey: string; // This might now be derived from the primary variant
|
|
27
|
+
// fileType: string; // This will come from the primary variant
|
|
28
|
+
// sizeBytes: number; // This will come from the primary variant
|
|
29
|
+
description?: string;
|
|
30
|
+
// width?: number; // This will come from the primary variant
|
|
31
|
+
// height?: number; // This will come from the primary variant
|
|
32
|
+
r2OriginalKey: string; // Key of the initially uploaded file in R2
|
|
33
|
+
r2Variants: ImageVariant[]; // Array of processed variants
|
|
34
|
+
originalImageDetails: ImageVariant; // Details of the original uploaded image from process-image
|
|
35
|
+
blurDataUrl?: string; // Optional, if generated and passed from client
|
|
36
|
+
}, returnJustData?: boolean): Promise<{ success: true; data: Media } | { error: string } | void> {
|
|
37
|
+
const supabase = createClient();
|
|
38
|
+
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
39
|
+
|
|
40
|
+
if (authError || !user) {
|
|
41
|
+
if (returnJustData) return { error: "User not authenticated for media record." };
|
|
42
|
+
return encodedRedirect("error", "/cms/media", "User not authenticated for media record.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { data: profile } = await supabase
|
|
46
|
+
.from("profiles")
|
|
47
|
+
.select("role")
|
|
48
|
+
.eq("id", user.id)
|
|
49
|
+
.single();
|
|
50
|
+
if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
|
|
51
|
+
if (returnJustData) return { error: "Forbidden: Insufficient permissions to record media." };
|
|
52
|
+
return encodedRedirect("error", "/cms/media", "Forbidden: Insufficient permissions to record media.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Determine the primary variant to use for the main table columns
|
|
56
|
+
// This logic can be adjusted. Prioritize 'original_avif', then 'xlarge_avif', then the first variant.
|
|
57
|
+
let primaryVariant =
|
|
58
|
+
payload.r2Variants.find(v => v.variantLabel === 'original_avif') ||
|
|
59
|
+
payload.r2Variants.find(v => v.variantLabel === 'xlarge_avif') ||
|
|
60
|
+
payload.r2Variants[0] || // Fallback to the first variant if specific ones aren't found
|
|
61
|
+
payload.originalImageDetails; // Fallback to original uploaded details if no variants
|
|
62
|
+
|
|
63
|
+
if (!primaryVariant) {
|
|
64
|
+
// This case should ideally not happen if originalImageDetails is always present
|
|
65
|
+
// but as a safeguard:
|
|
66
|
+
primaryVariant = payload.originalImageDetails || {
|
|
67
|
+
objectKey: payload.r2OriginalKey,
|
|
68
|
+
url: `YOUR_R2_PUBLIC_BASE_URL/${payload.r2OriginalKey}`, // Construct URL if needed
|
|
69
|
+
width: 0, // Or fetch if necessary, though client sends initial dimensions
|
|
70
|
+
height: 0,
|
|
71
|
+
fileType: 'application/octet-stream', // A generic fallback
|
|
72
|
+
sizeBytes: 0,
|
|
73
|
+
variantLabel: 'fallback_original'
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Construct the full list of variants to store, including the original uploaded file details
|
|
78
|
+
const allVariantsToStore = [
|
|
79
|
+
...(payload.originalImageDetails && payload.originalImageDetails.objectKey !== primaryVariant.objectKey ? [payload.originalImageDetails] : []),
|
|
80
|
+
...payload.r2Variants,
|
|
81
|
+
].filter((variant, index, self) =>
|
|
82
|
+
index === self.findIndex((v) => v.objectKey === variant.objectKey)
|
|
83
|
+
); // Ensure unique variants by objectKey
|
|
84
|
+
|
|
85
|
+
// If no description provided for images, derive one from the filename (un-slug)
|
|
86
|
+
const deriveAltFromFilename = (name: string) => {
|
|
87
|
+
const lastDot = name.lastIndexOf('.');
|
|
88
|
+
const base = lastDot > 0 ? name.substring(0, lastDot) : name;
|
|
89
|
+
const spaced = base.replace(/[-+_\\]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
90
|
+
return spaced.replace(/\b\w+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const computedDescription = payload.description
|
|
94
|
+
?? ((primaryVariant.fileType?.startsWith('image/') || payload.originalImageDetails?.fileType?.startsWith('image/'))
|
|
95
|
+
? deriveAltFromFilename(payload.fileName)
|
|
96
|
+
: null);
|
|
97
|
+
|
|
98
|
+
const mediaData: Omit<Media, 'id' | 'created_at' | 'updated_at'> & { uploader_id: string } = {
|
|
99
|
+
uploader_id: user.id,
|
|
100
|
+
file_name: payload.fileName, // Keep original file name for reference
|
|
101
|
+
object_key: primaryVariant.objectKey, // Key of the primary display version
|
|
102
|
+
file_path: primaryVariant.objectKey,
|
|
103
|
+
folder: (() => {
|
|
104
|
+
const match = primaryVariant.objectKey.match(/^(.*\/)?.*$/);
|
|
105
|
+
const path = match && match[1] ? match[1] : null;
|
|
106
|
+
return path;
|
|
107
|
+
})(),
|
|
108
|
+
// file_url is removed as it's not in the Media type; URLs are in variants
|
|
109
|
+
file_type: primaryVariant.fileType,
|
|
110
|
+
size_bytes: primaryVariant.sizeBytes,
|
|
111
|
+
description: computedDescription,
|
|
112
|
+
width: primaryVariant.width,
|
|
113
|
+
height: primaryVariant.height,
|
|
114
|
+
variants: allVariantsToStore as any, // Store all variants including the original
|
|
115
|
+
blur_data_url: payload.blurDataUrl || null, // Store if provided
|
|
116
|
+
// Ensure all other required fields for 'Media' type are present or nullable
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const { data: newMedia, error } = await supabase
|
|
120
|
+
.from("media")
|
|
121
|
+
.insert(mediaData)
|
|
122
|
+
.select()
|
|
123
|
+
.single();
|
|
124
|
+
|
|
125
|
+
if (error) {
|
|
126
|
+
console.error("Error recording media upload:", error);
|
|
127
|
+
if (returnJustData) return { error: `Failed to record media: ${error.message}` };
|
|
128
|
+
return encodedRedirect("error", "/cms/media", `Failed to record media: ${error.message}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
revalidatePath("/cms/media");
|
|
132
|
+
if (returnJustData) {
|
|
133
|
+
return { success: true, data: newMedia as Media };
|
|
134
|
+
} else {
|
|
135
|
+
encodedRedirect("success", "/cms/media", "Media recorded successfully.");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
export async function updateMediaItem(
|
|
141
|
+
mediaId: string,
|
|
142
|
+
payload: { description?: string; file_name?: string },
|
|
143
|
+
returnJustData?: boolean
|
|
144
|
+
) {
|
|
145
|
+
const supabase = createClient();
|
|
146
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
147
|
+
const mediaEditPath = `/cms/media/${mediaId}/edit`;
|
|
148
|
+
|
|
149
|
+
if (!user) {
|
|
150
|
+
if (returnJustData) return { error: "User not authenticated for media update." };
|
|
151
|
+
return encodedRedirect("error", mediaEditPath, "User not authenticated for media update.");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
|
|
155
|
+
if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
|
|
156
|
+
if (returnJustData) return { error: "Forbidden to update media." };
|
|
157
|
+
return encodedRedirect("error", mediaEditPath, "Forbidden to update media.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const updateData: Partial<Pick<Media, 'description' | 'file_name' | 'updated_at'>> = {};
|
|
161
|
+
if (payload.description !== undefined) updateData.description = payload.description;
|
|
162
|
+
if (payload.file_name !== undefined) updateData.file_name = payload.file_name;
|
|
163
|
+
|
|
164
|
+
if (Object.keys(updateData).length === 0) {
|
|
165
|
+
if (returnJustData) return { error: "No updatable fields provided for media." };
|
|
166
|
+
return encodedRedirect("error", mediaEditPath, "No updatable fields provided for media.");
|
|
167
|
+
}
|
|
168
|
+
updateData.updated_at = new Date().toISOString();
|
|
169
|
+
|
|
170
|
+
const { data: updatedMedia, error } = await supabase
|
|
171
|
+
.from("media")
|
|
172
|
+
.update(updateData)
|
|
173
|
+
.eq("id", mediaId)
|
|
174
|
+
.select()
|
|
175
|
+
.single();
|
|
176
|
+
|
|
177
|
+
if (error) {
|
|
178
|
+
console.error("Error updating media item:", error);
|
|
179
|
+
if (returnJustData) return { error: `Error updating media: ${error.message}` };
|
|
180
|
+
return encodedRedirect("error", mediaEditPath, `Error updating media: ${error.message}`);
|
|
181
|
+
}
|
|
182
|
+
revalidatePath("/cms/media");
|
|
183
|
+
revalidatePath(mediaEditPath);
|
|
184
|
+
if (returnJustData) {
|
|
185
|
+
return { success: true, media: updatedMedia } as { success: true; media: Media };
|
|
186
|
+
}
|
|
187
|
+
encodedRedirect("success", mediaEditPath, "Media item updated successfully.");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
export async function deleteMediaItem(mediaId: string, objectKey: string) {
|
|
192
|
+
const supabase = createClient();
|
|
193
|
+
const { data: { user } } = await supabase.auth.getUser();
|
|
194
|
+
|
|
195
|
+
if (!user) {
|
|
196
|
+
return encodedRedirect("error", "/cms/media", "User not authenticated.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
|
|
200
|
+
if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
|
|
201
|
+
return encodedRedirect("error", "/cms/media", "Forbidden: Insufficient permissions.");
|
|
202
|
+
}
|
|
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
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const deleteCommand = new DeleteObjectCommand({
|
|
218
|
+
Bucket: R2_BUCKET_NAME,
|
|
219
|
+
Key: objectKey,
|
|
220
|
+
});
|
|
221
|
+
await s3Client.send(deleteCommand);
|
|
222
|
+
} catch (r2Error: unknown) {
|
|
223
|
+
console.error("Error deleting from R2:", r2Error);
|
|
224
|
+
// Decide if you want to proceed with DB deletion if R2 deletion fails
|
|
225
|
+
// It's often better to proceed and log, or handle more gracefully.
|
|
226
|
+
// For now, we'll let it proceed to DB deletion but the error is logged.
|
|
227
|
+
// You could redirect with a partial success/warning message here.
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { error: dbError } = await supabase.from("media").delete().eq("id", mediaId);
|
|
231
|
+
|
|
232
|
+
if (dbError) {
|
|
233
|
+
console.error("Error deleting media record from DB:", dbError);
|
|
234
|
+
return encodedRedirect("error", "/cms/media", `Failed to delete media record: ${dbError.message}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
revalidatePath("/cms/media");
|
|
238
|
+
encodedRedirect("success", "/cms/media", "Media item deleted successfully.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function deleteMultipleMediaItems(items: Array<{ id: string; objectKey: string }>) {
|
|
242
|
+
const supabase = createClient();
|
|
243
|
+
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
244
|
+
|
|
245
|
+
if (authError || !user) {
|
|
246
|
+
return { error: "User not authenticated." };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
|
|
250
|
+
if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
|
|
251
|
+
return { error: "Forbidden: Insufficient permissions." };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!items || items.length === 0) {
|
|
255
|
+
return { error: "No items selected for deletion." };
|
|
256
|
+
}
|
|
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
|
+
}
|
|
269
|
+
|
|
270
|
+
const r2ObjectsToDelete = items.map(item => ({ Key: item.objectKey }));
|
|
271
|
+
const itemIdsToDelete = items.map(item => item.id);
|
|
272
|
+
let r2DeletionError = null;
|
|
273
|
+
let dbDeletionError = null;
|
|
274
|
+
|
|
275
|
+
// Batch delete from R2
|
|
276
|
+
try {
|
|
277
|
+
if (r2ObjectsToDelete.length > 0) {
|
|
278
|
+
const deleteCommand = new DeleteObjectsCommand({
|
|
279
|
+
Bucket: R2_BUCKET_NAME,
|
|
280
|
+
Delete: { Objects: r2ObjectsToDelete },
|
|
281
|
+
});
|
|
282
|
+
const output = await s3Client.send(deleteCommand);
|
|
283
|
+
if (output.Errors && output.Errors.length > 0) {
|
|
284
|
+
console.error("Errors deleting some objects from R2:", output.Errors);
|
|
285
|
+
// Collect specific errors if needed, for now a general message
|
|
286
|
+
r2DeletionError = `Some objects failed to delete from R2: ${output.Errors.map(e => e.Key).join(', ')}`;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch (error: unknown) {
|
|
290
|
+
console.error("Error batch deleting from R2:", error);
|
|
291
|
+
r2DeletionError = `Failed to delete objects from R2: ${error instanceof Error ? error.message : String(error)}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Batch delete from Supabase
|
|
295
|
+
try {
|
|
296
|
+
if (itemIdsToDelete.length > 0) {
|
|
297
|
+
const { error } = await supabase.from("media").delete().in("id", itemIdsToDelete);
|
|
298
|
+
if (error) {
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
} catch (error: unknown) {
|
|
303
|
+
console.error("Error batch deleting media records from DB:", error);
|
|
304
|
+
dbDeletionError = `Failed to delete media records from DB: ${error instanceof Error ? error.message : String(error)}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (r2DeletionError || dbDeletionError) {
|
|
308
|
+
// Construct a combined error message
|
|
309
|
+
const errors = [r2DeletionError, dbDeletionError].filter(Boolean).join(" | ");
|
|
310
|
+
// No redirect here, return error object for client-side handling
|
|
311
|
+
return { error: `Deletion process encountered issues: ${errors}` };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
revalidatePath("/cms/media");
|
|
315
|
+
// No redirect here, return success object for client-side handling
|
|
316
|
+
return { success: "Selected media items deleted successfully." };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
export async function moveMultipleMediaItems(
|
|
321
|
+
items: Array<{ id: string; objectKey: string }>,
|
|
322
|
+
destinationFolder: string
|
|
323
|
+
) {
|
|
324
|
+
const supabase = createClient();
|
|
325
|
+
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
326
|
+
|
|
327
|
+
if (authError || !user) {
|
|
328
|
+
return { error: "User not authenticated." };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
|
|
332
|
+
if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
|
|
333
|
+
return { error: "Forbidden: Insufficient permissions." };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const sanitizeFolder = (input?: string | null) => {
|
|
337
|
+
const f = (input ?? '').toString().trim();
|
|
338
|
+
if (!f) return 'uploads/';
|
|
339
|
+
let cleaned = f.replace(/^\/+/, '');
|
|
340
|
+
cleaned = cleaned.replace(/\\/g, '/');
|
|
341
|
+
cleaned = cleaned.replace(/\.{2,}/g, '');
|
|
342
|
+
cleaned = cleaned.replace(/[^a-zA-Z0-9_\-/]+/g, '-');
|
|
343
|
+
if (cleaned && !cleaned.endsWith('/')) cleaned += '/';
|
|
344
|
+
return cleaned || 'uploads/';
|
|
345
|
+
};
|
|
346
|
+
const folder = sanitizeFolder(destinationFolder);
|
|
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
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!items || items.length === 0) {
|
|
362
|
+
return { error: "No items selected for move." };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const results: Array<{ id: string; ok: boolean; error?: string }> = [];
|
|
366
|
+
|
|
367
|
+
for (const item of items) {
|
|
368
|
+
try {
|
|
369
|
+
// Load media to retrieve variants
|
|
370
|
+
const { data: mediaRow, error: mediaError } = await supabase
|
|
371
|
+
.from("media")
|
|
372
|
+
.select("id, object_key, file_type, variants")
|
|
373
|
+
.eq("id", item.id)
|
|
374
|
+
.single();
|
|
375
|
+
|
|
376
|
+
if (mediaError || !mediaRow) {
|
|
377
|
+
results.push({ id: item.id, ok: false, error: mediaError?.message || 'Media not found' });
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const getFilename = (key: string) => key.substring(key.lastIndexOf('/') + 1);
|
|
382
|
+
let newMainKey = `${folder}${getFilename(mediaRow.object_key)}`;
|
|
383
|
+
|
|
384
|
+
// Build list of keys to move: primary + variant keys (if any)
|
|
385
|
+
type Variant = { objectKey: string; url?: string; [k: string]: any };
|
|
386
|
+
const oldVariants: Variant[] = Array.isArray(mediaRow.variants) ? (mediaRow.variants as Variant[]) :
|
|
387
|
+
(typeof mediaRow.variants === 'object' && mediaRow.variants !== null ? (mediaRow.variants as any) : []);
|
|
388
|
+
|
|
389
|
+
const variantMoves = (oldVariants || []).map((v) => ({
|
|
390
|
+
oldKey: v.objectKey,
|
|
391
|
+
newKey: `${folder}${getFilename(v.objectKey)}`,
|
|
392
|
+
}));
|
|
393
|
+
|
|
394
|
+
const objectsToMove = [
|
|
395
|
+
{ oldKey: mediaRow.object_key, newKey: newMainKey, isMain: true },
|
|
396
|
+
...variantMoves.map(v => ({ ...v, isMain: false })),
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
// Copy then delete each object; tolerate missing variant keys, but main object must exist
|
|
400
|
+
const movedKeys = new Set<string>();
|
|
401
|
+
let mainMoved = false;
|
|
402
|
+
for (const { oldKey, newKey, isMain } of objectsToMove as Array<{oldKey:string; newKey:string; isMain:boolean}>) {
|
|
403
|
+
if (oldKey === newKey) continue;
|
|
404
|
+
// If destination already has the object, treat as moved and try to delete source if present
|
|
405
|
+
let destinationExists = false;
|
|
406
|
+
try {
|
|
407
|
+
await s3Client.send(new HeadObjectCommand({ Bucket: R2_BUCKET_NAME, Key: newKey }));
|
|
408
|
+
destinationExists = true;
|
|
409
|
+
} catch {
|
|
410
|
+
destinationExists = false;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (destinationExists) {
|
|
414
|
+
movedKeys.add(oldKey);
|
|
415
|
+
if (isMain) {
|
|
416
|
+
mainMoved = true;
|
|
417
|
+
newMainKey = newKey;
|
|
418
|
+
}
|
|
419
|
+
await s3Client
|
|
420
|
+
.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: oldKey }))
|
|
421
|
+
.catch(() => undefined);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const encodedSourceKey = encodeURIComponent(oldKey).replace(/%2F/g, '/');
|
|
426
|
+
const copySource = `/${R2_BUCKET_NAME}/${encodedSourceKey}`; // S3/R2 expects a leading slash
|
|
427
|
+
try {
|
|
428
|
+
await s3Client.send(new CopyObjectCommand({ Bucket: R2_BUCKET_NAME, CopySource: copySource, Key: newKey }));
|
|
429
|
+
await s3Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: oldKey }));
|
|
430
|
+
movedKeys.add(oldKey);
|
|
431
|
+
if (isMain) mainMoved = true;
|
|
432
|
+
} catch (err: any) {
|
|
433
|
+
const name = err?.name || '';
|
|
434
|
+
const message = err?.message || String(err);
|
|
435
|
+
if (isMain) {
|
|
436
|
+
// Main object missing: attempt fallback to any existing variant
|
|
437
|
+
let promoted = false;
|
|
438
|
+
for (const vm of variantMoves) {
|
|
439
|
+
try {
|
|
440
|
+
const encVar = encodeURIComponent(vm.oldKey).replace(/%2F/g, '/');
|
|
441
|
+
const srcVar = `/${R2_BUCKET_NAME}/${encVar}`;
|
|
442
|
+
await s3Client.send(new CopyObjectCommand({ Bucket: R2_BUCKET_NAME, CopySource: srcVar, Key: vm.newKey }));
|
|
443
|
+
await s3Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: vm.oldKey }));
|
|
444
|
+
movedKeys.add(vm.oldKey);
|
|
445
|
+
newMainKey = vm.newKey; // promote this variant as new main
|
|
446
|
+
mainMoved = true;
|
|
447
|
+
promoted = true;
|
|
448
|
+
break;
|
|
449
|
+
} catch {
|
|
450
|
+
continue; // try next variant
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (!promoted) {
|
|
454
|
+
// Last-resort fallback: list objects using a base prefix derived from timestamped key
|
|
455
|
+
// Keys look like: uploads/name_YYYYMMDDHHMMSS_original.avif or uploads/name_YYYYMMDD.png
|
|
456
|
+
const withoutExt = mediaRow.object_key.replace(/\.[^/.]+$/, '');
|
|
457
|
+
const tsMatch = withoutExt.match(/^(.*?_\d{8,14})/); // capture up to timestamp
|
|
458
|
+
const basePrefix = tsMatch ? tsMatch[1] : withoutExt.replace(/_(original(?:_uploaded)?|xlarge_avif|large_avif|medium_avif|small_avif|thumbnail_avif|[a-z]+)$/i, '');
|
|
459
|
+
// Ensure it ends with the underscore-delimited base, not variant label
|
|
460
|
+
const prefixGuess = basePrefix;
|
|
461
|
+
try {
|
|
462
|
+
const listed = await s3Client.send(new ListObjectsV2Command({ Bucket: R2_BUCKET_NAME, Prefix: prefixGuess }));
|
|
463
|
+
const keys = (listed.Contents || []).map(o => o.Key).filter(Boolean) as string[];
|
|
464
|
+
// Prefer common image extensions if present
|
|
465
|
+
const preferred = keys.find(k => /\.(avif|png|jpe?g|webp|gif|svg)$/i.test(k)) || keys[0];
|
|
466
|
+
if (preferred) {
|
|
467
|
+
const enc = encodeURIComponent(preferred).replace(/%2F/g, '/');
|
|
468
|
+
const src = `/${R2_BUCKET_NAME}/${enc}`;
|
|
469
|
+
const targetKey = `${folder}${getFilename(preferred)}`;
|
|
470
|
+
await s3Client.send(new CopyObjectCommand({ Bucket: R2_BUCKET_NAME, CopySource: src, Key: targetKey }));
|
|
471
|
+
await s3Client.send(new DeleteObjectCommand({ Bucket: R2_BUCKET_NAME, Key: preferred }));
|
|
472
|
+
movedKeys.add(preferred);
|
|
473
|
+
newMainKey = targetKey;
|
|
474
|
+
mainMoved = true;
|
|
475
|
+
} else {
|
|
476
|
+
results.push({ id: item.id, ok: false, error: `Main key missing: ${oldKey} (${name}: ${message})` });
|
|
477
|
+
mainMoved = false;
|
|
478
|
+
}
|
|
479
|
+
} catch {
|
|
480
|
+
results.push({ id: item.id, ok: false, error: `Main key missing: ${oldKey} (${name}: ${message})` });
|
|
481
|
+
mainMoved = false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Regardless, skip to next objectToMove item
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
// Variant missing: tolerate and continue; we'll drop it from variants below
|
|
488
|
+
// Do nothing (optionally could log)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (!mainMoved) {
|
|
492
|
+
// Already pushed a result; proceed to next item
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Update variants with new keys + urls
|
|
497
|
+
const newVariants = (oldVariants || [])
|
|
498
|
+
.filter((v) => movedKeys.has(v.objectKey)) // keep only variants that were successfully moved
|
|
499
|
+
.map((v) => {
|
|
500
|
+
const filename = getFilename(v.objectKey);
|
|
501
|
+
const updatedKey = `${folder}${filename}`;
|
|
502
|
+
const updated = { ...v, objectKey: updatedKey } as any;
|
|
503
|
+
if (R2_PUBLIC_URL_BASE) updated.url = `${R2_PUBLIC_URL_BASE}/${updatedKey}`;
|
|
504
|
+
return updated;
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Update DB
|
|
508
|
+
const { error: updateError } = await supabase
|
|
509
|
+
.from('media')
|
|
510
|
+
.update({ object_key: newMainKey, file_path: newMainKey, folder, variants: newVariants as any })
|
|
511
|
+
.eq('id', item.id);
|
|
512
|
+
if (updateError) {
|
|
513
|
+
results.push({ id: item.id, ok: false, error: updateError.message });
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
results.push({ id: item.id, ok: true });
|
|
518
|
+
} catch (err: any) {
|
|
519
|
+
const msg = err?.name && err?.message ? `${err.name}: ${err.message}` : (err?.message || String(err));
|
|
520
|
+
results.push({ id: item.id, ok: false, error: msg });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const failed = results.filter(r => !r.ok);
|
|
525
|
+
if (failed.length > 0) {
|
|
526
|
+
const detail = failed.map(f => `${f.id}${f.error ? ` (${f.error})` : ''}`).join(', ');
|
|
527
|
+
return { error: `Moved ${results.length - failed.length} item(s), ${failed.length} failed: ${detail}` };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
revalidatePath('/cms/media');
|
|
531
|
+
return { success: `Moved ${results.length} item(s) to ${folder}` };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export async function moveSingleMediaItem(item: { id: string; objectKey: string }, destinationFolder: string) {
|
|
535
|
+
// Reuse the multiple-items logic for a single element to keep behavior aligned.
|
|
536
|
+
const res = await moveMultipleMediaItems([item], destinationFolder);
|
|
537
|
+
return res;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
// Type for inserting media
|
|
542
|
+
|
|
543
|
+
export async function getMediaItems(
|
|
544
|
+
page = 1,
|
|
545
|
+
limit = 50 // Default to 50 items per page
|
|
546
|
+
): Promise<{ data?: Media[]; error?: string; hasMore?: boolean }> {
|
|
547
|
+
const supabase = createClient();
|
|
548
|
+
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
|
549
|
+
|
|
550
|
+
if (authError || !user) {
|
|
551
|
+
return { error: "User not authenticated." };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Optional: Check user role if only certain roles can view all media
|
|
555
|
+
// const { data: profile } = await supabase.from("profiles").select("role").eq("id", user.id).single();
|
|
556
|
+
// if (!profile || !["ADMIN", "WRITER"].includes(profile.role)) {
|
|
557
|
+
// return { error: "Forbidden: Insufficient permissions." };
|
|
558
|
+
// }
|
|
559
|
+
|
|
560
|
+
const from = (page - 1) * limit;
|
|
561
|
+
const to = from + limit - 1;
|
|
562
|
+
|
|
563
|
+
const { data, error, count } = await supabase
|
|
564
|
+
.from("media")
|
|
565
|
+
.select("*", { count: "exact" })
|
|
566
|
+
.order("created_at", { ascending: false })
|
|
567
|
+
.range(from, to);
|
|
568
|
+
|
|
569
|
+
if (error) {
|
|
570
|
+
console.error("Error fetching media items:", error);
|
|
571
|
+
return { error: `Failed to fetch media items: ${error.message}` };
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const hasMore = count ? to < count -1 : false;
|
|
575
|
+
|
|
576
|
+
return { data: data as Media[], hasMore };
|
|
577
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// app/cms/media/components/DeleteMediaButtonClient.tsx
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
import React, { useState, useTransition } from 'react';
|
|
5
|
+
import { DropdownMenuItem } from "@nextblock-cms/ui";
|
|
6
|
+
import { Trash2 } from "lucide-react";
|
|
7
|
+
import type { Database } from "@nextblock-cms/db";
|
|
8
|
+
import { deleteMediaItem } from "../actions";
|
|
9
|
+
import { ConfirmationModal } from '@/app/cms/components/ConfirmationModal';
|
|
10
|
+
|
|
11
|
+
type Media = Database['public']['Tables']['media']['Row'];
|
|
12
|
+
|
|
13
|
+
interface DeleteMediaButtonClientProps {
|
|
14
|
+
mediaItem: Media;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function DeleteMediaButtonClient({ mediaItem }: DeleteMediaButtonClientProps) {
|
|
18
|
+
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
19
|
+
const [isPending, startTransition] = useTransition();
|
|
20
|
+
|
|
21
|
+
const handleDelete = () => {
|
|
22
|
+
startTransition(async () => {
|
|
23
|
+
await deleteMediaItem(mediaItem.id, mediaItem.object_key);
|
|
24
|
+
setIsModalOpen(false);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const handleSelect = (event: Event) => {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
setIsModalOpen(true);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<DropdownMenuItem
|
|
36
|
+
className="text-red-600 hover:!text-red-600 hover:!bg-red-50 dark:hover:!bg-red-700/20 cursor-pointer"
|
|
37
|
+
onSelect={handleSelect}
|
|
38
|
+
disabled={isPending}
|
|
39
|
+
>
|
|
40
|
+
<Trash2 className="mr-2 h-4 w-4" />
|
|
41
|
+
{isPending ? 'Deleting...' : 'Delete'}
|
|
42
|
+
</DropdownMenuItem>
|
|
43
|
+
<ConfirmationModal
|
|
44
|
+
isOpen={isModalOpen}
|
|
45
|
+
onClose={() => setIsModalOpen(false)}
|
|
46
|
+
onConfirm={handleDelete}
|
|
47
|
+
title="Are you sure?"
|
|
48
|
+
description="This will permanently delete the media file. This action cannot be undone."
|
|
49
|
+
confirmText={isPending ? 'Deleting...' : 'Delete'}
|
|
50
|
+
/>
|
|
51
|
+
</>
|
|
52
|
+
);
|
|
53
|
+
}
|