add prompt import and export

This commit is contained in:
songtianlun 2025-08-30 00:58:03 +08:00
parent 3b0cc31f27
commit 6e0357f7f5
4 changed files with 632 additions and 1 deletions

View File

@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const userId = searchParams.get('userId')
if (!userId) {
return NextResponse.json({ error: 'Missing userId' }, { status: 400 })
}
// Fetch all prompts with their versions, tags, and albums
const prompts = await prisma.prompt.findMany({
where: { userId },
include: {
tags: {
select: {
name: true,
color: true
}
},
versions: {
select: {
version: true,
content: true,
changelog: true,
createdAt: true
},
orderBy: {
version: 'asc'
}
},
album: {
select: {
name: true,
description: true
}
}
},
orderBy: {
createdAt: 'desc'
}
})
// Transform the data for export
const exportData = {
exportVersion: '1.0',
exportDate: new Date().toISOString(),
userId,
totalPrompts: prompts.length,
prompts: prompts.map(prompt => ({
id: prompt.id,
name: prompt.name,
content: prompt.content,
description: prompt.description,
permissions: prompt.permissions,
visibility: prompt.visibility,
tags: prompt.tags.map(tag => ({
name: tag.name,
color: tag.color
})),
album: prompt.album ? {
name: prompt.album.name,
description: prompt.album.description
} : null,
versions: prompt.versions,
createdAt: prompt.createdAt.toISOString(),
updatedAt: prompt.updatedAt.toISOString()
}))
}
const fileName = `prompts-export-${new Date().toISOString().split('T')[0]}.json`
return new NextResponse(JSON.stringify(exportData, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${fileName}"`
}
})
} catch (error) {
console.error('Export error:', error)
return NextResponse.json({ error: 'Export failed' }, { status: 500 })
}
}

View File

@ -0,0 +1,185 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
interface ImportPrompt {
id: string
name: string
content: string
description?: string | null
permissions?: string
visibility?: string | null
tags?: Array<{ name: string; color?: string }>
album?: { name: string; description?: string | null } | null
versions?: Array<{
version: number
content: string
changelog?: string | null
createdAt: string
}>
createdAt?: string
updatedAt?: string
}
interface ImportData {
exportVersion?: string
exportDate?: string
userId?: string
totalPrompts?: number
prompts: ImportPrompt[]
}
export async function POST(req: NextRequest) {
try {
const formData = await req.formData()
const file = formData.get('file') as File
const userId = formData.get('userId') as string
if (!file || !userId) {
return NextResponse.json({ error: 'Missing file or userId' }, { status: 400 })
}
// Read and parse the file content
const fileContent = await file.text()
let importData: ImportData
try {
importData = JSON.parse(fileContent)
} catch {
return NextResponse.json({ error: 'Invalid JSON file' }, { status: 400 })
}
if (!importData.prompts || !Array.isArray(importData.prompts)) {
return NextResponse.json({ error: 'Invalid file format: missing prompts array' }, { status: 400 })
}
const results = {
imported: 0,
skipped: 0,
errors: 0,
messages: [] as string[]
}
// Process each prompt
for (const promptData of importData.prompts) {
try {
// Check if prompt with this ID already exists for this user
const existingPrompt = await prisma.prompt.findUnique({
where: { id: promptData.id }
})
if (existingPrompt) {
results.skipped++
results.messages.push(`Skipped prompt "${promptData.name}" (ID: ${promptData.id}) - already exists`)
continue
}
// Handle tags - create if they don't exist
const tagIds: string[] = []
if (promptData.tags && promptData.tags.length > 0) {
for (const tagData of promptData.tags) {
const existingTag = await prisma.promptTag.findUnique({
where: { name: tagData.name }
})
if (existingTag) {
tagIds.push(existingTag.id)
} else {
const newTag = await prisma.promptTag.create({
data: {
name: tagData.name,
color: tagData.color || '#3B82F6'
}
})
tagIds.push(newTag.id)
}
}
}
// Handle album - create if it doesn't exist
let albumId: string | null = null
if (promptData.album) {
const existingAlbum = await prisma.promptAlbum.findFirst({
where: { name: promptData.album.name }
})
if (existingAlbum) {
albumId = existingAlbum.id
} else {
const newAlbum = await prisma.promptAlbum.create({
data: {
name: promptData.album.name,
description: promptData.album.description
}
})
albumId = newAlbum.id
}
}
// Create the prompt with the original ID
const newPrompt = await prisma.prompt.create({
data: {
id: promptData.id,
name: promptData.name,
content: promptData.content,
description: promptData.description,
permissions: promptData.permissions || 'private',
visibility: promptData.visibility,
userId,
albumId,
createdAt: promptData.createdAt ? new Date(promptData.createdAt) : undefined,
updatedAt: promptData.updatedAt ? new Date(promptData.updatedAt) : undefined,
tags: {
connect: tagIds.map(id => ({ id }))
}
}
})
// Import versions if available
if (promptData.versions && promptData.versions.length > 0) {
for (const versionData of promptData.versions) {
await prisma.promptVersion.create({
data: {
promptId: newPrompt.id,
version: versionData.version,
content: versionData.content,
changelog: versionData.changelog,
createdAt: new Date(versionData.createdAt)
}
})
}
} else {
// If no versions are provided, create version 1 with the prompt content
await prisma.promptVersion.create({
data: {
promptId: newPrompt.id,
version: 1,
content: promptData.content,
changelog: 'Initial version'
}
})
}
results.imported++
results.messages.push(`Imported prompt "${promptData.name}" successfully`)
} catch (error) {
results.errors++
results.messages.push(`Error importing prompt "${promptData.name}": ${error instanceof Error ? error.message : 'Unknown error'}`)
console.error('Import error for prompt:', promptData.name, error)
}
}
return NextResponse.json({
success: true,
results,
message: `Import completed: ${results.imported} imported, ${results.skipped} skipped, ${results.errors} errors`
})
} catch (error) {
console.error('Import error:', error)
return NextResponse.json({
error: 'Import failed',
details: error instanceof Error ? error.message : 'Unknown error'
}, { status: 500 })
}
}

