better plaza

This commit is contained in:
songtianlun 2025-08-03 13:06:12 +08:00
parent 33b1c87dea
commit c048e05746
4 changed files with 345 additions and 7 deletions

View File

@ -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",

View File

@ -266,6 +266,9 @@
"promptCopied": "提示词已复制到剪贴板",
"promptDuplicated": "提示词已复制到您的工作室",
"versions": "个版本",
"promptContent": "提示词内容",
"tags": "标签",
"author": "作者",
"createdAt": "创建时间",
"loadingPrompts": "加载提示词中...",
"loadMore": "加载更多",

View File

@ -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)}
/>
</>
)
}

View 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>
)
}