From c4545bfc84fb3af534ccbdfe928d530ad60690cc Mon Sep 17 00:00:00 2001 From: songtianlun Date: Sun, 3 Aug 2025 11:48:58 +0800 Subject: [PATCH] add plaze --- create-prompt-stats-table.sql | 62 +++++ debug-copy-feature.md | 96 +++++++ enable-stats-after-migration.md | 84 ++++++ messages/en.json | 28 ++ messages/zh.json | 28 ++ prisma/schema.prisma | 16 ++ src/app/api/plaza/[id]/view/route.ts | 46 ++++ src/app/api/plaza/route.ts | 112 ++++++++ src/app/api/plaza/tags/route.ts | 46 ++++ src/app/api/prompts/route.ts | 3 +- src/app/globals.css | 58 ++++ src/app/plaza/page.tsx | 31 +++ src/app/profile/page.tsx | 4 +- src/components/layout/Header.tsx | 6 + src/components/plaza/PlazaClient.tsx | 256 ++++++++++++++++++ src/components/plaza/PlazaFilters.tsx | 234 ++++++++++++++++ src/components/plaza/PromptCard.tsx | 295 +++++++++++++++++++++ src/components/ui/avatar.tsx | 67 ++++- src/components/ui/badge.tsx | 5 +- src/components/ui/card.tsx | 79 +++++- src/components/ui/select.tsx | 149 +++++++++++ src/components/ui/user-avatar-dropdown.tsx | 8 +- 22 files changed, 1698 insertions(+), 15 deletions(-) create mode 100644 create-prompt-stats-table.sql create mode 100644 debug-copy-feature.md create mode 100644 enable-stats-after-migration.md create mode 100644 src/app/api/plaza/[id]/view/route.ts create mode 100644 src/app/api/plaza/route.ts create mode 100644 src/app/api/plaza/tags/route.ts create mode 100644 src/app/plaza/page.tsx create mode 100644 src/components/plaza/PlazaClient.tsx create mode 100644 src/components/plaza/PlazaFilters.tsx create mode 100644 src/components/plaza/PromptCard.tsx create mode 100644 src/components/ui/select.tsx diff --git a/create-prompt-stats-table.sql b/create-prompt-stats-table.sql new file mode 100644 index 0000000..07b7860 --- /dev/null +++ b/create-prompt-stats-table.sql @@ -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"; \ No newline at end of file diff --git a/debug-copy-feature.md b/debug-copy-feature.md new file mode 100644 index 0000000..0aebec0 --- /dev/null +++ b/debug-copy-feature.md @@ -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** 有标签和无标签的 + +如果按照以上步骤仍然有问题,请提供: +- 浏览器控制台的错误信息 +- 网络请求的详细响应 +- 具体的错误通知内容 \ No newline at end of file diff --git a/enable-stats-after-migration.md b/enable-stats-after-migration.md new file mode 100644 index 0000000..7c7286d --- /dev/null +++ b/enable-stats-after-migration.md @@ -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 = {} +if (sortBy === 'viewCount') { + orderBy.stats = { viewCount: sortOrder } +} else if (sortBy === 'name') { + orderBy.name = sortOrder +} else { + orderBy.createdAt = sortOrder +} +``` + +### 前端 (PlazaFilters.tsx): +在SelectContent中添加: +```typescript +{t('sortByMostViewed')} +``` + +## 4. 重新生成Prisma客户端 + +```bash +npm run db:generate +``` + +## 5. 重启开发服务器 + +```bash +npm run dev +``` + +## 当前状态 + +现在的代码已经修复了所有错误,即使没有stats表也能正常运行: + +- ✅ Plaza页面可以显示提示词 +- ✅ Studio页面正常工作 +- ✅ 搜索和过滤功能正常 +- ✅ 复制和分享功能正常 +- ⏳ 浏览计数功能暂时禁用(等待数据库表创建) + +一旦运行了SQL脚本创建表,就可以按上述步骤启用完整的统计功能。 \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index cbe84b6..9bbb8d1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/zh.json b/messages/zh.json index ff9232d..bab673e 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -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": "网络错误,请检查您的网络连接。", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b7068f..5f47b93 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/plaza/[id]/view/route.ts b/src/app/api/plaza/[id]/view/route.ts new file mode 100644 index 0000000..90c4a72 --- /dev/null +++ b/src/app/api/plaza/[id]/view/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/plaza/route.ts b/src/app/api/plaza/route.ts new file mode 100644 index 0000000..9ce6e3a --- /dev/null +++ b/src/app/api/plaza/route.ts @@ -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 = { + 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 = {} + 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 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/plaza/tags/route.ts b/src/app/api/plaza/tags/route.ts new file mode 100644 index 0000000..cf9b6d7 --- /dev/null +++ b/src/app/api/plaza/tags/route.ts @@ -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 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index 81afa99..e9f2531 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -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 })) } diff --git a/src/app/globals.css b/src/app/globals.css index 634ae3d..4c9cf93 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { * { diff --git a/src/app/plaza/page.tsx b/src/app/plaza/page.tsx new file mode 100644 index 0000000..40140fb --- /dev/null +++ b/src/app/plaza/page.tsx @@ -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 { + 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 +} \ No newline at end of file diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index f79ec52..c488ae0 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -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() {

{t('profilePicture')}

- {t('studio')} + + {t('plaza')} + Pricing @@ -89,6 +92,9 @@ export function Header() { {t('studio')} + + {t('plaza')} + Pricing diff --git a/src/components/plaza/PlazaClient.tsx b/src/components/plaza/PlazaClient.tsx new file mode 100644 index 0000000..549444f --- /dev/null +++ b/src/components/plaza/PlazaClient.tsx @@ -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([]) + const [tags, setTags] = useState([]) + 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 ( +
+
+ +
+ {/* Header */} +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ + {/* Filters */} + + + {/* Results */} +
+

+ {t('showingResults', { + current: prompts.length, + total: pagination.total + })} +

+
+ + {/* Content */} + {loading ? ( +
+ + {t('loadingPrompts')} +
+ ) : prompts.length > 0 ? ( + <> + {/* Prompt Grid */} +
+ {prompts.map((prompt) => ( + + ))} +
+ + {/* Load More */} + {pagination.hasNext && ( +
+ +
+ )} + + ) : ( +
+

+ {t('noPromptsFound')} +

+

+ {t('noPromptsMessage')} +

+ +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/plaza/PlazaFilters.tsx b/src/components/plaza/PlazaFilters.tsx new file mode 100644 index 0000000..08f04d8 --- /dev/null +++ b/src/components/plaza/PlazaFilters.tsx @@ -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 ( +
+ {/* Desktop Filters */} +
+ {/* Search */} +
+ + onSearchChange(e.target.value)} + className="pl-9" + /> +
+ + {/* Tag Filter */} + + + {/* Sort */} + + + {/* Clear Filters */} + {hasActiveFilters && ( + + )} +
+ + {/* Mobile Filters */} +
+ {/* Search */} +
+ + onSearchChange(e.target.value)} + className="pl-9" + /> +
+ + {/* Filter Toggle */} +
+ + + {hasActiveFilters && ( + + )} +
+ + {/* Mobile Filter Panel */} + {showMobileFilters && ( +
+ {/* Tag Filter */} +
+ + +
+ + {/* Sort */} +
+ + +
+
+ )} +
+ + {/* Active Filters Display */} + {hasActiveFilters && ( +
+ {search && ( + + {search} + onSearchChange('')} + /> + + )} + {selectedTag && ( + + {selectedTag} + onTagChange('')} + /> + + )} + {(sortBy !== 'createdAt' || sortOrder !== 'desc') && ( + + {t(sortBy === 'name' ? 'sortByName' : 'sortByNewest')} + { + onSortByChange('createdAt') + onSortOrderChange('desc') + }} + /> + + )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/components/plaza/PromptCard.tsx b/src/components/plaza/PromptCard.tsx new file mode 100644 index 0000000..d16c8f1 --- /dev/null +++ b/src/components/plaza/PromptCard.tsx @@ -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 ( + + +
+ + {prompt.name} + +
+ + {prompt.stats?.viewCount || 0} +
+
+ + {prompt.description && ( + + {prompt.description} + + )} +
+ + + {/* Content Preview */} +
+

+ {latestVersion?.content || prompt.content} +

+
+ + {/* Tags */} + {prompt.tags.length > 0 && ( +
+ {prompt.tags.slice(0, 3).map((tag) => ( + + {tag.name} + + ))} + {prompt.tags.length > 3 && ( + + +{prompt.tags.length - 3} + + )} +
+ )} + + {/* Author & Meta */} +
+
+ + + + {prompt.user.username?.[0]?.toUpperCase() || 'U'} + + + + {prompt.user.username || 'Anonymous'} + +
+ +
+ + {formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })} +
+
+ + {/* Actions */} +
+ + + +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 7f228ae..8b36f8d 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -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 ( +
+ {children} +
+ ) +} + +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 ( + {alt} + ) + } + + return ( + {alt + ) +} + +interface AvatarFallbackProps { + children: React.ReactNode + className?: string +} + +export function AvatarFallback({ children, className }: AvatarFallbackProps) { + return ( +
+ {children} +
+ ) +} + +// 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" diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 774529f..147985e 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -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 ( -
+
{children}
) diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 575db8b..a52343b 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -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 ( -
+
+ {children} +
+ ) +} + +interface CardHeaderProps { + children: React.ReactNode + className?: string +} + +export function CardHeader({ children, className }: CardHeaderProps) { + return ( +
+ {children} +
+ ) +} + +interface CardTitleProps { + children: React.ReactNode + className?: string +} + +export function CardTitle({ children, className }: CardTitleProps) { + return ( +

+ {children} +

+ ) +} + +interface CardDescriptionProps { + children: React.ReactNode + className?: string +} + +export function CardDescription({ children, className }: CardDescriptionProps) { + return ( +

+ {children} +

+ ) +} + +interface CardContentProps { + children: React.ReactNode + className?: string +} + +export function CardContent({ children, className }: CardContentProps) { + return ( +
+ {children} +
+ ) +} + +interface CardFooterProps { + children: React.ReactNode + className?: string +} + +export function CardFooter({ children, className }: CardFooterProps) { + return ( +
{children}
) diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..01b3eb8 --- /dev/null +++ b/src/components/ui/select.tsx @@ -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(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 ( + +
+ {children} +
+
+ ) +} + +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 ( + + ) +} + +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 ( + + {context.value || placeholder} + + ) +} + +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 ( +
+
+ {children} +
+
+ ) +} + +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 ( +
{ + context.onChange(value) + context.setOpen(false) + }} + > + + {isSelected && } + + {children} +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/user-avatar-dropdown.tsx b/src/components/ui/user-avatar-dropdown.tsx index 092e450..f009c79 100644 --- a/src/components/ui/user-avatar-dropdown.tsx +++ b/src/components/ui/user-avatar-dropdown.tsx @@ -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 */}
- -
-