chore(messages): update media prompts in multiple languages

This commit is contained in:
Fatih Kadir Akın
2025-12-13 23:18:32 +03:00
parent 65db4465e6
commit 169a1e9f83
20 changed files with 2199 additions and 31 deletions
+11 -3
View File
@@ -13,6 +13,10 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
# AZURE_AD_CLIENT_ID=""
# AZURE_AD_CLIENT_SECRET=""
# AZURE_AD_TENANT_ID=""
# GITHUB_CLIENT_ID=your_client_id
# GITHUB_CLIENT_SECRET=your_client_secret
# ENABLED_STORAGE="do-spaces" | "s3" | "url"
# Storage Providers (optional - enable in prompts.config.ts)
# S3_BUCKET=""
@@ -21,10 +25,14 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
# S3_SECRET_ACCESS_KEY=""
# S3_ENDPOINT="" # For S3-compatible services like MinIO
# GITHUB_CLIENT_ID=your_client_id
# GITHUB_CLIENT_SECRET=your_client_secret
# DigitalOcean Spaces (optional - S3-compatible storage)
# DO_SPACES_BUCKET=""
# DO_SPACES_REGION="" # e.g., nyc3, sfo3, ams3, sgp1, fra1
# DO_SPACES_ACCESS_KEY_ID=""
# DO_SPACES_SECRET_ACCESS_KEY=""
# DO_SPACES_CDN_ENDPOINT="" # Optional: for CDN-enabled Spaces
# AI Search (optional - enable aiSearch in prompts.config.ts)
# OPENAI_API_KEY=your_openai_api_key
GOOGLE_ANALYTICS_ID="G-XXXXXXXXX"
# GOOGLE_ANALYTICS_ID="G-XXXXXXXXX"
+6
View File
@@ -167,6 +167,12 @@
"mediaUrl": "رابط الوسائط",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "أدخل رابط ملف الوسائط لهذا الأمر",
"mediaImage": "صورة الوسائط",
"clickToUpload": "انقر لرفع صورة",
"uploading": "جارٍ الرفع...",
"maxFileSize": "الحد الأقصى لحجم الملف: 1 ميجابايت (JPEG، PNG، GIF، WebP)",
"fileTooLarge": "الملف كبير جداً. الحد الأقصى للحجم هو 1 ميجابايت.",
"invalidFileType": "نوع ملف غير صالح. يُسمح فقط بـ JPEG و PNG و GIF و WebP.",
"privateDescription": "الأوامر الخاصة مرئية لك فقط",
"update": "تحديث",
"createButton": "إنشاء",
+6
View File
@@ -160,6 +160,12 @@
"mediaUrl": "Medien-URL",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "Geben Sie die URL zur Mediendatei für diesen Prompt ein",
"mediaImage": "Medienbild",
"clickToUpload": "Klicken Sie, um ein Bild hochzuladen",
"uploading": "Wird hochgeladen...",
"maxFileSize": "Max. Dateigröße: 1MB (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "Datei zu groß. Maximale Größe ist 1MB.",
"invalidFileType": "Ungültiger Dateityp. Nur JPEG, PNG, GIF und WebP sind erlaubt.",
"privateDescription": "Private Prompts sind nur für Sie sichtbar",
"requiresMediaUpload": "Medien-Upload erforderlich",
"requiresMediaUploadDescription": "Benutzer müssen Mediendateien bereitstellen, um diesen Prompt zu verwenden",
+6
View File
@@ -160,6 +160,12 @@
"mediaUrl": "Media URL",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "Enter a URL to the media file for this prompt",
"mediaImage": "Media Image",
"clickToUpload": "Click to upload an image",
"uploading": "Uploading...",
"maxFileSize": "Max file size: 1MB (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "File too large. Maximum size is 1MB.",
"invalidFileType": "Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed.",
"privateDescription": "Private prompts are only visible to you",
"requiresMediaUpload": "Requires Media Upload",
"requiresMediaUploadDescription": "Users need to provide media files to use this prompt",
+6
View File
@@ -167,6 +167,12 @@
"mediaUrl": "URL del Medio",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "Ingresa una URL al archivo de medios para este prompt",
"mediaImage": "Imagen de Medios",
"clickToUpload": "Haz clic para subir una imagen",
"uploading": "Subiendo...",
"maxFileSize": "Tamaño máximo: 1MB (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "Archivo demasiado grande. El tamaño máximo es 1MB.",
"invalidFileType": "Tipo de archivo inválido. Solo se permiten JPEG, PNG, GIF y WebP.",
"privateDescription": "Los prompts privados solo son visibles para ti",
"update": "Actualizar",
"createButton": "Crear",
+6
View File
@@ -160,6 +160,12 @@
"mediaUrl": "URL du Média",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "Entrez l'URL du fichier média pour ce prompt",
"mediaImage": "Image Média",
"clickToUpload": "Cliquez pour télécharger une image",
"uploading": "Téléchargement...",
"maxFileSize": "Taille max: 1Mo (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "Fichier trop volumineux. La taille maximale est de 1Mo.",
"invalidFileType": "Type de fichier invalide. Seuls JPEG, PNG, GIF et WebP sont autorisés.",
"privateDescription": "Les prompts privés ne sont visibles que par vous",
"requiresMediaUpload": "Nécessite un Upload de Média",
"requiresMediaUploadDescription": "Les utilisateurs doivent fournir des fichiers média pour utiliser ce prompt",
+6
View File
@@ -160,6 +160,12 @@
"mediaUrl": "URL Media",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "Inserisci un URL al file multimediale per questo prompt",
"mediaImage": "Immagine Media",
"clickToUpload": "Clicca per caricare un'immagine",
"uploading": "Caricamento...",
"maxFileSize": "Dimensione max: 1MB (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "File troppo grande. La dimensione massima è 1MB.",
"invalidFileType": "Tipo di file non valido. Sono consentiti solo JPEG, PNG, GIF e WebP.",
"privateDescription": "I prompt privati sono visibili solo a te",
"requiresMediaUpload": "Richiede Caricamento Media",
"requiresMediaUploadDescription": "Gli utenti devono fornire file multimediali per usare questo prompt",
+6
View File
@@ -167,6 +167,12 @@
"mediaUrl": "メディアURL",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "このプロンプトのメディアファイルURLを入力",
"mediaImage": "メディア画像",
"clickToUpload": "クリックして画像をアップロード",
"uploading": "アップロード中...",
"maxFileSize": "最大ファイルサイズ: 1MB (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "ファイルが大きすぎます。最大サイズは1MBです。",
"invalidFileType": "無効なファイル形式です。JPEG、PNG、GIF、WebPのみ許可されています。",
"privateDescription": "非公開プロンプトはあなただけに表示されます",
"update": "更新",
"createButton": "作成",
+6
View File
@@ -160,6 +160,12 @@
"mediaUrl": "미디어 URL",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "이 프롬프트의 미디어 파일 URL을 입력하세요",
"mediaImage": "미디어 이미지",
"clickToUpload": "클릭하여 이미지 업로드",
"uploading": "업로드 중...",
"maxFileSize": "최대 파일 크기: 1MB (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "파일이 너무 큽니다. 최대 크기는 1MB입니다.",
"invalidFileType": "잘못된 파일 형식입니다. JPEG, PNG, GIF, WebP만 허용됩니다.",
"privateDescription": "비공개 프롬프트는 본인만 볼 수 있습니다",
"requiresMediaUpload": "미디어 업로드 필요",
"requiresMediaUploadDescription": "이 프롬프트를 사용하려면 미디어 파일을 제공해야 합니다",
+6
View File
@@ -160,6 +160,12 @@
"mediaUrl": "URL da Mídia",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "Digite a URL do arquivo de mídia para este prompt",
"mediaImage": "Imagem de Mídia",
"clickToUpload": "Clique para enviar uma imagem",
"uploading": "Enviando...",
"maxFileSize": "Tamanho máximo: 1MB (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "Arquivo muito grande. O tamanho máximo é 1MB.",
"invalidFileType": "Tipo de arquivo inválido. Apenas JPEG, PNG, GIF e WebP são permitidos.",
"privateDescription": "Prompts privados são visíveis apenas para você",
"requiresMediaUpload": "Requer Upload de Mídia",
"requiresMediaUploadDescription": "Usuários precisam fornecer arquivos de mídia para usar este prompt",
+6
View File
@@ -160,6 +160,12 @@
"mediaUrl": "Medya URL",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "Bu prompt için medya dosyasının URL'sini girin",
"mediaImage": "Medya Görseli",
"clickToUpload": "Görsel yüklemek için tıklayın",
"uploading": "Yükleniyor...",
"maxFileSize": "Maksimum dosya boyutu: 1MB (JPEG, PNG, GIF, WebP)",
"fileTooLarge": "Dosya çok büyük. Maksimum boyut 1MB'dir.",
"invalidFileType": "Geçersiz dosya türü. Yalnızca JPEG, PNG, GIF ve WebP'ye izin verilir.",
"privateDescription": "Özel promptlar yalnızca size görünür",
"requiresMediaUpload": "Medya Yüklemesi Gerekli",
"requiresMediaUploadDescription": "Bu promptu kullanmak için medya dosyası gereklidir",
+6
View File
@@ -160,6 +160,12 @@
"mediaUrl": "媒体链接",
"mediaUrlPlaceholder": "https://...",
"mediaUrlDescription": "输入此提示词的媒体文件链接",
"mediaImage": "媒体图片",
"clickToUpload": "点击上传图片",
"uploading": "上传中...",
"maxFileSize": "最大文件大小:1MB(JPEG、PNG、GIF、WebP)",
"fileTooLarge": "文件太大。最大大小为1MB。",
"invalidFileType": "无效的文件类型。仅允许JPEG、PNG、GIF和WebP。",
"privateDescription": "私有提示词仅对您可见",
"requiresMediaUpload": "需要媒体上传",
"requiresMediaUploadDescription": "用户需要提供媒体文件才能使用此提示词",
+1653
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -17,6 +17,7 @@
},
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@aws-sdk/client-s3": "^3.948.0",
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@prisma/client": "^6.19.0",
@@ -47,6 +48,7 @@
"openai": "^6.10.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"sharp": "^0.33.5",
"react-hook-form": "^7.68.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
+10
View File
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
export async function GET() {
const enabledStorage = process.env.ENABLED_STORAGE || "url";
return NextResponse.json({
mode: enabledStorage,
supportsUpload: enabledStorage !== "url",
});
}
+109
View File
@@ -0,0 +1,109 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getStoragePlugin } from "@/lib/plugins/registry";
const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1MB
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
async function compressToJpg(buffer: Buffer): Promise<Buffer> {
try {
// Dynamic import for optional sharp dependency
const sharpModule = await import(/* webpackIgnore: true */ "sharp");
const sharp = sharpModule.default;
return await sharp(buffer)
.jpeg({ quality: 90, mozjpeg: true })
.toBuffer();
} catch {
throw new Error(
"Image compression requires sharp. Install it with: npm install sharp"
);
}
}
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const enabledStorage = process.env.ENABLED_STORAGE || "url";
if (enabledStorage === "url") {
return NextResponse.json(
{ error: "File upload is not enabled. Using URL storage mode." },
{ status: 400 }
);
}
const storagePlugin = getStoragePlugin(enabledStorage);
if (!storagePlugin) {
return NextResponse.json(
{ error: `Storage plugin "${enabledStorage}" not found` },
{ status: 500 }
);
}
if (!storagePlugin.isConfigured()) {
return NextResponse.json(
{ error: `Storage plugin "${enabledStorage}" is not configured` },
{ status: 500 }
);
}
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed." },
{ status: 400 }
);
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: "File too large. Maximum size is 1MB." },
{ status: 400 }
);
}
// Convert to buffer
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Compress to JPG
const compressedBuffer = await compressToJpg(buffer);
// Generate filename
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 8);
const filename = `prompt-media-${timestamp}-${randomId}.jpg`;
// Upload to storage
const result = await storagePlugin.upload(compressedBuffer, {
filename,
mimeType: "image/jpeg",
folder: "prompt-media",
});
return NextResponse.json({
url: result.url,
size: result.size,
});
} catch (error) {
console.error("Upload error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Upload failed" },
{ status: 500 }
);
}
}
+160 -15
View File
@@ -1,12 +1,12 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Loader2 } from "lucide-react";
import { Loader2, Upload, X } from "lucide-react";
import { VariableToolbar } from "./variable-toolbar";
import { ContributorSearch } from "./contributor-search";
import { Button } from "@/components/ui/button";
@@ -34,6 +34,160 @@ import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor";
import { toast } from "sonner";
import { prettifyJson } from "@/lib/format";
interface MediaFieldProps {
form: ReturnType<typeof useForm<PromptFormValues>>;
t: (key: string) => string;
}
function MediaField({ form, t }: MediaFieldProps) {
const [storageMode, setStorageMode] = useState<string>("url");
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const mediaUrl = form.watch("mediaUrl");
useEffect(() => {
fetch("/api/config/storage")
.then((res) => res.json())
.then((data) => setStorageMode(data.mode))
.catch(() => setStorageMode("url"));
}, []);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file size (1MB)
if (file.size > 1 * 1024 * 1024) {
setUploadError(t("fileTooLarge"));
return;
}
// Validate file type
const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
if (!allowedTypes.includes(file.type)) {
setUploadError(t("invalidFileType"));
return;
}
setIsUploading(true);
setUploadError(null);
try {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Upload failed");
}
const result = await response.json();
form.setValue("mediaUrl", result.url);
} catch (error) {
setUploadError(error instanceof Error ? error.message : "Upload failed");
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
};
const clearMedia = () => {
form.setValue("mediaUrl", "");
setUploadError(null);
};
// URL mode: show text input
if (storageMode === "url") {
return (
<FormField
control={form.control}
name="mediaUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t("mediaUrl")}</FormLabel>
<FormControl>
<Input placeholder={t("mediaUrlPlaceholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
// Upload mode: show file upload
return (
<FormField
control={form.control}
name="mediaUrl"
render={() => (
<FormItem>
<FormLabel>{t("mediaImage")}</FormLabel>
<FormControl>
<div className="space-y-2">
{mediaUrl ? (
<div className="relative inline-block">
<img
src={mediaUrl}
alt="Preview"
className="max-h-40 rounded-md border"
/>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute -top-2 -right-2 h-6 w-6"
onClick={clearMedia}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div
className="flex flex-col items-center justify-center gap-2 p-6 border-2 border-dashed rounded-md cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
{isUploading ? (
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
) : (
<Upload className="h-8 w-8 text-muted-foreground" />
)}
<p className="text-sm text-muted-foreground">
{isUploading ? t("uploading") : t("clickToUpload")}
</p>
<p className="text-xs text-muted-foreground">
{t("maxFileSize")}
</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
/>
{uploadError && (
<p className="text-sm text-destructive">{uploadError}</p>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
const createPromptSchema = (t: (key: string) => string) => z.object({
title: z.string().min(1, t("titleRequired")).max(200),
description: z.string().max(500).optional(),
@@ -395,20 +549,11 @@ export function PromptForm({ categories, tags, initialData, initialContributors
</div>
)}
{/* Media URL (for IMAGE/VIDEO/AUDIO/STRUCTURED types) */}
{/* Media URL/Upload (for IMAGE/VIDEO/AUDIO/STRUCTURED types) */}
{(promptType === "IMAGE" || promptType === "VIDEO" || promptType === "AUDIO" || promptType === "STRUCTURED") && (
<FormField
control={form.control}
name="mediaUrl"
render={({ field }) => (
<FormItem>
<FormLabel>{t("mediaUrl")}</FormLabel>
<FormControl>
<Input placeholder={t("mediaUrlPlaceholder")} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
<MediaField
form={form}
t={t}
/>
)}
+160
View File
@@ -0,0 +1,160 @@
import type { StoragePlugin, UploadResult, UploadOptions } from "../types";
/**
* DigitalOcean Spaces Storage Plugin
*
* DigitalOcean Spaces is S3-compatible but uses a different endpoint format.
*
* Required env vars:
* - DO_SPACES_BUCKET
* - DO_SPACES_REGION (e.g., nyc3, sfo3, ams3, sgp1, fra1)
* - DO_SPACES_ACCESS_KEY_ID
* - DO_SPACES_SECRET_ACCESS_KEY
*
* Optional env vars:
* - DO_SPACES_CDN_ENDPOINT (for CDN-enabled Spaces, e.g., https://bucket.region.cdn.digitaloceanspaces.com)
*
* Note: Requires @aws-sdk/client-s3 to be installed:
* npm install @aws-sdk/client-s3
*/
// Helper to dynamically load AWS SDK
async function getS3Client() {
try {
// Use webpackIgnore to prevent bundling this optional dependency
// @ts-expect-error - optional dependency, installed at runtime
const s3Module = await import(/* webpackIgnore: true */ "@aws-sdk/client-s3");
return s3Module;
} catch {
throw new Error(
"DigitalOcean Spaces storage requires @aws-sdk/client-s3. Install it with: npm install @aws-sdk/client-s3"
);
}
}
function getEndpoint(): string {
const region = process.env.DO_SPACES_REGION!;
return `https://${region}.digitaloceanspaces.com`;
}
function getFileUrl(key: string): string {
const bucket = process.env.DO_SPACES_BUCKET!;
const region = process.env.DO_SPACES_REGION!;
// Use CDN endpoint if configured, otherwise use standard Spaces URL
if (process.env.DO_SPACES_CDN_ENDPOINT) {
return `${process.env.DO_SPACES_CDN_ENDPOINT}/${key}`;
}
// Standard DigitalOcean Spaces URL format: https://bucket.region.digitaloceanspaces.com/key
return `https://${bucket}.${region}.digitaloceanspaces.com/${key}`;
}
export const doSpacesStoragePlugin: StoragePlugin = {
id: "do-spaces",
name: "DigitalOcean Spaces",
isConfigured: () => {
return !!(
process.env.DO_SPACES_BUCKET &&
process.env.DO_SPACES_REGION &&
process.env.DO_SPACES_ACCESS_KEY_ID &&
process.env.DO_SPACES_SECRET_ACCESS_KEY
);
},
async upload(file: File | Buffer, options?: UploadOptions): Promise<UploadResult> {
// Check configuration
if (!this.isConfigured()) {
throw new Error(
"DigitalOcean Spaces storage is not configured. Please set DO_SPACES_BUCKET, DO_SPACES_REGION, DO_SPACES_ACCESS_KEY_ID, and DO_SPACES_SECRET_ACCESS_KEY environment variables."
);
}
// Dynamic import to avoid bundling issues when not used
const { S3Client, PutObjectCommand } = await getS3Client();
const client = new S3Client({
region: process.env.DO_SPACES_REGION!,
endpoint: getEndpoint(),
credentials: {
accessKeyId: process.env.DO_SPACES_ACCESS_KEY_ID!,
secretAccessKey: process.env.DO_SPACES_SECRET_ACCESS_KEY!,
},
forcePathStyle: false, // DO Spaces uses virtual-hosted style URLs
});
// Generate unique key
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 8);
const filename = options?.filename || `file-${timestamp}-${randomId}`;
const folder = options?.folder || "uploads";
const key = `${folder}/${filename}`;
// Convert File to Buffer if needed
let buffer: Buffer;
let contentType: string | undefined;
if (file instanceof File) {
const arrayBuffer = await file.arrayBuffer();
buffer = Buffer.from(arrayBuffer);
contentType = file.type;
} else {
buffer = file;
contentType = options?.mimeType;
}
// Upload to DigitalOcean Spaces
await client.send(
new PutObjectCommand({
Bucket: process.env.DO_SPACES_BUCKET!,
Key: key,
Body: buffer,
ContentType: contentType,
ACL: "public-read", // Make files publicly accessible
})
);
// Construct URL
const url = getFileUrl(key);
return {
url,
key,
size: buffer.length,
mimeType: contentType,
};
},
async delete(keyOrUrl: string): Promise<void> {
if (!this.isConfigured()) {
throw new Error("DigitalOcean Spaces storage is not configured.");
}
const { S3Client, DeleteObjectCommand } = await getS3Client();
const client = new S3Client({
region: process.env.DO_SPACES_REGION!,
endpoint: getEndpoint(),
credentials: {
accessKeyId: process.env.DO_SPACES_ACCESS_KEY_ID!,
secretAccessKey: process.env.DO_SPACES_SECRET_ACCESS_KEY!,
},
forcePathStyle: false,
});
// Extract key from URL if needed
let key = keyOrUrl;
if (keyOrUrl.startsWith("http")) {
const url = new URL(keyOrUrl);
key = url.pathname.substring(1); // Remove leading slash
}
await client.send(
new DeleteObjectCommand({
Bucket: process.env.DO_SPACES_BUCKET!,
Key: key,
})
);
},
};
+26 -11
View File
@@ -1,19 +1,34 @@
import { registerStoragePlugin } from "../registry";
import { urlStoragePlugin } from "./url";
const ENABLED_STORAGE: string = process.env.ENABLED_STORAGE || "url";
// Register all built-in storage plugins
export function registerBuiltInStoragePlugins(): void {
registerStoragePlugin(urlStoragePlugin);
// S3 plugin requires @aws-sdk/client-s3 to be installed
// To enable S3 storage:
// 1. npm install @aws-sdk/client-s3
// 2. Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY env vars
// 3. Uncomment the code below:
//
// import("./s3").then(({ s3StoragePlugin }) => {
// registerStoragePlugin(s3StoragePlugin);
// });
if (ENABLED_STORAGE === "url") {
registerStoragePlugin(urlStoragePlugin);
return
}
if (ENABLED_STORAGE === "s3") {
// To enable S3 storage:
// 1. Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY env vars
import("./s3").then(({ s3StoragePlugin }) => {
registerStoragePlugin(s3StoragePlugin);
});
return;
}
if (ENABLED_STORAGE === "do-spaces") {
// To enable DO Spaces storage:
// 1. Set DO_SPACES_BUCKET, DO_SPACES_REGION, DO_SPACES_ACCESS_KEY_ID, DO_SPACES_SECRET_ACCESS_KEY env vars
import("./do-spaces").then(({ doSpacesStoragePlugin }) => {
registerStoragePlugin(doSpacesStoragePlugin);
});
return;
}
console.warn(`No storage plugin enabled for "${ENABLED_STORAGE}"`);
}
export { urlStoragePlugin };
+2 -2
View File
@@ -21,8 +21,8 @@ async function getS3Client() {
try {
// Use webpackIgnore to prevent bundling this optional dependency
// @ts-expect-error - optional dependency, installed at runtime
const module = await import(/* webpackIgnore: true */ "@aws-sdk/client-s3");
return module;
const s3Module = await import(/* webpackIgnore: true */ "@aws-sdk/client-s3");
return s3Module;
} catch {
throw new Error(
"S3 storage requires @aws-sdk/client-s3. Install it with: npm install @aws-sdk/client-s3"