diff --git a/messages/ar.json b/messages/ar.json index 88d89e6d..542c3d1f 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -38,6 +38,7 @@ }, "nav": { "home": "الرئيسية", + "collection": "مجموعتي", "feed": "الخلاصة", "promptmasters": "Promptmasters", "prompts": "الأوامر", @@ -853,6 +854,18 @@ "subscribeToCategories": "اشترك في التصنيفات لرؤية الأوامر هنا", "viewAllCategories": "عرض جميع التصنيفات" }, + "collection": { + "title": "مجموعتي", + "description": "الأوامر التي حفظتها لوقت لاحق", + "browsePrompts": "تصفح الأوامر", + "discover": "اكتشف", + "emptyTitle": "مجموعتك فارغة", + "emptyDescription": "احفظ الأوامر في مجموعتك للوصول إليها بسرعة", + "addToCollection": "أضف للمجموعة", + "inCollection": "في المجموعة", + "added": "تمت الإضافة للمجموعة", + "removed": "تمت الإزالة من المجموعة" + }, "discovery": { "featuredPrompts": "الأوامر المميزة", "todaysMostUpvoted": "الأكثر تصويتاً اليوم", diff --git a/messages/az.json b/messages/az.json index 5c4c4193..c59c6886 100644 --- a/messages/az.json +++ b/messages/az.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Ana səhifə", + "collection": "Kolleksiyam", "feed": "Lent", "promptmasters": "Promptmasterlər", "prompts": "Promptlar", @@ -853,6 +854,18 @@ "subscribeToCategories": "Burada promptları görmək üçün kateqoriyalara abunə olun", "viewAllCategories": "Bütün Kateqoriyalara Bax" }, + "collection": { + "title": "Kolleksiyam", + "description": "Sonra üçün saxladığınız promptlar", + "browsePrompts": "Promptlara Bax", + "discover": "Kəşf et", + "emptyTitle": "Kolleksiyanız boşdur", + "emptyDescription": "Tez daxil olmaq üçün promptları kolleksiyanıza saxlayın", + "addToCollection": "Kolleksiyaya Əlavə Et", + "inCollection": "Kolleksiyada", + "added": "Kolleksiyaya əlavə edildi", + "removed": "Kolleksiyadan silindi" + }, "discovery": { "featuredPrompts": "Seçilmiş Promptlar", "todaysMostUpvoted": "Bu Günün Ən Çox Bəyənilənləri", diff --git a/messages/de.json b/messages/de.json index d4e4640c..a9954661 100644 --- a/messages/de.json +++ b/messages/de.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Startseite", + "collection": "Meine Sammlung", "feed": "Feed", "promptmasters": "Promptmasters", "prompts": "Prompts", @@ -853,6 +854,18 @@ "subscribeToCategories": "Abonnieren Sie Kategorien, um hier Prompts zu sehen", "viewAllCategories": "Alle Kategorien anzeigen" }, + "collection": { + "title": "Meine Sammlung", + "description": "Prompts, die Sie für später gespeichert haben", + "browsePrompts": "Prompts durchsuchen", + "discover": "Entdecken", + "emptyTitle": "Ihre Sammlung ist leer", + "emptyDescription": "Speichern Sie Prompts in Ihrer Sammlung für schnellen Zugriff", + "addToCollection": "Zur Sammlung hinzufügen", + "inCollection": "In Sammlung", + "added": "Zur Sammlung hinzugefügt", + "removed": "Aus Sammlung entfernt" + }, "discovery": { "featuredPrompts": "Hervorgehobene Prompts", "todaysMostUpvoted": "Heute am meisten bewertet", diff --git a/messages/en.json b/messages/en.json index 1f61db87..28d5c35b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Home", + "collection": "My Collection", "feed": "Feed", "promptmasters": "Promptmasters", "prompts": "Prompts", @@ -853,6 +854,18 @@ "subscribeToCategories": "Subscribe to categories to see prompts here", "viewAllCategories": "View All Categories" }, + "collection": { + "title": "My Collection", + "description": "Prompts you've saved for later", + "browsePrompts": "Browse Prompts", + "discover": "Discover", + "emptyTitle": "Your collection is empty", + "emptyDescription": "Save prompts to your collection to access them quickly", + "addToCollection": "Add to Collection", + "inCollection": "In Collection", + "added": "Added to collection", + "removed": "Removed from collection" + }, "discovery": { "featuredPrompts": "Featured Prompts", "todaysMostUpvoted": "Today's Most Upvoted", diff --git a/messages/es.json b/messages/es.json index 09a30a73..fbcc741b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Inicio", + "collection": "Mi Colección", "feed": "Feed", "promptmasters": "Promptmasters", "prompts": "Prompts", @@ -853,6 +854,18 @@ "subscribeToCategories": "Suscríbete a categorías para ver prompts aquí", "viewAllCategories": "Ver Todas las Categorías" }, + "collection": { + "title": "Mi Colección", + "description": "Prompts que has guardado para después", + "browsePrompts": "Ver Prompts", + "discover": "Descubrir", + "emptyTitle": "Tu colección está vacía", + "emptyDescription": "Guarda prompts en tu colección para acceder rápidamente", + "addToCollection": "Añadir a Colección", + "inCollection": "En Colección", + "added": "Añadido a la colección", + "removed": "Eliminado de la colección" + }, "discovery": { "featuredPrompts": "Prompts Destacados", "todaysMostUpvoted": "Más Votados Hoy", diff --git a/messages/fr.json b/messages/fr.json index b098e9b9..569470e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Accueil", + "collection": "Ma Collection", "feed": "Flux", "promptmasters": "Promptmasters", "prompts": "Prompts", @@ -853,6 +854,18 @@ "subscribeToCategories": "Abonnez-vous à des catégories pour voir des prompts ici", "viewAllCategories": "Voir Toutes les Catégories" }, + "collection": { + "title": "Ma Collection", + "description": "Prompts que vous avez sauvegardés pour plus tard", + "browsePrompts": "Parcourir les Prompts", + "discover": "Découvrir", + "emptyTitle": "Votre collection est vide", + "emptyDescription": "Sauvegardez des prompts dans votre collection pour y accéder rapidement", + "addToCollection": "Ajouter à la Collection", + "inCollection": "Dans la Collection", + "added": "Ajouté à la collection", + "removed": "Retiré de la collection" + }, "discovery": { "featuredPrompts": "Prompts en Vedette", "todaysMostUpvoted": "Les Plus Votés Aujourd'hui", diff --git a/messages/it.json b/messages/it.json index ac65ccbe..b67aa1c1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Home", + "collection": "La Mia Collezione", "feed": "Feed", "promptmasters": "Promptmaster", "prompts": "Prompt", @@ -853,6 +854,18 @@ "subscribeToCategories": "Iscriviti alle categorie per vedere i prompt qui", "viewAllCategories": "Vedi Tutte le Categorie" }, + "collection": { + "title": "La Mia Collezione", + "description": "Prompt che hai salvato per dopo", + "browsePrompts": "Sfoglia Prompt", + "discover": "Scopri", + "emptyTitle": "La tua collezione è vuota", + "emptyDescription": "Salva i prompt nella tua collezione per accedervi rapidamente", + "addToCollection": "Aggiungi alla Collezione", + "inCollection": "Nella Collezione", + "added": "Aggiunto alla collezione", + "removed": "Rimosso dalla collezione" + }, "discovery": { "featuredPrompts": "Prompt in Evidenza", "todaysMostUpvoted": "I Più Votati di Oggi", diff --git a/messages/ja.json b/messages/ja.json index f3237b8b..66231ee0 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -38,6 +38,7 @@ }, "nav": { "home": "ホーム", + "collection": "マイコレクション", "feed": "フィード", "promptmasters": "Promptmasters", "prompts": "プロンプト", diff --git a/messages/ko.json b/messages/ko.json index 55c97874..f7d51372 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -38,6 +38,7 @@ }, "nav": { "home": "홈", + "collection": "내 컬렉션", "feed": "피드", "promptmasters": "프롬프트마스터", "prompts": "프롬프트", diff --git a/messages/pt.json b/messages/pt.json index b1a8da84..475878c5 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Início", + "collection": "Minha Coleção", "feed": "Feed", "promptmasters": "Promptmasters", "prompts": "Prompts", @@ -853,6 +854,18 @@ "subscribeToCategories": "Inscreva-se em categorias para ver prompts aqui", "viewAllCategories": "Ver Todas as Categorias" }, + "collection": { + "title": "Minha Coleção", + "description": "Prompts que você salvou para depois", + "browsePrompts": "Ver Prompts", + "discover": "Descobrir", + "emptyTitle": "Sua coleção está vazia", + "emptyDescription": "Salve prompts na sua coleção para acessá-los rapidamente", + "addToCollection": "Adicionar à Coleção", + "inCollection": "Na Coleção", + "added": "Adicionado à coleção", + "removed": "Removido da coleção" + }, "discovery": { "featuredPrompts": "Prompts em Destaque", "todaysMostUpvoted": "Mais Votados de Hoje", diff --git a/messages/ru.json b/messages/ru.json index 1e6f93ac..d5bf580f 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Главная", + "collection": "Моя Коллекция", "feed": "Лента", "promptmasters": "Мастера промптов", "prompts": "Промпты", diff --git a/messages/tr.json b/messages/tr.json index f3f31d95..e038466a 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -38,6 +38,7 @@ }, "nav": { "home": "Ana Sayfa", + "collection": "Koleksiyonum", "feed": "Akış", "promptmasters": "Promptmasters", "prompts": "Promptlar", @@ -853,6 +854,18 @@ "subscribeToCategories": "Burada promptları görmek için kategorilere abone olun", "viewAllCategories": "Tüm Kategorileri Gör" }, + "collection": { + "title": "Koleksiyonum", + "description": "Sonra için kaydettiğiniz promptlar", + "browsePrompts": "Promptlara Göz At", + "discover": "Keşfet", + "emptyTitle": "Koleksiyonunuz boş", + "emptyDescription": "Hızlı erişim için promptları koleksiyonunuza kaydedin", + "addToCollection": "Koleksiyona Ekle", + "inCollection": "Koleksiyonda", + "added": "Koleksiyona eklendi", + "removed": "Koleksiyondan kaldırıldı" + }, "discovery": { "featuredPrompts": "Öne Çıkan Promptlar", "todaysMostUpvoted": "Bugün En Çok Oy Alanlar", diff --git a/messages/zh.json b/messages/zh.json index 5ce2cacb..737c66da 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -38,6 +38,7 @@ }, "nav": { "home": "首页", + "collection": "我的收藏", "feed": "动态", "promptmasters": "Promptmasters", "prompts": "提示词", diff --git a/prisma/migrations/20260104210000_add_collections/migration.sql b/prisma/migrations/20260104210000_add_collections/migration.sql new file mode 100644 index 00000000..d81fa5c6 --- /dev/null +++ b/prisma/migrations/20260104210000_add_collections/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "collections" ( + "userId" TEXT NOT NULL, + "promptId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "collections_pkey" PRIMARY KEY ("userId","promptId") +); + +-- CreateIndex +CREATE INDEX "collections_userId_idx" ON "collections"("userId"); + +-- CreateIndex +CREATE INDEX "collections_promptId_idx" ON "collections"("promptId"); + +-- AddForeignKey +ALTER TABLE "collections" ADD CONSTRAINT "collections_promptId_fkey" FOREIGN KEY ("promptId") REFERENCES "prompts"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "collections" ADD CONSTRAINT "collections_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a463d831..ed77c0f4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,7 @@ model User { commentVotes CommentVote[] notifications Notification[] @relation("NotificationRecipient") notificationsActed Notification[] @relation("NotificationActor") + collections Collection[] @@map("users") } @@ -122,6 +123,7 @@ model Prompt { contributors User[] @relation("PromptContributors") outgoingConnections PromptConnection[] @relation("ConnectionSource") incomingConnections PromptConnection[] @relation("ConnectionTarget") + collectedBy Collection[] @@index([authorId]) @@index([categoryId]) @@ -248,6 +250,19 @@ model PinnedPrompt { @@map("pinned_prompts") } +model Collection { + 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("collections") +} + model PromptReport { id String @id @default(cuid()) reason ReportReason diff --git a/src/app/api/collection/route.ts b/src/app/api/collection/route.ts new file mode 100644 index 00000000..e7216c6b --- /dev/null +++ b/src/app/api/collection/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { z } from "zod"; + +const addToCollectionSchema = z.object({ + promptId: z.string().min(1), +}); + +export async function GET() { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const collections = await db.collection.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + include: { + prompt: { + include: { + author: { + select: { + id: true, + name: true, + username: true, + avatar: true, + verified: true, + }, + }, + category: { + include: { + parent: { + select: { id: true, name: true, slug: true }, + }, + }, + }, + tags: { + include: { + tag: true, + }, + }, + _count: { + select: { votes: true, contributors: true }, + }, + }, + }, + }, + }); + + return NextResponse.json({ collections }); +} + +export async function POST(req: NextRequest) { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const body = await req.json(); + const { promptId } = addToCollectionSchema.parse(body); + + const existingCollection = await db.collection.findUnique({ + where: { + userId_promptId: { + userId: session.user.id, + promptId, + }, + }, + }); + + if (existingCollection) { + return NextResponse.json({ error: "Already in collection" }, { status: 400 }); + } + + const prompt = await db.prompt.findUnique({ + where: { id: promptId }, + select: { id: true, isPrivate: true, authorId: true }, + }); + + if (!prompt) { + return NextResponse.json({ error: "Prompt not found" }, { status: 404 }); + } + + if (prompt.isPrivate && prompt.authorId !== session.user.id) { + return NextResponse.json({ error: "Cannot add private prompt" }, { status: 403 }); + } + + const collection = await db.collection.create({ + data: { + userId: session.user.id, + promptId, + }, + }); + + return NextResponse.json({ collection, added: true }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + console.error("Failed to add to collection:", error); + return NextResponse.json({ error: "Failed to add to collection" }, { status: 500 }); + } +} + +export async function DELETE(req: NextRequest) { + const session = await auth(); + + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { searchParams } = new URL(req.url); + const promptId = searchParams.get("promptId"); + + if (!promptId) { + return NextResponse.json({ error: "promptId required" }, { status: 400 }); + } + + await db.collection.delete({ + where: { + userId_promptId: { + userId: session.user.id, + promptId, + }, + }, + }); + + return NextResponse.json({ removed: true }); + } catch (error) { + console.error("Failed to remove from collection:", error); + return NextResponse.json({ error: "Failed to remove from collection" }, { status: 500 }); + } +} diff --git a/src/app/collection/loading.tsx b/src/app/collection/loading.tsx new file mode 100644 index 00000000..c50ae23c --- /dev/null +++ b/src/app/collection/loading.tsx @@ -0,0 +1,28 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export default function CollectionLoading() { + return ( +
+ {/* Header */} +
+ + +
+ + {/* Collection Items */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+ + +
+ + + +
+ ))} +
+
+ ); +} diff --git a/src/app/collection/page.tsx b/src/app/collection/page.tsx new file mode 100644 index 00000000..64c12638 --- /dev/null +++ b/src/app/collection/page.tsx @@ -0,0 +1,102 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { ArrowRight, Bookmark, Sparkles } from "lucide-react"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { Button } from "@/components/ui/button"; +import { PromptList } from "@/components/prompts/prompt-list"; + +export default async function CollectionPage() { + const t = await getTranslations("collection"); + const session = await auth(); + + if (!session?.user) { + redirect("/login"); + } + + const collectionsRaw = await db.collection.findMany({ + where: { userId: session.user.id }, + orderBy: { createdAt: "desc" }, + include: { + prompt: { + include: { + author: { + select: { + id: true, + name: true, + username: true, + avatar: true, + verified: true, + }, + }, + category: { + include: { + parent: { + select: { id: true, name: true, slug: true }, + }, + }, + }, + tags: { + include: { + tag: true, + }, + }, + _count: { + select: { votes: true, contributors: true }, + }, + }, + }, + }, + }); + + const prompts = collectionsRaw + .filter((c) => c.prompt && !c.prompt.deletedAt) + .map((c) => ({ + ...c.prompt, + voteCount: c.prompt._count?.votes ?? 0, + contributorCount: c.prompt._count?.contributors ?? 0, + })); + + return ( +
+
+
+

{t("title")}

+

+ {t("description")} +

+
+
+ + +
+
+ + {prompts.length > 0 ? ( + + ) : ( +
+ +

{t("emptyTitle")}

+

+ {t("emptyDescription")} +

+ +
+ )} +
+ ); +} diff --git a/src/app/prompts/[id]/page.tsx b/src/app/prompts/[id]/page.tsx index 6732d937..a2a469a2 100644 --- a/src/app/prompts/[id]/page.tsx +++ b/src/app/prompts/[id]/page.tsx @@ -27,6 +27,7 @@ import { RestorePromptButton } from "@/components/prompts/restore-prompt-button" import { CommentSection } from "@/components/comments"; import { PromptFlowSection } from "@/components/prompts/prompt-flow-section"; import { RelatedPrompts } from "@/components/prompts/related-prompts"; +import { AddToCollectionButton } from "@/components/prompts/add-to-collection-button"; import { getConfig } from "@/lib/config"; import { StructuredData } from "@/components/seo/structured-data"; @@ -146,6 +147,18 @@ export default async function PromptPage({ params }: PromptPageProps) { }) : null; + // Check if user has this prompt in their collection + const userCollection = session?.user + ? await db.collection.findUnique({ + where: { + userId_promptId: { + userId: session.user.id, + promptId: id, + }, + }, + }) + : null; + // Fetch related prompts (via PromptConnection with label "related") const relatedConnections = await db.promptConnection.findMany({ where: { @@ -208,6 +221,7 @@ export default async function PromptPage({ params }: PromptPageProps) { const canEdit = isOwner || isAdmin; const voteCount = prompt._count?.votes ?? 0; const hasVoted = !!userVote; + const inCollection = !!userCollection; // Fetch change requests for this prompt const changeRequests = await db.changeRequest.findMany({ @@ -327,23 +341,6 @@ export default async function PromptPage({ params }: PromptPageProps) { {t("promptPrivate")} )} -
- {isOwner && ( - <> - - - - )} -
@@ -445,17 +442,32 @@ export default async function PromptPage({ params }: PromptPageProps) { {/* Content Tabs */}
- {/* Propose changes button - on top on mobile */} - {!isOwner && session?.user && ( -
- + {/* Action buttons - on top on mobile */} +
+ +
+ {!isOwner && session?.user && ( + + )} + {isOwner && ( + + )}
- )} +
{t("promptContent")} @@ -478,15 +490,30 @@ export default async function PromptPage({ params }: PromptPageProps) { )} - {/* Propose changes button - inline on desktop */} - {!isOwner && session?.user && ( - - )} + {/* Action buttons - inline on desktop */} +
+ + {!isOwner && session?.user && ( + + )} + {isOwner && ( + + )} +
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index d54e4b00..a3afbb26 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -142,13 +142,22 @@ export function Header({ authProvider = "credentials", allowRegistration = true