add plaze
This commit is contained in:
parent
b052bbedf5
commit
c4545bfc84
62
create-prompt-stats-table.sql
Normal file
62
create-prompt-stats-table.sql
Normal 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
96
debug-copy-feature.md
Normal 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** 有标签和无标签的
|
||||
|
||||
如果按照以上步骤仍然有问题,请提供:
|
||||
- 浏览器控制台的错误信息
|
||||
- 网络请求的详细响应
|
||||
- 具体的错误通知内容
|
84
enable-stats-after-migration.md
Normal file
84
enable-stats-after-migration.md
Normal 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脚本创建表,就可以按上述步骤启用完整的统计功能。
|
@ -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.",
|
||||
|
@ -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": "网络错误,请检查您的网络连接。",
|
||||
|
@ -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
|
||||
|
46
src/app/api/plaza/[id]/view/route.ts
Normal file
46
src/app/api/plaza/[id]/view/route.ts
Normal 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
112
src/app/api/plaza/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
46
src/app/api/plaza/tags/route.ts
Normal file
46
src/app/api/plaza/tags/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
@ -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 }))
|
||||
}
|
||||
|
@ -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
31
src/app/plaza/page.tsx
Normal 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 />
|
||||
}
|
@ -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}
|
||||
|
@ -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>
|
||||
|
256
src/components/plaza/PlazaClient.tsx
Normal file
256
src/components/plaza/PlazaClient.tsx
Normal 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>
|
||||
)
|
||||
}
|
234
src/components/plaza/PlazaFilters.tsx
Normal file
234
src/components/plaza/PlazaFilters.tsx
Normal 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>
|
||||
)
|
||||
}
|
295
src/components/plaza/PromptCard.tsx
Normal file
295
src/components/plaza/PromptCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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"
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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(
|
||||
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}>
|
||||
<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>
|
||||
)
|
||||
|
149
src/components/ui/select.tsx
Normal file
149
src/components/ui/select.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user