add plaze

This commit is contained in:
songtianlun 2025-08-03 11:48:58 +08:00
parent b052bbedf5
commit c4545bfc84
22 changed files with 1698 additions and 15 deletions

View File

@ -0,0 +1,62 @@
-- 创建 prompt_stats 表的SQL脚本
-- 这个脚本可以在Supabase SQL编辑器中手动运行
CREATE TABLE IF NOT EXISTS "public"."prompt_stats" (
"id" TEXT NOT NULL,
"promptId" TEXT NOT NULL,
"viewCount" INTEGER NOT NULL DEFAULT 0,
"likeCount" INTEGER NOT NULL DEFAULT 0,
"rating" DOUBLE PRECISION,
"ratingCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "prompt_stats_pkey" PRIMARY KEY ("id")
);
-- 创建唯一索引
CREATE UNIQUE INDEX IF NOT EXISTS "prompt_stats_promptId_key" ON "public"."prompt_stats"("promptId");
-- 添加外键约束
ALTER TABLE "public"."prompt_stats"
ADD CONSTRAINT "prompt_stats_promptId_fkey"
FOREIGN KEY ("promptId") REFERENCES "public"."prompts"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- 为查询优化添加索引
CREATE INDEX IF NOT EXISTS "prompt_stats_viewCount_idx" ON "public"."prompt_stats"("viewCount");
CREATE INDEX IF NOT EXISTS "prompt_stats_rating_idx" ON "public"."prompt_stats"("rating");
-- 创建更新时间戳的触发器
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW."updatedAt" = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE OR REPLACE TRIGGER update_prompt_stats_updated_at
BEFORE UPDATE ON "public"."prompt_stats"
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 插入一些示例数据(可选)
-- 为现有的公开提示词创建基础统计记录
INSERT INTO "public"."prompt_stats" ("id", "promptId", "viewCount", "likeCount", "rating", "ratingCount")
SELECT
gen_random_uuid()::text,
p.id,
floor(random() * 100 + 1)::integer, -- 随机浏览量 1-100
0,
NULL,
0
FROM "public"."prompts" p
WHERE p.permissions = 'public'
AND p.visibility = 'published'
AND NOT EXISTS (
SELECT 1 FROM "public"."prompt_stats" ps WHERE ps."promptId" = p.id
);
-- 验证创建结果
SELECT 'prompt_stats table created successfully' AS result;
SELECT COUNT(*) AS total_stats_records FROM "public"."prompt_stats";

96
debug-copy-feature.md Normal file
View File

@ -0,0 +1,96 @@
# 调试"复制到工作室"功能
## 🔧 已修复的问题
### 1. 头像显示问题 ✅
- **修复**: 头像尺寸从 `h-5 w-5` (20px) 增加到 `h-6 w-6` (24px)
- **修复**: 添加了 `min-w-0` 到父容器防止压缩
- **修复**: 移除了多余的背景样式冲突
### 2. API路由修复 ✅
- **修复**: 添加了 `permissions` 字段处理
- **修复**: 改进了错误处理和调试信息
- **修复**: 添加了详细的控制台日志
## 🧪 调试步骤
如果复制功能仍然不工作,请按以下步骤调试:
### 步骤1: 检查浏览器控制台
1. 打开浏览器开发者工具 (F12)
2. 转到 Console 标签
3. 点击"复制到工作室"按钮
4. 查看是否有以下日志:
```
Duplicating prompt with data: {name: "...", content: "...", ...}
```
### 步骤2: 检查网络请求
1. 在开发者工具中转到 Network 标签
2. 点击"复制到工作室"按钮
3. 查看是否有 POST 请求到 `/api/prompts`
4. 检查请求状态码:
- **200**: 成功
- **400**: 请求数据错误
- **401**: 用户认证问题
- **500**: 服务器错误
### 步骤3: 检查错误详情
如果看到红色错误通知,请:
1. 检查控制台中的错误详情
2. 查看网络请求的响应内容
3. 确认用户已登录且有有效的 user.id
## 🔍 常见问题和解决方案
### 问题1: "User ID is required" 错误
**原因**: useAuth hook 没有正确获取用户信息
**解决**:
- 确保用户已登录
- 检查 `user.id` 是否存在
- 刷新页面重新获取用户信息
### 问题2: 网络错误或超时
**原因**: API路由响应慢或数据库连接问题
**解决**:
- 检查数据库连接状态
- 确保所有环境变量正确设置
- 查看服务器端日志
### 问题3: "Failed to duplicate prompt"
**原因**: 数据验证失败或数据库约束问题
**解决**:
- 检查 prompt 数据是否完整
- 确保 tags 数组格式正确
- 检查数据库是否有相关表和权限
## 📝 当前代码状态
### 请求数据格式
```javascript
{
name: "Original Name (Copy)",
content: "prompt content",
description: "prompt description",
permissions: "private",
userId: "user-id-string",
tags: ["tag1", "tag2"]
}
```
### API响应处理
- ✅ 成功: 绿色通知 2秒
- ❌ API错误: 红色通知显示具体错误 3秒
- ❌ 网络错误: 红色通知显示网络错误 3秒
## 🚀 测试建议
1. **先在Studio页面手动创建一个prompt** 确认基本功能正常
2. **检查广场是否显示公开prompt** 确认数据可访问
3. **确保用户已登录** 查看右上角用户信息
4. **尝试复制不同类型的prompt** 有标签和无标签的
如果按照以上步骤仍然有问题,请提供:
- 浏览器控制台的错误信息
- 网络请求的详细响应
- 具体的错误通知内容

