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": {
|
"navigation": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"studio": "Studio",
|
"studio": "Studio",
|
||||||
|
"plaza": "Plaza",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"signIn": "Sign In",
|
"signIn": "Sign In",
|
||||||
@ -244,6 +245,33 @@
|
|||||||
"loadingDashboard": "Loading dashboard statistics...",
|
"loadingDashboard": "Loading dashboard statistics...",
|
||||||
"loadingPrompts": "Loading prompts for review..."
|
"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": {
|
"errors": {
|
||||||
"generic": "Something went wrong. Please try again.",
|
"generic": "Something went wrong. Please try again.",
|
||||||
"network": "Network error. Please check your connection.",
|
"network": "Network error. Please check your connection.",
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "首页",
|
"home": "首页",
|
||||||
"studio": "工作室",
|
"studio": "工作室",
|
||||||
|
"plaza": "广场",
|
||||||
"profile": "个人资料",
|
"profile": "个人资料",
|
||||||
"admin": "管理员后台",
|
"admin": "管理员后台",
|
||||||
"signIn": "登录",
|
"signIn": "登录",
|
||||||
@ -244,6 +245,33 @@
|
|||||||
"loadingDashboard": "加载统计数据中...",
|
"loadingDashboard": "加载统计数据中...",
|
||||||
"loadingPrompts": "加载审核提示词中..."
|
"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": {
|
"errors": {
|
||||||
"generic": "出现错误,请重试。",
|
"generic": "出现错误,请重试。",
|
||||||
"network": "网络错误,请检查您的网络连接。",
|
"network": "网络错误,请检查您的网络连接。",
|
||||||
|
@ -53,6 +53,7 @@ model Prompt {
|
|||||||
album PromptAlbum? @relation(fields: [albumId], references: [id])
|
album PromptAlbum? @relation(fields: [albumId], references: [id])
|
||||||
albumId String?
|
albumId String?
|
||||||
tests PromptTestRun[]
|
tests PromptTestRun[]
|
||||||
|
stats PromptStats?
|
||||||
|
|
||||||
@@map("prompts")
|
@@map("prompts")
|
||||||
}
|
}
|
||||||
@ -107,6 +108,21 @@ model PromptTestRun {
|
|||||||
@@map("prompt_test_runs")
|
@@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 {
|
model Credit {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { name, description, content, tags, userId } = body
|
const { name, description, content, tags, userId, permissions } = body
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
|
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
|
||||||
@ -142,6 +142,7 @@ export async function POST(request: NextRequest) {
|
|||||||
description,
|
description,
|
||||||
content,
|
content,
|
||||||
userId,
|
userId,
|
||||||
|
permissions: permissions || 'private',
|
||||||
tags: {
|
tags: {
|
||||||
connect: tagObjects.map(tag => ({ id: tag.id }))
|
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 */
|
/* Print styles */
|
||||||
@media print {
|
@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 { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
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 { LoadingSpinner, LoadingOverlay, GradientLoading } from '@/components/ui/loading-spinner'
|
||||||
import { AvatarSkeleton, FormFieldSkeleton, TextAreaSkeleton } from '@/components/ui/skeleton'
|
import { AvatarSkeleton, FormFieldSkeleton, TextAreaSkeleton } from '@/components/ui/skeleton'
|
||||||
import { Save, Eye, EyeOff, CreditCard, Crown, Star } from 'lucide-react'
|
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">
|
<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>
|
<h2 className="text-xl font-semibold text-foreground mb-4">{t('profilePicture')}</h2>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<Avatar
|
<LegacyAvatar
|
||||||
src={user?.user_metadata?.avatar_url || profile?.avatar}
|
src={user?.user_metadata?.avatar_url || profile?.avatar}
|
||||||
alt="Profile Avatar"
|
alt="Profile Avatar"
|
||||||
size={96}
|
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">
|
<Link href="/studio" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
|
||||||
{t('studio')}
|
{t('studio')}
|
||||||
</Link>
|
</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">
|
<a href="#pricing" className="text-muted-foreground hover:text-foreground px-3 py-2 text-sm font-medium transition-colors">
|
||||||
Pricing
|
Pricing
|
||||||
</a>
|
</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">
|
<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')}
|
{t('studio')}
|
||||||
</Link>
|
</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">
|
<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
|
Pricing
|
||||||
</a>
|
</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 Image from 'next/image'
|
||||||
import { User } from 'lucide-react'
|
import { User } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface AvatarProps {
|
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
|
src?: string
|
||||||
alt?: string
|
alt?: string
|
||||||
size?: number
|
size?: number
|
||||||
className?: string
|
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
|
// Convert pixel size to Tailwind classes
|
||||||
const getSizeClasses = (size: number) => {
|
const getSizeClasses = (size: number) => {
|
||||||
if (size <= 32) return "w-8 h-8"
|
if (size <= 32) return "w-8 h-8"
|
||||||
|
@ -4,9 +4,10 @@ interface BadgeProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
|
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||||
className?: string
|
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 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 = {
|
const variants = {
|
||||||
@ -17,7 +18,7 @@ export function Badge({ children, variant = 'default', className }: BadgeProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(baseStyles, variants[variant], className)}>
|
<div className={cn(baseStyles, variants[variant], className)} style={style}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -3,14 +3,83 @@ import { cn } from '@/lib/utils'
|
|||||||
interface CardProps {
|
interface CardProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Card({ children, className }: CardProps) {
|
export function Card({ children, className, ...props }: CardProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||||
className
|
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}
|
{children}
|
||||||
</div>
|
</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 { useTranslations } from 'next-intl'
|
||||||
import { User as SupabaseUser } from '@supabase/supabase-js'
|
import { User as SupabaseUser } from '@supabase/supabase-js'
|
||||||
import { Button } from '@/components/ui/button'
|
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 { ChevronDown, User, LogOut, Settings } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUser } from '@/hooks/useUser'
|
import { useUser } from '@/hooks/useUser'
|
||||||
@ -40,7 +40,7 @@ export function MobileUserMenu({
|
|||||||
{/* User info section */}
|
{/* User info section */}
|
||||||
<div className="px-3 py-2 border-b border-border">
|
<div className="px-3 py-2 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar
|
<LegacyAvatar
|
||||||
src={userAvatar}
|
src={userAvatar}
|
||||||
alt={userName}
|
alt={userName}
|
||||||
size={48}
|
size={48}
|
||||||
@ -146,7 +146,7 @@ export function UserAvatarDropdown({
|
|||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
>
|
>
|
||||||
<Avatar
|
<LegacyAvatar
|
||||||
src={userAvatar}
|
src={userAvatar}
|
||||||
alt={userName}
|
alt={userName}
|
||||||
size={32}
|
size={32}
|
||||||
@ -167,7 +167,7 @@ export function UserAvatarDropdown({
|
|||||||
{/* User info section */}
|
{/* User info section */}
|
||||||
<div className="px-3 py-2 border-b border-border">
|
<div className="px-3 py-2 border-b border-border">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar
|
<LegacyAvatar
|
||||||
src={userAvatar}
|
src={userAvatar}
|
||||||
alt={userName}
|
alt={userName}
|
||||||
size={40}
|
size={40}
|
||||||
|
Loading…
Reference in New Issue
Block a user