View File

@ -11,6 +11,7 @@ import { LoadingRing } from '@/components/ui/loading-ring'
import { EditPromptModal } from '@/components/studio/EditPromptModal' import { EditPromptModal } from '@/components/studio/EditPromptModal'
import { PromptDetailModal } from '@/components/studio/PromptDetailModal' import { PromptDetailModal } from '@/components/studio/PromptDetailModal'
import { BulkAddPromptModal } from '@/components/studio/BulkAddPromptModal' import { BulkAddPromptModal } from '@/components/studio/BulkAddPromptModal'
import { ImportPromptModal } from '@/components/studio/ImportPromptModal'
import { PermissionToggle } from '@/components/studio/PermissionToggle' import { PermissionToggle } from '@/components/studio/PermissionToggle'
import { import {
Plus, Plus,
@ -22,7 +23,9 @@ import {
ChevronDown, ChevronDown,
Grid, Grid,
List, List,
FolderPlus FolderPlus,
Download,
Upload
} from 'lucide-react' } from 'lucide-react'
interface Prompt { interface Prompt {
@ -77,6 +80,9 @@ export default function StudioPage() {
// Bulk Add Modal // Bulk Add Modal
const [isBulkAddModalOpen, setIsBulkAddModalOpen] = useState(false) const [isBulkAddModalOpen, setIsBulkAddModalOpen] = useState(false)
// Import Modal
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
// Pagination // Pagination
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 12 const itemsPerPage = 12
@ -269,6 +275,37 @@ export default function StudioPage() {
} }
} }
const handleExportPrompts = async () => {
if (!user) return
try {
const response = await fetch(`/api/prompts/export?userId=${user.id}`)
if (!response.ok) {
throw new Error('Export failed')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = `prompts-export-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Export error:', error)
// Could add a toast notification here
}
}
const handleImportComplete = () => {
fetchPrompts(false)
setIsImportModalOpen(false)
}
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
@ -303,6 +340,26 @@ export default function StudioPage() {
<p className="text-muted-foreground mt-1">{t('myPrompts')}</p> <p className="text-muted-foreground mt-1">{t('myPrompts')}</p>
</div> </div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3"> <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-3">
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleExportPrompts}
className="flex items-center justify-center space-x-2 h-10"
title="Export all prompts"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Export</span>
</Button>
<Button
variant="outline"
onClick={() => setIsImportModalOpen(true)}
className="flex items-center justify-center space-x-2 h-10"
title="Import prompts from file"
>
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Import</span>
</Button>
</div>
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsBulkAddModalOpen(true)} onClick={() => setIsBulkAddModalOpen(true)}
@ -781,6 +838,14 @@ export default function StudioPage() {
onSave={handleBulkAdd} onSave={handleBulkAdd}
userId={user?.id || ''} userId={user?.id || ''}
/> />
{/* Import Modal */}
<ImportPromptModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
onImportComplete={handleImportComplete}
userId={user?.id || ''}
/>
</div> </div>
) )
} }

View File

@ -0,0 +1,296 @@
'use client'
import { useState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { LoadingRing } from '@/components/ui/loading-ring'
import { Upload, FileText, X, CheckCircle, AlertCircle, Info } from 'lucide-react'
interface ImportPromptModalProps {
isOpen: boolean
onClose: () => void
onImportComplete: () => void
userId: string
}
interface ImportResult {
imported: number
skipped: number
errors: number
messages: string[]
}
export function ImportPromptModal({ isOpen, onClose, onImportComplete, userId }: ImportPromptModalProps) {
const [isUploading, setIsUploading] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
if (!isOpen) return null
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (file) {
setSelectedFile(file)
setError(null)
setImportResult(null)
}
}
const handleDrop = (event: React.DragEvent) => {
event.preventDefault()
const file = event.dataTransfer.files[0]
if (file && file.type === 'application/json') {
setSelectedFile(file)
setError(null)
setImportResult(null)
} else {
setError('Please select a JSON file')
}
}
const handleDragOver = (event: React.DragEvent) => {
event.preventDefault()
}
const handleImport = async () => {
if (!selectedFile || !userId) return
setIsUploading(true)
setError(null)
try {
const formData = new FormData()
formData.append('file', selectedFile)
formData.append('userId', userId)
const response = await fetch('/api/prompts/import', {
method: 'POST',
body: formData
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Import failed')
}
setImportResult(data.results)
if (data.results.imported > 0) {
onImportComplete()
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Import failed')
} finally {
setIsUploading(false)
}
}
const handleClose = () => {
setSelectedFile(null)
setImportResult(null)
setError(null)
onClose()
}
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-background rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<div className="flex items-center space-x-3">
<div className="bg-primary/10 p-2 rounded-lg">
<Upload className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-xl font-semibold text-foreground">Import Prompts</h2>
<p className="text-sm text-muted-foreground">Import prompts from a JSON file</p>
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleClose}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-6 overflow-y-auto">
{!importResult ? (
<>
{/* File Upload Area */}
<div className="space-y-4">
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
selectedFile
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/50'
}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{selectedFile ? (
<div className="space-y-3">
<div className="bg-primary/10 w-12 h-12 rounded-lg flex items-center justify-center mx-auto">
<FileText className="h-6 w-6 text-primary" />
</div>
<div>
<p className="font-medium text-foreground">{selectedFile.name}</p>
<p className="text-sm text-muted-foreground">{formatFileSize(selectedFile.size)}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedFile(null)}
className="mt-2"
>
Remove file
</Button>
</div>
) : (
<div className="space-y-3">
<div className="bg-muted w-12 h-12 rounded-lg flex items-center justify-center mx-auto">
<Upload className="h-6 w-6 text-muted-foreground" />
</div>
<div>
<p className="font-medium text-foreground">Drop your JSON file here</p>
<p className="text-sm text-muted-foreground">or click to browse</p>
</div>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
className="mt-2"
>
Select File
</Button>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileSelect}
className="hidden"
/>
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-900 dark:text-blue-100 mb-1">Import Instructions:</p>
<ul className="text-blue-700 dark:text-blue-300 space-y-1">
<li> Upload a JSON file exported from Prmbr</li>
<li> Prompts with duplicate IDs will be skipped</li>
<li> Tags and albums will be created if they don&apos;t exist</li>
<li> Version history will be preserved</li>
</ul>
</div>
</div>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center space-x-2">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
<p className="text-sm font-medium text-red-900 dark:text-red-100">{error}</p>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end space-x-3 pt-6">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={!selectedFile || isUploading}
className="min-w-[100px]"
>
{isUploading ? (
<>
<LoadingRing size="sm" className="mr-2" />
Importing...
</>
) : (
'Import'
)}
</Button>
</div>
</>
) : (
<>
{/* Import Results */}
<div className="space-y-6">
{/* Summary */}
<div className="bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center space-x-3">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
<div>
<p className="font-medium text-green-900 dark:text-green-100">Import Completed</p>
<p className="text-sm text-green-700 dark:text-green-300">
{importResult.imported} imported, {importResult.skipped} skipped, {importResult.errors} errors
</p>
</div>
</div>
</div>
{/* Statistics */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-card border border-border rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{importResult.imported}
</div>
<div className="text-sm text-muted-foreground">Imported</div>
</div>
<div className="bg-card border border-border rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{importResult.skipped}
</div>
<div className="text-sm text-muted-foreground">Skipped</div>
</div>
<div className="bg-card border border-border rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
{importResult.errors}
</div>
<div className="text-sm text-muted-foreground">Errors</div>
</div>
</div>
{/* Messages */}
{importResult.messages.length > 0 && (
<div className="space-y-2">
<h3 className="font-medium text-foreground">Import Details</h3>
<div className="bg-muted rounded-lg p-3 max-h-40 overflow-y-auto">
{importResult.messages.map((message, index) => (
<div key={index} className="text-sm text-muted-foreground py-1">
{message}
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end space-x-3 pt-4">
<Button onClick={handleClose}>
Close
</Button>
</div>
</div>
</>
)}
</div>
</div>
</div>
)
}