View File

@ -0,0 +1,84 @@
# 启用Stats功能说明
在运行了 `create-prompt-stats-table.sql` 创建了 `prompt_stats` 表之后,请按以下步骤启用完整的统计功能:
## 1. 更新Plaza API
`src/app/api/plaza/route.ts`更新include部分恢复stats查询
```typescript
include: {
user: { /* ... */ },
tags: { /* ... */ },
versions: { /* ... */ },
stats: { // 添加这个部分
select: {
viewCount: true,
likeCount: true,
rating: true,
ratingCount: true,
}
},
_count: { /* ... */ }
}
```
## 2. 启用浏览计数API
`src/app/api/plaza/[id]/view/route.ts` 中,取消注释并启用以下代码:
```typescript
// 将这些注释的代码恢复:
await prisma.promptStats.upsert({
where: { promptId: promptId },
update: { viewCount: { increment: 1 } },
create: { promptId: promptId, viewCount: 1 }
})
```
## 3. 可选:添加按浏览量排序
如果需要按浏览量排序可以在Plaza API和前端组件中添加相关逻辑
### 后端 (plaza/route.ts):
```typescript
// 排序条件
const orderBy: Record<string, any> = {}
if (sortBy === 'viewCount') {
orderBy.stats = { viewCount: sortOrder }
} else if (sortBy === 'name') {
orderBy.name = sortOrder
} else {
orderBy.createdAt = sortOrder
}
```
### 前端 (PlazaFilters.tsx):
在SelectContent中添加
```typescript
<SelectItem value="viewCount">{t('sortByMostViewed')}</SelectItem>
```
## 4. 重新生成Prisma客户端
```bash
npm run db:generate
```
## 5. 重启开发服务器
```bash
npm run dev
```
## 当前状态
现在的代码已经修复了所有错误即使没有stats表也能正常运行
- ✅ Plaza页面可以显示提示词
- ✅ Studio页面正常工作
- ✅ 搜索和过滤功能正常
- ✅ 复制和分享功能正常
- ⏳ 浏览计数功能暂时禁用(等待数据库表创建)
一旦运行了SQL脚本创建表就可以按上述步骤启用完整的统计功能。

View File

