diff --git a/.github/workflows/update-contributors.yml b/.github/workflows/update-contributors.yml index 64072958..8a8045b5 100644 --- a/.github/workflows/update-contributors.yml +++ b/.github/workflows/update-contributors.yml @@ -34,6 +34,26 @@ jobs: chmod +x scripts/generate-contributors.sh ./scripts/generate-contributors.sh + - name: Sync prompts.csv from API (removes unlisted prompts) + env: + PROMPTS_API_URL: "https://prompts.chat" + run: | + if [ -n "$PROMPTS_API_URL" ]; then + echo "Fetching prompts.csv from API..." + curl -s "${PROMPTS_API_URL}/prompts.csv" -o prompts.csv.new + if [ -s prompts.csv.new ]; then + mv prompts.csv.new prompts.csv + git add prompts.csv + git diff --cached --quiet || git commit -m "Update prompts.csv (sync from API)" + echo "prompts.csv updated from API" + else + echo "Warning: Empty response from API, keeping existing prompts.csv" + rm -f prompts.csv.new + fi + else + echo "PROMPTS_API_URL not set, skipping prompts.csv sync" + fi + - name: Push changes run: | git push origin main --force-with-lease diff --git a/messages/ar.json b/messages/ar.json index 2f0bedec..da84ddc3 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -103,6 +103,8 @@ "promptPublic": "عام", "feature": "تمييز", "featured": "مميز", + "unlist": "إلغاء الإدراج", + "relist": "إعادة الإدراج", "adminArea": "منطقة المسؤول", "types": { "text": "نص", diff --git a/messages/de.json b/messages/de.json index db9d2d5a..ed9d2107 100644 --- a/messages/de.json +++ b/messages/de.json @@ -103,6 +103,8 @@ "promptPublic": "Öffentlich", "feature": "Hervorheben", "featured": "Hervorgehoben", + "unlist": "Ausblenden", + "relist": "Wieder einblenden", "adminArea": "Admin-Bereich", "types": { "text": "Text", diff --git a/messages/en.json b/messages/en.json index 1954f48c..0f0b2767 100644 --- a/messages/en.json +++ b/messages/en.json @@ -103,6 +103,8 @@ "promptPublic": "Public", "feature": "Feature", "featured": "Featured", + "unlist": "Unlist", + "relist": "Relist", "adminArea": "Admin Area", "types": { "text": "Text", diff --git a/messages/es.json b/messages/es.json index 3ea6954b..bc791c59 100644 --- a/messages/es.json +++ b/messages/es.json @@ -101,6 +101,8 @@ "promptPublic": "Público", "feature": "Destacar", "featured": "Destacado", + "unlist": "Ocultar", + "relist": "Mostrar", "adminArea": "Área de Administrador", "noMorePrompts": "Has llegado al final", "loading": "Cargando...", diff --git a/messages/fr.json b/messages/fr.json index 125a9e92..9808e782 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -103,6 +103,8 @@ "promptPublic": "Public", "feature": "Mettre en avant", "featured": "En vedette", + "unlist": "Retirer", + "relist": "Remettre", "adminArea": "Zone Admin", "types": { "text": "Texte", diff --git a/messages/it.json b/messages/it.json index 34ab7565..f51c3cd8 100644 --- a/messages/it.json +++ b/messages/it.json @@ -103,6 +103,8 @@ "promptPublic": "Pubblico", "feature": "In evidenza", "featured": "In evidenza", + "unlist": "Nascondi", + "relist": "Mostra", "adminArea": "Area Admin", "types": { "text": "Testo", diff --git a/messages/ja.json b/messages/ja.json index 516b3b04..91f8dcf6 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -101,6 +101,8 @@ "promptPublic": "公開", "feature": "注目に設定", "featured": "注目", + "unlist": "非公開にする", + "relist": "公開に戻す", "adminArea": "管理者エリア", "noMorePrompts": "最後まで表示しました", "loading": "読み込み中...", diff --git a/messages/ko.json b/messages/ko.json index 2996cb7a..4a2c864d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -103,6 +103,8 @@ "promptPublic": "공개", "feature": "추천", "featured": "추천됨", + "unlist": "숨기기", + "relist": "다시 표시", "adminArea": "관리자 영역", "types": { "text": "텍스트", diff --git a/messages/pt.json b/messages/pt.json index b185002c..a6a1ed57 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -103,6 +103,8 @@ "promptPublic": "Público", "feature": "Destacar", "featured": "Destacado", + "unlist": "Ocultar", + "relist": "Mostrar", "adminArea": "Área Admin", "types": { "text": "Texto", diff --git a/messages/tr.json b/messages/tr.json index fcf5869f..9f0f3316 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -103,6 +103,8 @@ "promptPublic": "Herkese Açık", "feature": "Öne Çıkar", "featured": "Öne Çıkan", + "unlist": "Listeden Kaldır", + "relist": "Listeye Ekle", "adminArea": "Yönetici Alanı", "types": { "text": "Metin", diff --git a/messages/zh.json b/messages/zh.json index 562b892d..0cf8cab5 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -101,6 +101,8 @@ "promptPublic": "公开", "feature": "精选", "featured": "已精选", + "unlist": "取消列出", + "relist": "重新列出", "adminArea": "管理员区域", "noMorePrompts": "已到达末尾", "loading": "加载中...", diff --git a/package.json b/package.json index 582f3441..8ab14907 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "prisma generate && next build", + "build": "prisma generate && prisma migrate deploy && next build", "start": "next start", "lint": "eslint", "db:generate": "prisma generate", diff --git a/prisma/migrations/20251213203400_add_unlisted_field/migration.sql b/prisma/migrations/20251213203400_add_unlisted_field/migration.sql new file mode 100644 index 00000000..01bdf790 --- /dev/null +++ b/prisma/migrations/20251213203400_add_unlisted_field/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "prompts" ADD COLUMN IF NOT EXISTS "isUnlisted" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "prompts" ADD COLUMN IF NOT EXISTS "unlistedAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "prompts_isUnlisted_idx" ON "prompts"("isUnlisted"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3b7c53b1..331bdb73 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,3 @@ -// Prisma schema for prompts.chat -// https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client-js" } @@ -10,41 +7,30 @@ datasource db { url = env("DATABASE_URL") } -// ============================================ -// User & Authentication -// ============================================ - -enum UserRole { - ADMIN - USER -} - model User { - id String @id @default(cuid()) - email String @unique - username String @unique + id String @id @default(cuid()) + email String @unique + username String @unique name String? - password String? // For credentials auth + password String? avatar String? - role UserRole @default(USER) - locale String @default("en") - verified Boolean @default(false) + role UserRole @default(USER) + locale String @default("en") emailVerified DateTime? - githubUsername String? // Immutable GitHub username for contributor attribution - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - prompts Prompt[] @relation("PromptAuthor") - contributions Prompt[] @relation("PromptContributors") - promptVersions PromptVersion[] - changeRequests ChangeRequest[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + verified Boolean @default(false) + githubUsername String? accounts Account[] - sessions Session[] subscriptions CategorySubscription[] - votes PromptVote[] + changeRequests ChangeRequest[] pinnedPrompts PinnedPrompt[] reports PromptReport[] + promptVersions PromptVersion[] + votes PromptVote[] + prompts Prompt[] @relation("PromptAuthor") + sessions Session[] + contributions Prompt[] @relation("PromptContributors") @@map("users") } @@ -55,15 +41,14 @@ model Account { type String provider String providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text + refresh_token String? + access_token String? expires_at Int? token_type String? scope String? - id_token String? @db.Text + id_token String? session_state String? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) @@map("accounts") @@ -88,9 +73,199 @@ model VerificationToken { @@map("verification_tokens") } -// ============================================ -// Prompts & Content -// ============================================ +model Prompt { + id String @id @default(cuid()) + title String + description String? + content String + type PromptType @default(TEXT) + isPrivate Boolean @default(false) + mediaUrl String? + viewCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + authorId String + categoryId String? + embedding Json? + requiredMediaCount Int? + requiredMediaType RequiredMediaType? + requiresMediaUpload Boolean @default(false) + structuredFormat StructuredFormat? + featuredAt DateTime? + isFeatured Boolean @default(false) + isUnlisted Boolean @default(false) + unlistedAt DateTime? + deletedAt DateTime? + changeRequests ChangeRequest[] + pinnedBy PinnedPrompt[] + reports PromptReport[] + tags PromptTag[] + versions PromptVersion[] + votes PromptVote[] + author User @relation("PromptAuthor", fields: [authorId], references: [id], onDelete: Cascade) + category Category? @relation(fields: [categoryId], references: [id]) + contributors User[] @relation("PromptContributors") + + @@index([authorId]) + @@index([categoryId]) + @@index([type]) + @@index([isPrivate]) + @@index([isFeatured]) + @@index([isUnlisted]) + @@map("prompts") +} + +model PromptVersion { + id String @id @default(cuid()) + version Int + content String + changeNote String? + createdAt DateTime @default(now()) + promptId String + createdBy String + author User @relation(fields: [createdBy], references: [id], onDelete: Cascade) + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + + @@unique([promptId, version]) + @@index([promptId]) + @@map("prompt_versions") +} + +model ChangeRequest { + id String @id @default(cuid()) + proposedContent String + proposedTitle String? + reason String? + status ChangeRequestStatus @default(PENDING) + reviewNote String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + promptId String + authorId String + originalContent String + originalTitle String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + + @@index([promptId]) + @@index([authorId]) + @@index([status]) + @@map("change_requests") +} + +model Category { + id String @id @default(cuid()) + name String + slug String @unique + description String? + icon String? + order Int @default(0) + parentId String? + parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id]) + children Category[] @relation("CategoryHierarchy") + subscribers CategorySubscription[] + prompts Prompt[] + + @@index([parentId]) + @@map("categories") +} + +model Tag { + id String @id @default(cuid()) + name String @unique + slug String @unique + color String @default("#6366f1") + prompts PromptTag[] + + @@map("tags") +} + +model PromptTag { + promptId String + tagId String + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([promptId, tagId]) + @@map("prompt_tags") +} + +model CategorySubscription { + userId String + categoryId String + createdAt DateTime @default(now()) + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, categoryId]) + @@index([userId]) + @@index([categoryId]) + @@map("category_subscriptions") +} + +model PromptVote { + userId String + promptId String + createdAt DateTime @default(now()) + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, promptId]) + @@index([userId]) + @@index([promptId]) + @@map("prompt_votes") +} + +model PinnedPrompt { + userId String + promptId String + order Int @default(0) + createdAt DateTime @default(now()) + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, promptId]) + @@index([userId]) + @@map("pinned_prompts") +} + +model PromptReport { + id String @id @default(cuid()) + reason ReportReason + details String? + status ReportStatus @default(PENDING) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + promptId String + reporterId String + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) + + @@index([promptId]) + @@index([reporterId]) + @@index([status]) + @@map("prompt_reports") +} + +model WebhookConfig { + id String @id @default(cuid()) + name String + url String + method String @default("POST") + headers Json? + payload String + events WebhookEvent[] + isEnabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("webhook_configs") +} + +enum UserRole { + ADMIN + USER +} enum PromptType { TEXT @@ -117,191 +292,6 @@ enum RequiredMediaType { DOCUMENT } -model Prompt { - id String @id @default(cuid()) - title String - description String? - content String @db.Text - type PromptType @default(TEXT) - structuredFormat StructuredFormat? // For STRUCTURED type: JSON or YAML - isPrivate Boolean @default(false) - mediaUrl String? // For non-text prompts - - // AI Search embedding (stored as JSON array of floats) - embedding Json? // OpenAI embedding vector for semantic search - - // Media requirements (user needs to upload media to use this prompt) - requiresMediaUpload Boolean @default(false) - requiredMediaType RequiredMediaType? - requiredMediaCount Int? - - // Metadata - viewCount Int @default(0) - isFeatured Boolean @default(false) - featuredAt DateTime? - deletedAt DateTime? // Soft delete - CC0 prompts cannot be permanently deleted - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - authorId String - author User @relation("PromptAuthor", fields: [authorId], references: [id], onDelete: Cascade) - categoryId String? - category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) - - versions PromptVersion[] - changeRequests ChangeRequest[] - tags PromptTag[] - votes PromptVote[] - contributors User[] @relation("PromptContributors") - pinnedBy PinnedPrompt[] - reports PromptReport[] - - @@index([authorId]) - @@index([categoryId]) - @@index([type]) - @@index([isPrivate]) - @@index([isFeatured]) - @@map("prompts") -} - -model PromptVersion { - id String @id @default(cuid()) - version Int - content String @db.Text - changeNote String? - createdAt DateTime @default(now()) - - // Relations - promptId String - prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) - createdBy String - author User @relation(fields: [createdBy], references: [id], onDelete: Cascade) - - @@unique([promptId, version]) - @@index([promptId]) - @@map("prompt_versions") -} - -model ChangeRequest { - id String @id @default(cuid()) - originalContent String @db.Text - originalTitle String - proposedContent String @db.Text - proposedTitle String? - reason String? - status ChangeRequestStatus @default(PENDING) - reviewNote String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - promptId String - prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) - authorId String - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - - @@index([promptId]) - @@index([authorId]) - @@index([status]) - @@map("change_requests") -} - -// ============================================ -// Categories & Tags -// ============================================ - -model Category { - id String @id @default(cuid()) - name String - slug String @unique - description String? - icon String? - order Int @default(0) - - // Self-referential for hierarchy - parentId String? - parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: SetNull) - children Category[] @relation("CategoryHierarchy") - - prompts Prompt[] - subscribers CategorySubscription[] - - @@index([parentId]) - @@map("categories") -} - -model Tag { - id String @id @default(cuid()) - name String @unique - slug String @unique - color String @default("#6366f1") - - prompts PromptTag[] - - @@map("tags") -} - -model PromptTag { - promptId String - tagId String - prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) - tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) - - @@id([promptId, tagId]) - @@map("prompt_tags") -} - -// ============================================ -// Subscriptions -// ============================================ - -model CategorySubscription { - userId String - categoryId String - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) - - @@id([userId, categoryId]) - @@index([userId]) - @@index([categoryId]) - @@map("category_subscriptions") -} - -model PromptVote { - userId String - promptId String - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) - - @@id([userId, promptId]) - @@index([userId]) - @@index([promptId]) - @@map("prompt_votes") -} - -model PinnedPrompt { - userId String - promptId String - order Int @default(0) - createdAt DateTime @default(now()) - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) - - @@id([userId, promptId]) - @@index([userId]) - @@map("pinned_prompts") -} - -// ============================================ -// Prompt Reports -// ============================================ - enum ReportReason { SPAM INAPPROPRIATE @@ -316,47 +306,8 @@ enum ReportStatus { DISMISSED } -model PromptReport { - id String @id @default(cuid()) - reason ReportReason - details String? @db.Text - status ReportStatus @default(PENDING) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - promptId String - prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) - reporterId String - reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade) - - @@index([promptId]) - @@index([reporterId]) - @@index([status]) - @@map("prompt_reports") -} - -// ============================================ -// Webhook Integration -// ============================================ - enum WebhookEvent { PROMPT_CREATED PROMPT_UPDATED PROMPT_DELETED } - -model WebhookConfig { - id String @id @default(cuid()) - name String // e.g. "Slack Notifications" - url String // Webhook URL - method String @default("POST") // HTTP method - headers Json? // Custom headers as JSON object - payload String @db.Text // JSON template with placeholders - events WebhookEvent[] // Which events trigger this webhook - isEnabled Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@map("webhook_configs") -} diff --git a/src/app/api/prompts/[id]/unlist/route.ts b/src/app/api/prompts/[id]/unlist/route.ts new file mode 100644 index 00000000..9edc4b50 --- /dev/null +++ b/src/app/api/prompts/[id]/unlist/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; + +// Toggle unlist status (admin only) +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const session = await auth(); + + if (!session?.user) { + return NextResponse.json( + { error: "unauthorized", message: "You must be logged in" }, + { status: 401 } + ); + } + + // Only admins can unlist prompts + if (session.user.role !== "ADMIN") { + return NextResponse.json( + { error: "forbidden", message: "Only admins can unlist prompts" }, + { status: 403 } + ); + } + + // Check if prompt exists + const existing = await db.prompt.findUnique({ + where: { id }, + select: { id: true, isUnlisted: true }, + }); + + if (!existing) { + return NextResponse.json( + { error: "not_found", message: "Prompt not found" }, + { status: 404 } + ); + } + + // Toggle unlist status + const newUnlistedStatus = !existing.isUnlisted; + + await db.prompt.update({ + where: { id }, + data: { + isUnlisted: newUnlistedStatus, + unlistedAt: newUnlistedStatus ? new Date() : null, + }, + }); + + return NextResponse.json({ + success: true, + isUnlisted: newUnlistedStatus, + message: newUnlistedStatus ? "Prompt unlisted" : "Prompt relisted" + }); + } catch (error) { + console.error("Unlist prompt error:", error); + return NextResponse.json( + { error: "server_error", message: "Something went wrong" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/prompts/route.ts b/src/app/api/prompts/route.ts index 748f5f6e..e630fc95 100644 --- a/src/app/api/prompts/route.ts +++ b/src/app/api/prompts/route.ts @@ -138,6 +138,7 @@ export async function GET(request: Request) { const where: Record = { isPrivate: false, + isUnlisted: false, // Exclude unlisted prompts from public API deletedAt: null, // Exclude soft-deleted prompts }; diff --git a/src/app/prompts.csv/route.ts b/src/app/prompts.csv/route.ts index 8b3c734e..d3919a64 100644 --- a/src/app/prompts.csv/route.ts +++ b/src/app/prompts.csv/route.ts @@ -28,6 +28,7 @@ export async function GET() { const prompts = await db.prompt.findMany({ where: { isPrivate: false, + isUnlisted: false, // Exclude unlisted prompts from CSV export deletedAt: null, }, select: { diff --git a/src/app/prompts/[id]/page.tsx b/src/app/prompts/[id]/page.tsx index e3878ec7..41f6a407 100644 --- a/src/app/prompts/[id]/page.tsx +++ b/src/app/prompts/[id]/page.tsx @@ -19,6 +19,7 @@ import { DeleteVersionButton } from "@/components/prompts/delete-version-button" import { VersionCompareModal } from "@/components/prompts/version-compare-modal"; import { VersionCompareButton } from "@/components/prompts/version-compare-button"; import { FeaturePromptButton } from "@/components/prompts/feature-prompt-button"; +import { UnlistPromptButton } from "@/components/prompts/unlist-prompt-button"; import { MediaPreview } from "@/components/prompts/media-preview"; import { ReportPromptDialog } from "@/components/prompts/report-prompt-dialog"; @@ -117,6 +118,11 @@ export default async function PromptPage({ params }: PromptPageProps) { notFound(); } + // Check if user can view unlisted prompt (only owner and admins can see) + if (prompt.isUnlisted && prompt.authorId !== session?.user?.id && session?.user?.role !== "ADMIN") { + notFound(); + } + const isOwner = session?.user?.id === prompt.authorId; const isAdmin = session?.user?.role === "ADMIN"; const canEdit = isOwner || isAdmin; @@ -546,6 +552,10 @@ export default async function PromptPage({ params }: PromptPageProps) { promptId={prompt.id} isFeatured={prompt.isFeatured} /> + + ); +}