better plaza
This commit is contained in:
parent
33b1c87dea
commit
c048e05746
@ -266,6 +266,9 @@
|
||||
"promptCopied": "Prompt copied to clipboard",
|
||||
"promptDuplicated": "Prompt duplicated to your studio",
|
||||
"versions": "versions",
|
||||
"promptContent": "Prompt Content",
|
||||
"tags": "Tags",
|
||||
"author": "Author",
|
||||
"createdAt": "Created",
|
||||
"loadingPrompts": "Loading prompts...",
|
||||
"loadMore": "Load More",
|
||||
|
@ -266,6 +266,9 @@
|
||||
"promptCopied": "提示词已复制到剪贴板",
|
||||
"promptDuplicated": "提示词已复制到您的工作室",
|
||||
"versions": "个版本",
|
||||
"promptContent": "提示词内容",
|
||||
"tags": "标签",
|
||||
"author": "作者",
|
||||
"createdAt": "创建时间",
|
||||
"loadingPrompts": "加载提示词中...",
|
||||
"loadMore": "加载更多",
|
||||
|
@ -16,11 +16,11 @@ import {
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
Calendar,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { PromptDetailModal } from './PromptDetailModal'
|
||||
|
||||
interface Prompt {
|
||||
id: string
|
||||
@ -65,6 +65,7 @@ export function PromptCard({ prompt, onViewIncrement }: PromptCardProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [duplicating, setDuplicating] = useState(false)
|
||||
const [viewIncremented, setViewIncremented] = useState(false)
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false)
|
||||
|
||||
const latestVersion = prompt.versions[0]
|
||||
|
||||
@ -174,22 +175,20 @@ export function PromptCard({ prompt, onViewIncrement }: PromptCardProps) {
|
||||
onViewIncrement(prompt.id)
|
||||
setViewIncremented(true)
|
||||
}
|
||||
setIsDetailModalOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
<>
|
||||
<Card
|
||||
className="group hover:shadow-lg transition-all duration-300 hover:-translate-y-1 cursor-pointer animate-fade-in"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="mb-2">
|
||||
<CardTitle className="text-lg font-semibold line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{prompt.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center text-xs text-muted-foreground ml-2">
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
{prompt.stats?.viewCount || 0}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prompt.description && (
|
||||
@ -288,5 +287,13 @@ export function PromptCard({ prompt, onViewIncrement }: PromptCardProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<PromptDetailModal
|
||||
prompt={prompt}
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => setIsDetailModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
325
src/components/plaza/PromptDetailModal.tsx
Normal file
325
src/components/plaza/PromptDetailModal.tsx
Normal file
@ -0,0 +1,325 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { LegacyAvatar } from '@/components/ui/avatar'
|
||||
import {
|
||||
X,
|
||||
Copy,
|
||||
Download,
|
||||
Calendar,
|
||||
Check,
|
||||
Tag,
|
||||
User
|
||||
} from 'lucide-react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface Prompt {
|
||||
id: string
|
||||
name: string
|
||||
content: string
|
||||
description: string | null
|
||||
createdAt: string
|
||||
user: {
|
||||
id: string
|
||||
username: string | null
|
||||
avatar: string | null
|
||||
}
|
||||
tags: Array<{
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}>
|
||||
versions: Array<{
|
||||
content: string
|
||||
version: number
|
||||
createdAt: string
|
||||
}>
|
||||
stats?: {
|
||||
viewCount: number
|
||||
likeCount: number
|
||||
rating: number | null
|
||||
ratingCount: number
|
||||
} | null
|
||||
_count: {
|
||||
versions: number
|
||||
}
|
||||
}
|
||||
|
||||
interface PromptDetailModalProps {
|
||||
prompt: Prompt | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PromptDetailModal({
|
||||
prompt,
|
||||
isOpen,
|
||||
onClose
|
||||
}: PromptDetailModalProps) {
|
||||
const t = useTranslations('plaza')
|
||||
const tCommon = useTranslations('common')
|
||||
const { user } = useAuth()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [duplicating, setDuplicating] = useState(false)
|
||||
|
||||
if (!isOpen || !prompt) return null
|
||||
|
||||
const latestVersion = prompt.versions[0]
|
||||
const promptContent = latestVersion?.content || prompt.content
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(promptContent)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to copy:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
if (!user) {
|
||||
// Redirect to sign in
|
||||
window.location.href = '/signin'
|
||||
return
|
||||
}
|
||||
|
||||
setDuplicating(true)
|
||||
try {
|
||||
const requestData = {
|
||||
name: `${prompt.name} (Copy)`,
|
||||
content: promptContent,
|
||||
description: prompt.description,
|
||||
permissions: 'private',
|
||||
userId: user.id,
|
||||
tags: prompt.tags.map(tag => tag.name),
|
||||
}
|
||||
|
||||
const response = await fetch('/api/prompts', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestData),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Create a temporary success notification
|
||||
const notification = document.createElement('div')
|
||||
notification.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-md shadow-lg z-50 transition-all duration-300'
|
||||
notification.textContent = t('promptDuplicated')
|
||||
document.body.appendChild(notification)
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0'
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(notification)) {
|
||||
document.body.removeChild(notification)
|
||||
}
|
||||
}, 300)
|
||||
}, 2000)
|
||||
} else {
|
||||
// Create error notification
|
||||
console.error('API response error:', response.status, response.statusText)
|
||||
let errorMessage = 'Failed to duplicate prompt'
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
console.error('Error data:', errorData)
|
||||
errorMessage = errorData.error || `HTTP ${response.status}: ${response.statusText}`
|
||||
} catch (e) {
|
||||
console.error('Failed to parse error response:', e)
|
||||
errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
||||
}
|
||||
|
||||
const notification = document.createElement('div')
|
||||
notification.className = 'fixed top-4 right-4 bg-red-500 text-white px-4 py-2 rounded-md shadow-lg z-50 transition-all duration-300'
|
||||
notification.textContent = errorMessage
|
||||
document.body.appendChild(notification)
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0'
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(notification)) {
|
||||
document.body.removeChild(notification)
|
||||
}
|
||||
}, 300)
|
||||
}, 3000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate:', error)
|
||||
// Create error notification for network/other errors
|
||||
const notification = document.createElement('div')
|
||||
notification.className = 'fixed top-4 right-4 bg-red-500 text-white px-4 py-2 rounded-md shadow-lg z-50 transition-all duration-300'
|
||||
notification.textContent = 'Network error. Please try again.'
|
||||
document.body.appendChild(notification)
|
||||
|
||||
setTimeout(() => {
|
||||
notification.style.opacity = '0'
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(notification)) {
|
||||
document.body.removeChild(notification)
|
||||
}
|
||||
}, 300)
|
||||
}, 3000)
|
||||
} finally {
|
||||
setDuplicating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Intl.DateTimeFormat('default', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(dateString))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-background border border-border rounded-lg shadow-lg max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-semibold text-foreground truncate">
|
||||
{prompt.name}
|
||||
</h2>
|
||||
{prompt.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{prompt.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 ml-4 flex-shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
{/* Prompt Content */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-3">
|
||||
{t('promptContent')}
|
||||
</h3>
|
||||
<div className="bg-muted/50 rounded-lg p-4 border">
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{promptContent}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{prompt.tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<Tag className="h-4 w-4" />
|
||||
{t('tags')}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{prompt.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author & Meta Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-border">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
{t('author')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<LegacyAvatar
|
||||
src={prompt.user.avatar || undefined}
|
||||
alt={prompt.user.username || 'User'}
|
||||
size={40}
|
||||
className="w-10 h-10"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{prompt.user.username || 'Anonymous'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{t('createdAt')}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(prompt.createdAt)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
({formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-end space-x-3 p-6 border-t border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
>
|
||||
{tCommon('close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
<span>{t('promptCopied')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>{t('copyToClipboard')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDuplicate}
|
||||
disabled={duplicating}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
<span>{duplicating ? '...' : t('duplicateToStudio')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user