@ -2,6 +2,7 @@
"navigation": {
"home": "Home",
"studio": "Studio",
"plaza": "Plaza",
"profile": "Profile",
"admin": "Admin",
"signIn": "Sign In",
@ -244,6 +245,33 @@
"loadingDashboard": "Loading dashboard statistics...",
"loadingPrompts": "Loading prompts for review..."
},
"plaza": {
"title": "Prompt Plaza",
"subtitle": "Discover and explore prompts shared by the community",
"searchPlaceholder": "Search prompts by name or description...",
"filterByTag": "Filter by tag",
"allTags": "All Tags",
"sortBy": "Sort by",
"sortByNewest": "Newest",
"sortByOldest": "Oldest",
"sortByMostViewed": "Most Viewed",
"sortByName": "Name",
"noPromptsFound": "No prompts found",
"noPromptsMessage": "Try adjusting your search or filter criteria",
"viewCount": "views",
"sharedBy": "Shared by",
"copyPrompt": "Copy Prompt",
"copyToClipboard": "Copy to Clipboard",
"duplicateToStudio": "Duplicate to Studio",
"promptCopied": "Prompt copied to clipboard",
"promptDuplicated": "Prompt duplicated to your studio",
"versions": "versions",
"createdAt": "Created",
"loadingPrompts": "Loading prompts...",
"loadMore": "Load More",
"showingResults": "Showing {current} of {total} results",
"clearFilters": "Clear Filters"
},
"errors": {
"generic": "Something went wrong. Please try again.",
"network": "Network error. Please check your connection.",

View File

@ -2,6 +2,7 @@
"navigation": {
"home": "首页",
"studio": "工作室",
"plaza": "广场",
"profile": "个人资料",
"admin": "管理员后台",
"signIn": "登录",
@ -244,6 +245,33 @@
"loadingDashboard": "加载统计数据中...",
"loadingPrompts": "加载审核提示词中..."
},
"plaza": {
"title": "提示词广场",
"subtitle": "发现并探索社区分享的提示词",
"searchPlaceholder": "按名称或描述搜索提示词...",
"filterByTag": "按标签筛选",
"allTags": "所有标签",
"sortBy": "排序方式",
"sortByNewest": "最新",
"sortByOldest": "最早",
"sortByMostViewed": "最多浏览",
"sortByName": "名称",
"noPromptsFound": "未找到提示词",
"noPromptsMessage": "请尝试调整搜索或筛选条件",
"viewCount": "次浏览",
"sharedBy": "分享者",
"copyPrompt": "复制提示词",
"copyToClipboard": "复制到剪贴板",
"duplicateToStudio": "复制到工作室",
"promptCopied": "提示词已复制到剪贴板",
"promptDuplicated": "提示词已复制到您的工作室",
"versions": "个版本",
"createdAt": "创建时间",
"loadingPrompts": "加载提示词中...",
"loadMore": "加载更多",
"showingResults": "显示 {current} / {total} 个结果",
"clearFilters": "清除筛选"
},
"errors": {
"generic": "出现错误,请重试。",
"network": "网络错误,请检查您的网络连接。",

View File

@ -53,6 +53,7 @@ model Prompt {
album PromptAlbum? @relation(fields: [albumId], references: [id])
albumId String?
tests PromptTestRun[]
stats PromptStats?
@@map("prompts")
}
@ -107,6 +108,21 @@ model PromptTestRun {
@@map("prompt_test_runs")
}
model PromptStats {
id String @id @default(cuid())
promptId String @unique
viewCount Int @default(0) // 浏览计数
likeCount Int @default(0) // 点赞计数
rating Float? // 平均评分
ratingCount Int @default(0) // 评分数量
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade)
@@map("prompt_stats")
}
model Credit {
id String @id @default(cuid())
userId String

View File

@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params
const promptId = resolvedParams.id
// 首先检查prompt是否存在且为公开发布状态
const prompt = await prisma.prompt.findFirst({
where: {
id: promptId,
permissions: 'public',
visibility: 'published',
}
})
if (!prompt) {
return NextResponse.json(
{ error: 'Prompt not found' },
{ status: 404 }
)
}
// TODO: 暂时禁用浏览计数功能等待prompt_stats表创建
// 将来可以启用以下代码:
// await prisma.promptStats.upsert({
// where: { promptId: promptId },
// update: { viewCount: { increment: 1 } },
// create: { promptId: promptId, viewCount: 1 }
// })
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error updating view count:', error)
return NextResponse.json(
{ error: 'Failed to update view count' },
{ status: 500 }
)
}
}

112
src/app/api/plaza/route.ts Normal file
View File

