chore(messages): update media prompts in multiple languages
This commit is contained in:
+11
-3
@@ -13,6 +13,10 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
|
|||||||
# AZURE_AD_CLIENT_ID=""
|
# AZURE_AD_CLIENT_ID=""
|
||||||
# AZURE_AD_CLIENT_SECRET=""
|
# AZURE_AD_CLIENT_SECRET=""
|
||||||
# AZURE_AD_TENANT_ID=""
|
# 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)
|
# Storage Providers (optional - enable in prompts.config.ts)
|
||||||
# S3_BUCKET=""
|
# S3_BUCKET=""
|
||||||
@@ -21,10 +25,14 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
|
|||||||
# S3_SECRET_ACCESS_KEY=""
|
# S3_SECRET_ACCESS_KEY=""
|
||||||
# S3_ENDPOINT="" # For S3-compatible services like MinIO
|
# S3_ENDPOINT="" # For S3-compatible services like MinIO
|
||||||
|
|
||||||
# GITHUB_CLIENT_ID=your_client_id
|
# DigitalOcean Spaces (optional - S3-compatible storage)
|
||||||
# GITHUB_CLIENT_SECRET=your_client_secret
|
# 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)
|
# AI Search (optional - enable aiSearch in prompts.config.ts)
|
||||||
# OPENAI_API_KEY=your_openai_api_key
|
# OPENAI_API_KEY=your_openai_api_key
|
||||||
|
|
||||||
GOOGLE_ANALYTICS_ID="G-XXXXXXXXX"
|
# GOOGLE_ANALYTICS_ID="G-XXXXXXXXX"
|
||||||
@@ -167,6 +167,12 @@
|
|||||||
"mediaUrl": "رابط الوسائط",
|
"mediaUrl": "رابط الوسائط",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "أدخل رابط ملف الوسائط لهذا الأمر",
|
"mediaUrlDescription": "أدخل رابط ملف الوسائط لهذا الأمر",
|
||||||
|
"mediaImage": "صورة الوسائط",
|
||||||
|
"clickToUpload": "انقر لرفع صورة",
|
||||||
|
"uploading": "جارٍ الرفع...",
|
||||||
|
"maxFileSize": "الحد الأقصى لحجم الملف: 1 ميجابايت (JPEG، PNG، GIF، WebP)",
|
||||||
|
"fileTooLarge": "الملف كبير جداً. الحد الأقصى للحجم هو 1 ميجابايت.",
|
||||||
|
"invalidFileType": "نوع ملف غير صالح. يُسمح فقط بـ JPEG و PNG و GIF و WebP.",
|
||||||
"privateDescription": "الأوامر الخاصة مرئية لك فقط",
|
"privateDescription": "الأوامر الخاصة مرئية لك فقط",
|
||||||
"update": "تحديث",
|
"update": "تحديث",
|
||||||
"createButton": "إنشاء",
|
"createButton": "إنشاء",
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
"mediaUrl": "Medien-URL",
|
"mediaUrl": "Medien-URL",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "Geben Sie die URL zur Mediendatei für diesen Prompt ein",
|
"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",
|
"privateDescription": "Private Prompts sind nur für Sie sichtbar",
|
||||||
"requiresMediaUpload": "Medien-Upload erforderlich",
|
"requiresMediaUpload": "Medien-Upload erforderlich",
|
||||||
"requiresMediaUploadDescription": "Benutzer müssen Mediendateien bereitstellen, um diesen Prompt zu verwenden",
|
"requiresMediaUploadDescription": "Benutzer müssen Mediendateien bereitstellen, um diesen Prompt zu verwenden",
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
"mediaUrl": "Media URL",
|
"mediaUrl": "Media URL",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "Enter a URL to the media file for this prompt",
|
"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",
|
"privateDescription": "Private prompts are only visible to you",
|
||||||
"requiresMediaUpload": "Requires Media Upload",
|
"requiresMediaUpload": "Requires Media Upload",
|
||||||
"requiresMediaUploadDescription": "Users need to provide media files to use this prompt",
|
"requiresMediaUploadDescription": "Users need to provide media files to use this prompt",
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"mediaUrl": "URL del Medio",
|
"mediaUrl": "URL del Medio",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "Ingresa una URL al archivo de medios para este prompt",
|
"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",
|
"privateDescription": "Los prompts privados solo son visibles para ti",
|
||||||
"update": "Actualizar",
|
"update": "Actualizar",
|
||||||
"createButton": "Crear",
|
"createButton": "Crear",
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
"mediaUrl": "URL du Média",
|
"mediaUrl": "URL du Média",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "Entrez l'URL du fichier média pour ce prompt",
|
"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",
|
"privateDescription": "Les prompts privés ne sont visibles que par vous",
|
||||||
"requiresMediaUpload": "Nécessite un Upload de Média",
|
"requiresMediaUpload": "Nécessite un Upload de Média",
|
||||||
"requiresMediaUploadDescription": "Les utilisateurs doivent fournir des fichiers média pour utiliser ce prompt",
|
"requiresMediaUploadDescription": "Les utilisateurs doivent fournir des fichiers média pour utiliser ce prompt",
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
"mediaUrl": "URL Media",
|
"mediaUrl": "URL Media",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "Inserisci un URL al file multimediale per questo prompt",
|
"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",
|
"privateDescription": "I prompt privati sono visibili solo a te",
|
||||||
"requiresMediaUpload": "Richiede Caricamento Media",
|
"requiresMediaUpload": "Richiede Caricamento Media",
|
||||||
"requiresMediaUploadDescription": "Gli utenti devono fornire file multimediali per usare questo prompt",
|
"requiresMediaUploadDescription": "Gli utenti devono fornire file multimediali per usare questo prompt",
|
||||||
|
|||||||
@@ -167,6 +167,12 @@
|
|||||||
"mediaUrl": "メディアURL",
|
"mediaUrl": "メディアURL",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "このプロンプトのメディアファイルURLを入力",
|
"mediaUrlDescription": "このプロンプトのメディアファイルURLを入力",
|
||||||
|
"mediaImage": "メディア画像",
|
||||||
|
"clickToUpload": "クリックして画像をアップロード",
|
||||||
|
"uploading": "アップロード中...",
|
||||||
|
"maxFileSize": "最大ファイルサイズ: 1MB (JPEG, PNG, GIF, WebP)",
|
||||||
|
"fileTooLarge": "ファイルが大きすぎます。最大サイズは1MBです。",
|
||||||
|
"invalidFileType": "無効なファイル形式です。JPEG、PNG、GIF、WebPのみ許可されています。",
|
||||||
"privateDescription": "非公開プロンプトはあなただけに表示されます",
|
"privateDescription": "非公開プロンプトはあなただけに表示されます",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"createButton": "作成",
|
"createButton": "作成",
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
"mediaUrl": "미디어 URL",
|
"mediaUrl": "미디어 URL",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "이 프롬프트의 미디어 파일 URL을 입력하세요",
|
"mediaUrlDescription": "이 프롬프트의 미디어 파일 URL을 입력하세요",
|
||||||
|
"mediaImage": "미디어 이미지",
|
||||||
|
"clickToUpload": "클릭하여 이미지 업로드",
|
||||||
|
"uploading": "업로드 중...",
|
||||||
|
"maxFileSize": "최대 파일 크기: 1MB (JPEG, PNG, GIF, WebP)",
|
||||||
|
"fileTooLarge": "파일이 너무 큽니다. 최대 크기는 1MB입니다.",
|
||||||
|
"invalidFileType": "잘못된 파일 형식입니다. JPEG, PNG, GIF, WebP만 허용됩니다.",
|
||||||
"privateDescription": "비공개 프롬프트는 본인만 볼 수 있습니다",
|
"privateDescription": "비공개 프롬프트는 본인만 볼 수 있습니다",
|
||||||
"requiresMediaUpload": "미디어 업로드 필요",
|
"requiresMediaUpload": "미디어 업로드 필요",
|
||||||
"requiresMediaUploadDescription": "이 프롬프트를 사용하려면 미디어 파일을 제공해야 합니다",
|
"requiresMediaUploadDescription": "이 프롬프트를 사용하려면 미디어 파일을 제공해야 합니다",
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
"mediaUrl": "URL da Mídia",
|
"mediaUrl": "URL da Mídia",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "Digite a URL do arquivo de mídia para este prompt",
|
"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ê",
|
"privateDescription": "Prompts privados são visíveis apenas para você",
|
||||||
"requiresMediaUpload": "Requer Upload de Mídia",
|
"requiresMediaUpload": "Requer Upload de Mídia",
|
||||||
"requiresMediaUploadDescription": "Usuários precisam fornecer arquivos de mídia para usar este prompt",
|
"requiresMediaUploadDescription": "Usuários precisam fornecer arquivos de mídia para usar este prompt",
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
"mediaUrl": "Medya URL",
|
"mediaUrl": "Medya URL",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "Bu prompt için medya dosyasının URL'sini girin",
|
"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",
|
"privateDescription": "Özel promptlar yalnızca size görünür",
|
||||||
"requiresMediaUpload": "Medya Yüklemesi Gerekli",
|
"requiresMediaUpload": "Medya Yüklemesi Gerekli",
|
||||||
"requiresMediaUploadDescription": "Bu promptu kullanmak için medya dosyası gereklidir",
|
"requiresMediaUploadDescription": "Bu promptu kullanmak için medya dosyası gereklidir",
|
||||||
|
|||||||
@@ -160,6 +160,12 @@
|
|||||||
"mediaUrl": "媒体链接",
|
"mediaUrl": "媒体链接",
|
||||||
"mediaUrlPlaceholder": "https://...",
|
"mediaUrlPlaceholder": "https://...",
|
||||||
"mediaUrlDescription": "输入此提示词的媒体文件链接",
|
"mediaUrlDescription": "输入此提示词的媒体文件链接",
|
||||||
|
"mediaImage": "媒体图片",
|
||||||
|
"clickToUpload": "点击上传图片",
|
||||||
|
"uploading": "上传中...",
|
||||||
|
"maxFileSize": "最大文件大小:1MB(JPEG、PNG、GIF、WebP)",
|
||||||
|
"fileTooLarge": "文件太大。最大大小为1MB。",
|
||||||
|
"invalidFileType": "无效的文件类型。仅允许JPEG、PNG、GIF和WebP。",
|
||||||
"privateDescription": "私有提示词仅对您可见",
|
"privateDescription": "私有提示词仅对您可见",
|
||||||
"requiresMediaUpload": "需要媒体上传",
|
"requiresMediaUpload": "需要媒体上传",
|
||||||
"requiresMediaUploadDescription": "用户需要提供媒体文件才能使用此提示词",
|
"requiresMediaUploadDescription": "用户需要提供媒体文件才能使用此提示词",
|
||||||
|
|||||||
Generated
+1653
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@aws-sdk/client-s3": "^3.948.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@prisma/client": "^6.19.0",
|
"@prisma/client": "^6.19.0",
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
"openai": "^6.10.0",
|
"openai": "^6.10.0",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, Upload, X } from "lucide-react";
|
||||||
import { VariableToolbar } from "./variable-toolbar";
|
import { VariableToolbar } from "./variable-toolbar";
|
||||||
import { ContributorSearch } from "./contributor-search";
|
import { ContributorSearch } from "./contributor-search";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -34,6 +34,160 @@ import { CodeEditor, type CodeEditorHandle } from "@/components/ui/code-editor";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { prettifyJson } from "@/lib/format";
|
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({
|
const createPromptSchema = (t: (key: string) => string) => z.object({
|
||||||
title: z.string().min(1, t("titleRequired")).max(200),
|
title: z.string().min(1, t("titleRequired")).max(200),
|
||||||
description: z.string().max(500).optional(),
|
description: z.string().max(500).optional(),
|
||||||
@@ -395,20 +549,11 @@ export function PromptForm({ categories, tags, initialData, initialContributors
|
|||||||
</div>
|
</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") && (
|
{(promptType === "IMAGE" || promptType === "VIDEO" || promptType === "AUDIO" || promptType === "STRUCTURED") && (
|
||||||
<FormField
|
<MediaField
|
||||||
control={form.control}
|
form={form}
|
||||||
name="mediaUrl"
|
t={t}
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("mediaUrl")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={t("mediaUrlPlaceholder")} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
import { registerStoragePlugin } from "../registry";
|
import { registerStoragePlugin } from "../registry";
|
||||||
import { urlStoragePlugin } from "./url";
|
import { urlStoragePlugin } from "./url";
|
||||||
|
|
||||||
|
const ENABLED_STORAGE: string = process.env.ENABLED_STORAGE || "url";
|
||||||
|
|
||||||
// Register all built-in storage plugins
|
// Register all built-in storage plugins
|
||||||
export function registerBuiltInStoragePlugins(): void {
|
export function registerBuiltInStoragePlugins(): void {
|
||||||
registerStoragePlugin(urlStoragePlugin);
|
if (ENABLED_STORAGE === "url") {
|
||||||
|
registerStoragePlugin(urlStoragePlugin);
|
||||||
// S3 plugin requires @aws-sdk/client-s3 to be installed
|
return
|
||||||
// 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
|
if (ENABLED_STORAGE === "s3") {
|
||||||
// 3. Uncomment the code below:
|
// To enable S3 storage:
|
||||||
//
|
// 1. Set S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY env vars
|
||||||
// import("./s3").then(({ s3StoragePlugin }) => {
|
import("./s3").then(({ s3StoragePlugin }) => {
|
||||||
// registerStoragePlugin(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 };
|
export { urlStoragePlugin };
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ async function getS3Client() {
|
|||||||
try {
|
try {
|
||||||
// Use webpackIgnore to prevent bundling this optional dependency
|
// Use webpackIgnore to prevent bundling this optional dependency
|
||||||
// @ts-expect-error - optional dependency, installed at runtime
|
// @ts-expect-error - optional dependency, installed at runtime
|
||||||
const module = await import(/* webpackIgnore: true */ "@aws-sdk/client-s3");
|
const s3Module = await import(/* webpackIgnore: true */ "@aws-sdk/client-s3");
|
||||||
return module;
|
return s3Module;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"S3 storage requires @aws-sdk/client-s3. Install it with: npm install @aws-sdk/client-s3"
|
"S3 storage requires @aws-sdk/client-s3. Install it with: npm install @aws-sdk/client-s3"
|
||||||
|
|||||||
Reference in New Issue
Block a user