@ -0,0 +1,112 @@
import { NextRequest, NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const search = searchParams.get('search') || ''
const tag = searchParams.get('tag') || ''
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '12')
const sortBy = searchParams.get('sortBy') || 'createdAt'
const sortOrder = searchParams.get('sortOrder') || 'desc'
const skip = (page - 1) * limit
// 构建where条件
const where: Record<string, unknown> = {
permissions: 'public',
visibility: 'published',
}
// 搜索条件:根据名称和描述匹配
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
]
}
// 标签过滤
if (tag) {
where.tags = {
some: {
name: tag
}
}
}
// 排序条件
const orderBy: Record<string, string> = {}
if (sortBy === 'name') {
orderBy.name = sortOrder
} else {
orderBy.createdAt = sortOrder
}
// 获取总数和数据
const [total, prompts] = await Promise.all([
prisma.prompt.count({ where }),
prisma.prompt.findMany({
where,
skip,
take: limit,
orderBy,
include: {
user: {
select: {
id: true,
username: true,
avatar: true,
}
},
tags: {
select: {
id: true,
name: true,
color: true,
}
},
versions: {
orderBy: {
version: 'desc'
},
take: 1,
select: {
content: true,
version: true,
createdAt: true,
}
},
_count: {
select: {
versions: true,
}
}
}
})
])
const totalPages = Math.ceil(total / limit)
return NextResponse.json({
prompts,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
}
})
} catch (error) {
console.error('Error fetching plaza prompts:', error)
return NextResponse.json(
{ error: 'Failed to fetch prompts' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,46 @@
import { NextResponse } from 'next/server'
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function GET() {
try {
// 获取在已发布的公开提示词中使用的标签
const tags = await prisma.promptTag.findMany({
where: {
prompts: {
some: {
permissions: 'public',
visibility: 'published',
}
}
},
include: {
_count: {
select: {
prompts: {
where: {
permissions: 'public',
visibility: 'published',
}
}
}
}
},
orderBy: {
prompts: {
_count: 'desc'
}
},
take: 20 // 最多返回20个热门标签
})
return NextResponse.json({ tags })
} catch (error) {
console.error('Error fetching plaza tags:', error)
return NextResponse.json(
{ error: 'Failed to fetch tags' },
{ status: 500 }
)
}
}

View File

@ -109,7 +109,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, description, content, tags, userId } = body
const { name, description, content, tags, userId, permissions } = body
if (!userId) {
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
@ -142,6 +142,7 @@ export async function POST(request: NextRequest) {
description,
content,
userId,
permissions: permissions || 'private',
tags: {
connect: tagObjects.map(tag => ({ id: tag.id }))
}

View File

@ -145,6 +145,64 @@ body {
}
}
@keyframes slideIn {
0% {
transform: translateY(10px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes scaleIn {
0% {
transform: scale(0.95);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Utility classes for animations */
.animate-slide-in {
animation: slideIn 0.3s ease-out;
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.line-clamp-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
/* Print styles */
@media print {
* {

31
src/app/plaza/page.tsx Normal file
View File

@ -0,0 +1,31 @@
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import { PlazaClient } from '@/components/plaza/PlazaClient'
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('plaza')
return {
title: `${t('title')} | Prmbr`,
description: t('subtitle'),
keywords: ['AI prompts', 'prompt library', 'community prompts', 'AI tools', 'prompt sharing', 'ChatGPT prompts', 'Claude prompts'],
openGraph: {
title: `${t('title')} | Prmbr`,
description: t('subtitle'),
type: 'website',
siteName: 'Prmbr',
},
twitter: {
card: 'summary_large_image',
title: `${t('title')} | Prmbr`,
description: t('subtitle'),
},
alternates: {
canonical: '/plaza',
}
}
}
export default function PlazaPage() {
return <PlazaClient />
}

View File

@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Avatar } from '@/components/ui/avatar'
import { LegacyAvatar } from '@/components/ui/avatar'
import { LoadingSpinner, LoadingOverlay, GradientLoading } from '@/components/ui/loading-spinner'
import { AvatarSkeleton, FormFieldSkeleton, TextAreaSkeleton } from '@/components/ui/skeleton'
import { Save, Eye, EyeOff, CreditCard, Crown, Star } from 'lucide-react'
@ -295,7 +295,7 @@ export default function ProfilePage() {
<div className="bg-card p-6 rounded-lg border border-border transition-all duration-200 hover:shadow-sm">
<h2 className="text-xl font-semibold text-foreground mb-4">{t('profilePicture')}</h2>
<div className="flex flex-col items-center">
<Avatar
<LegacyAvatar
src={user?.user_metadata?.avatar_url || profile?.avatar}
alt="Profile Avatar"
size={96}

View File

@ -35,6 +35,9 @@ export function Header() {
<Link href="/studio" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
{t('studio')}
</Link>
<Link href="/plaza" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
{t('plaza')}
</Link>
<a href="#pricing" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
Pricing
</a>
@ -89,6 +92,9 @@ export function Header() {
<Link href="/studio" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
{t('studio')}
</Link>
<Link href="/plaza" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
{t('plaza')}
</Link>
<a href="#pricing" className="block text-muted-foreground hover:text-foreground px-3 py-2 text-base font-medium transition-colors rounded-md hover:bg-accent">
Pricing
</a>

View File

@ -0,0 +1,256 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useTranslations } from 'next-intl'
import { Header } from '@/components/layout/Header'
import { PlazaFilters } from './PlazaFilters'
import { PromptCard } from './PromptCard'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
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 PlazaResponse {
prompts: Prompt[]
pagination: {
page: number
limit: number
total: number
totalPages: number
hasNext: boolean
hasPrev: boolean
}
}
interface Tag {
id: string
name: string
color: string
_count: {
prompts: number
}
}
export function PlazaClient() {
const t = useTranslations('plaza')
const [prompts, setPrompts] = useState<Prompt[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [search, setSearch] = useState('')
const [selectedTag, setSelectedTag] = useState('')
const [sortBy, setSortBy] = useState('createdAt')
const [sortOrder, setSortOrder] = useState('desc')
const [pagination, setPagination] = useState({
page: 1,
limit: 12,
total: 0,
totalPages: 0,
hasNext: false,
hasPrev: false,
})
const fetchPrompts = useCallback(async (page = 1, reset = false) => {
if (page === 1) setLoading(true)
else setLoadingMore(true)
try {
const params = new URLSearchParams({
page: page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder,
})
if (search) params.append('search', search)
if (selectedTag) params.append('tag', selectedTag)
const response = await fetch(`/api/plaza?${params}`)
if (!response.ok) throw new Error('Failed to fetch')
const data: PlazaResponse = await response.json()
if (reset) {
setPrompts(data.prompts)
} else {
setPrompts(prev => [...prev, ...data.prompts])
}
setPagination(data.pagination)
} catch (error) {
console.error('Error fetching prompts:', error)
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [search, selectedTag, sortBy, sortOrder, pagination.limit])
const fetchTags = useCallback(async () => {
try {
const response = await fetch('/api/plaza/tags')
if (!response.ok) throw new Error('Failed to fetch tags')
const data = await response.json()
setTags(data.tags)
} catch (error) {
console.error('Error fetching tags:', error)
}
}, [])
useEffect(() => {
fetchTags()
}, [fetchTags])
useEffect(() => {
fetchPrompts(1, true)
}, [search, selectedTag, sortBy, sortOrder, fetchPrompts])
const handleLoadMore = () => {
if (pagination.hasNext && !loadingMore) {
fetchPrompts(pagination.page + 1, false)
}
}
const handleClearFilters = () => {
setSearch('')
setSelectedTag('')
setSortBy('createdAt')
setSortOrder('desc')
}
const incrementViewCount = async (promptId: string) => {
try {
await fetch(`/api/plaza/${promptId}/view`, {
method: 'POST',
})
} catch (error) {
console.error('Error updating view count:', error)
}
}
return (
<div className="min-h-screen bg-background">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-foreground mb-4">
{t('title')}
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
{t('subtitle')}
</p>
</div>
{/* Filters */}
<PlazaFilters
search={search}
onSearchChange={setSearch}
selectedTag={selectedTag}
onTagChange={setSelectedTag}
tags={tags}
sortBy={sortBy}
onSortByChange={setSortBy}
sortOrder={sortOrder}
onSortOrderChange={setSortOrder}
onClearFilters={handleClearFilters}
/>
{/* Results */}
<div className="mb-6">
<p className="text-sm text-muted-foreground">
{t('showingResults', {
current: prompts.length,
total: pagination.total
})}
</p>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('loadingPrompts')}</span>
</div>
) : prompts.length > 0 ? (
<>
{/* Prompt Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{prompts.map((prompt) => (
<PromptCard
key={prompt.id}
prompt={prompt}
onViewIncrement={incrementViewCount}
/>
))}
</div>
{/* Load More */}
{pagination.hasNext && (
<div className="text-center">
<Button
onClick={handleLoadMore}
disabled={loadingMore}
variant="outline"
size="lg"
>
{loadingMore ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
{t('loadingPrompts')}
</>
) : (
t('loadMore')
)}
</Button>
</div>
)}
</>
) : (
<div className="text-center py-12">
<h3 className="text-lg font-medium text-foreground mb-2">
{t('noPromptsFound')}
</h3>
<p className="text-muted-foreground mb-4">
{t('noPromptsMessage')}
</p>
<Button variant="outline" onClick={handleClearFilters}>
{t('clearFilters')}
</Button>
</div>
)}
</main>
</div>
)
}

View File

@ -0,0 +1,234 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Search, Filter, X } from 'lucide-react'
interface Tag {
id: string
name: string
color: string
_count: {
prompts: number
}
}
interface PlazaFiltersProps {
search: string
onSearchChange: (value: string) => void
selectedTag: string
onTagChange: (value: string) => void
tags: Tag[]
sortBy: string
onSortByChange: (value: string) => void
sortOrder: string
onSortOrderChange: (value: string) => void
onClearFilters: () => void
}
export function PlazaFilters({
search,
onSearchChange,
selectedTag,
onTagChange,
tags,
sortBy,
onSortByChange,
sortOrder,
onSortOrderChange,
onClearFilters,
}: PlazaFiltersProps) {
const t = useTranslations('plaza')
const [showMobileFilters, setShowMobileFilters] = useState(false)
const hasActiveFilters = search || selectedTag || sortBy !== 'createdAt' || sortOrder !== 'desc'
return (
<div className="mb-8">
{/* Desktop Filters */}
<div className="hidden md:flex items-center gap-4 mb-4">
{/* Search */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('searchPlaceholder')}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
{/* Tag Filter */}
<Select value={selectedTag} onValueChange={onTagChange}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={t('filterByTag')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t('allTags')}</SelectItem>
{tags.map((tag) => (
<SelectItem key={tag.id} value={tag.name}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span>{tag.name}</span>
<span className="text-xs text-muted-foreground">
({tag._count.prompts})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Sort */}
<Select value={sortBy} onValueChange={onSortByChange}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt">{t('sortByNewest')}</SelectItem>
<SelectItem value="name">{t('sortByName')}</SelectItem>
</SelectContent>
</Select>
{/* Clear Filters */}
{hasActiveFilters && (
<Button variant="outline" onClick={onClearFilters}>
<X className="h-4 w-4 mr-2" />
{t('clearFilters')}
</Button>
)}
</div>
{/* Mobile Filters */}
<div className="md:hidden mb-4">
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('searchPlaceholder')}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
{/* Filter Toggle */}
<div className="flex items-center justify-between mb-4">
<Button
variant="outline"
onClick={() => setShowMobileFilters(!showMobileFilters)}
>
<Filter className="h-4 w-4 mr-2" />
{t('filter')}
</Button>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters}>
<X className="h-4 w-4 mr-1" />
{t('clearFilters')}
</Button>
)}
</div>
{/* Mobile Filter Panel */}
{showMobileFilters && (
<div className="space-y-4 p-4 border rounded-lg bg-card">
{/* Tag Filter */}
<div>
<label className="text-sm font-medium mb-2 block">
{t('filterByTag')}
</label>
<Select value={selectedTag} onValueChange={onTagChange}>
<SelectTrigger>
<SelectValue placeholder={t('allTags')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{t('allTags')}</SelectItem>
{tags.map((tag) => (
<SelectItem key={tag.id} value={tag.name}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span>{tag.name}</span>
<span className="text-xs text-muted-foreground">
({tag._count.prompts})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Sort */}
<div>
<label className="text-sm font-medium mb-2 block">
{t('sortBy')}
</label>
<Select value={sortBy} onValueChange={onSortByChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt">{t('sortByNewest')}</SelectItem>
<SelectItem value="name">{t('sortByName')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2">
{search && (
<Badge variant="secondary" className="gap-1">
{search}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => onSearchChange('')}
/>
</Badge>
)}
{selectedTag && (
<Badge variant="secondary" className="gap-1">
{selectedTag}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => onTagChange('')}
/>
</Badge>
)}
{(sortBy !== 'createdAt' || sortOrder !== 'desc') && (
<Badge variant="secondary" className="gap-1">
{t(sortBy === 'name' ? 'sortByName' : 'sortByNewest')}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => {
onSortByChange('createdAt')
onSortOrderChange('desc')
}}
/>
</Badge>
)}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,295 @@
'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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card'
import {
Copy,
Download,
Eye,
Calendar,
Check
} 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 PromptCardProps {
prompt: Prompt
onViewIncrement: (promptId: string) => void
}
export function PromptCard({ prompt, onViewIncrement }: PromptCardProps) {
const t = useTranslations('plaza')
const { user } = useAuth()
const [copied, setCopied] = useState(false)
const [duplicating, setDuplicating] = useState(false)
const [viewIncremented, setViewIncremented] = useState(false)
const latestVersion = prompt.versions[0]
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(latestVersion?.content || prompt.content)
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: latestVersion?.content || prompt.content,
description: prompt.description,
permissions: 'private',
userId: user.id,
tags: prompt.tags.map(tag => tag.name),
}
console.log('Duplicating prompt with data:', requestData)
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 handleCardClick = () => {
if (!viewIncremented) {
onViewIncrement(prompt.id)
setViewIncremented(true)
}
}
return (
<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">
<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 && (
<CardDescription className="line-clamp-2">
{prompt.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* Content Preview */}
<div className="bg-muted/50 rounded-md p-3">
<p className="text-sm text-foreground line-clamp-3 font-mono">
{latestVersion?.content || prompt.content}
</p>
</div>
{/* Tags */}
{prompt.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{prompt.tags.slice(0, 3).map((tag) => (
<Badge
key={tag.id}
variant="secondary"
className="text-xs"
style={{ backgroundColor: `${tag.color}20`, color: tag.color }}
>
{tag.name}
</Badge>
))}
{prompt.tags.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{prompt.tags.length - 3}
</Badge>
)}
</div>
)}
{/* Author & Meta */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center space-x-2 min-w-0">
<Avatar className="h-6 w-6 shrink-0">
<AvatarImage
src={prompt.user.avatar || undefined}
alt={prompt.user.username || 'User'}
/>
<AvatarFallback className="text-xs">
{prompt.user.username?.[0]?.toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<span className="truncate max-w-[100px]">
{prompt.user.username || 'Anonymous'}
</span>
</div>
<div className="flex items-center">
<Calendar className="h-3 w-3 mr-1" />
{formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleCopy()
}}
className="flex-1"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
{t('promptCopied')}
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
{t('copyToClipboard')}
</>
)}
</Button>
<Button
variant="default"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDuplicate()
}}
disabled={duplicating}
className="flex-1"
>
<Download className="h-4 w-4 mr-2" />
{duplicating ? '...' : t('duplicateToStudio')}
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@ -2,15 +2,80 @@
import Image from 'next/image'
import { User } from 'lucide-react'
import { cn } from '@/lib/utils'
interface AvatarProps {
children: React.ReactNode
className?: string
}
export function Avatar({ children, className }: AvatarProps) {
return (
<div className={cn(
'relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full bg-muted',
className
)}>
{children}
</div>
)
}
interface AvatarImageProps {
src?: string
alt?: string
className?: string
}
export function AvatarImage({ src, alt, className }: AvatarImageProps) {
if (!src) return null
if (src.startsWith('data:')) {
// eslint-disable-next-line @next/next/no-img-element
return (
<img
src={src}
alt={alt}
className={cn('aspect-square h-full w-full object-cover', className)}
/>
)
}
return (
<Image
src={src}
alt={alt || ''}
width={40}
height={40}
className={cn('aspect-square h-full w-full object-cover', className)}
/>
)
}
interface AvatarFallbackProps {
children: React.ReactNode
className?: string
}
export function AvatarFallback({ children, className }: AvatarFallbackProps) {
return (
<div className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted text-muted-foreground text-sm font-medium',
className
)}>
{children}
</div>
)
}
// Legacy component for backward compatibility
interface LegacyAvatarProps {
src?: string
alt?: string
size?: number
className?: string
}
export function Avatar({ src, alt = "Avatar", size = 96, className = "" }: AvatarProps) {
export function LegacyAvatar({ src, alt = "Avatar", size = 96, className = "" }: LegacyAvatarProps) {
// Convert pixel size to Tailwind classes
const getSizeClasses = (size: number) => {
if (size <= 32) return "w-8 h-8"

View File

@ -4,9 +4,10 @@ interface BadgeProps {
children: React.ReactNode
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
className?: string
style?: React.CSSProperties
}
export function Badge({ children, variant = 'default', className }: BadgeProps) {
export function Badge({ children, variant = 'default', className, style }: BadgeProps) {
const baseStyles = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2'
const variants = {
@ -17,7 +18,7 @@ export function Badge({ children, variant = 'default', className }: BadgeProps)
}
return (
<div className={cn(baseStyles, variants[variant], className)}>
<div className={cn(baseStyles, variants[variant], className)} style={style}>
{children}
</div>
)

View File

@ -3,14 +3,83 @@ import { cn } from '@/lib/utils'
interface CardProps {
children: React.ReactNode
className?: string
onClick?: () => void
}
export function Card({ children, className }: CardProps) {
export function Card({ children, className, ...props }: CardProps) {
return (
<div className={cn(
<div
className={cn(
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
className
)}>
)}
{...props}
>
{children}
</div>
)
}
interface CardHeaderProps {
children: React.ReactNode
className?: string
}
export function CardHeader({ children, className }: CardHeaderProps) {
return (
<div className={cn('flex flex-col space-y-1.5 p-6', className)}>
{children}
</div>
)
}
interface CardTitleProps {
children: React.ReactNode
className?: string
}
export function CardTitle({ children, className }: CardTitleProps) {
return (
<h3 className={cn('text-2xl font-semibold leading-none tracking-tight', className)}>
{children}
</h3>
)
}
interface CardDescriptionProps {
children: React.ReactNode
className?: string
}
export function CardDescription({ children, className }: CardDescriptionProps) {
return (
<p className={cn('text-sm text-muted-foreground', className)}>
{children}
</p>
)
}
interface CardContentProps {
children: React.ReactNode
className?: string
}
export function CardContent({ children, className }: CardContentProps) {
return (
<div className={cn('p-6 pt-0', className)}>
{children}
</div>
)
}
interface CardFooterProps {
children: React.ReactNode
className?: string
}
export function CardFooter({ children, className }: CardFooterProps) {
return (
<div className={cn('flex items-center p-6 pt-0', className)}>
{children}
</div>
)

View File

@ -0,0 +1,149 @@
'use client'
import * as React from 'react'
import { ChevronDown, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SelectContextType {
value: string
onChange: (value: string) => void
open: boolean
setOpen: (open: boolean) => void
}
const SelectContext = React.createContext<SelectContextType | undefined>(undefined)
interface SelectProps {
value: string
onValueChange: (value: string) => void
children: React.ReactNode
}
export function Select({ value, onValueChange, children }: SelectProps) {
const [open, setOpen] = React.useState(false)
const contextValue: SelectContextType = {
value,
onChange: onValueChange,
open,
setOpen,
}
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element
if (!target.closest('[data-select-root]')) {
setOpen(false)
}
}
if (open) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [open])
return (
<SelectContext.Provider value={contextValue}>
<div className="relative" data-select-root>
{children}
</div>
</SelectContext.Provider>
)
}
interface SelectTriggerProps {
className?: string
children: React.ReactNode
}
export function SelectTrigger({ className, children }: SelectTriggerProps) {
const context = React.useContext(SelectContext)
if (!context) throw new Error('SelectTrigger must be used within Select')
return (
<button
type="button"
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
onClick={() => context.setOpen(!context.open)}
>
{children}
<ChevronDown className={cn('h-4 w-4 opacity-50 transition-transform', context.open && 'rotate-180')} />
</button>
)
}
interface SelectValueProps {
placeholder?: string
}
export function SelectValue({ placeholder }: SelectValueProps) {
const context = React.useContext(SelectContext)
if (!context) throw new Error('SelectValue must be used within Select')
return (
<span className={cn(!context.value && 'text-muted-foreground')}>
{context.value || placeholder}
</span>
)
}
interface SelectContentProps {
className?: string
children: React.ReactNode
}
export function SelectContent({ className, children }: SelectContentProps) {
const context = React.useContext(SelectContext)
if (!context) throw new Error('SelectContent must be used within Select')
if (!context.open) return null
return (
<div
className={cn(
'absolute z-50 top-full mt-1 min-w-[8rem] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
className
)}
>
<div className="p-1 max-h-60 overflow-auto">
{children}
</div>
</div>
)
}
interface SelectItemProps {
value: string
className?: string
children: React.ReactNode
}
export function SelectItem({ value, className, children }: SelectItemProps) {
const context = React.useContext(SelectContext)
if (!context) throw new Error('SelectItem must be used within Select')
const isSelected = context.value === value
return (
<div
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
isSelected && 'bg-accent',
className
)}
onClick={() => {
context.onChange(value)
context.setOpen(false)
}}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{isSelected && <Check className="h-4 w-4" />}
</span>
{children}
</div>
)
}

View File

@ -4,7 +4,7 @@ import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { User as SupabaseUser } from '@supabase/supabase-js'
import { Button } from '@/components/ui/button'
import { Avatar } from '@/components/ui/avatar'
import { LegacyAvatar } from '@/components/ui/avatar'
import { ChevronDown, User, LogOut, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUser } from '@/hooks/useUser'
@ -40,7 +40,7 @@ export function MobileUserMenu({
{/* User info section */}
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-3">
<Avatar
<LegacyAvatar
src={userAvatar}
alt={userName}
size={48}
@ -146,7 +146,7 @@ export function UserAvatarDropdown({
aria-expanded={isOpen}
aria-haspopup="menu"
>
<Avatar
<LegacyAvatar
src={userAvatar}
alt={userName}
size={32}
@ -167,7 +167,7 @@ export function UserAvatarDropdown({
{/* User info section */}
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-3">
<Avatar
<LegacyAvatar
src={userAvatar}
alt={userName}
size={40}