Includes FastAPI+Jinja2+HTMX+SQLite implementation with seed categories, plus deployment templates. Co-authored-by: Cursor <cursoragent@cursor.com>
261 lines
9.3 KiB
TypeScript
261 lines
9.3 KiB
TypeScript
import Link from "next/link";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
export default async function PromptsPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{
|
|
query?: string;
|
|
entity?: string;
|
|
tag?: string;
|
|
model?: string;
|
|
sort?: "new" | "popular";
|
|
}>;
|
|
}) {
|
|
const sp = await searchParams;
|
|
const query = (sp.query ?? "").trim();
|
|
const entity = (sp.entity ?? "").trim();
|
|
const tag = (sp.tag ?? "").trim();
|
|
const model = (sp.model ?? "").trim();
|
|
const sort = sp.sort === "popular" ? "popular" : "new";
|
|
|
|
const [entities, tags, models, prompts] = await Promise.all([
|
|
prisma.entity.findMany({
|
|
where: { isPublished: true },
|
|
orderBy: { name: "asc" },
|
|
take: 100,
|
|
select: { slug: true, name: true },
|
|
}),
|
|
prisma.tag.findMany({
|
|
orderBy: { name: "asc" },
|
|
take: 200,
|
|
select: { slug: true, name: true },
|
|
}),
|
|
prisma.promptModel.findMany({
|
|
orderBy: { name: "asc" },
|
|
take: 100,
|
|
select: { slug: true, name: true, provider: true },
|
|
}),
|
|
prisma.prompt.findMany({
|
|
where: {
|
|
isPublished: true,
|
|
...(entity ? { entity: { slug: entity } } : {}),
|
|
...(model ? { model: { slug: model } } : {}),
|
|
...(tag ? { tags: { some: { tag: { slug: tag } } } } : {}),
|
|
...(query
|
|
? {
|
|
OR: [
|
|
{ title: { contains: query, mode: "insensitive" } },
|
|
{ descriptionMd: { contains: query, mode: "insensitive" } },
|
|
],
|
|
}
|
|
: {}),
|
|
},
|
|
orderBy:
|
|
sort === "popular"
|
|
? { likes: { _count: "desc" } }
|
|
: { createdAt: "desc" },
|
|
take: 50,
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
descriptionMd: true,
|
|
createdAt: true,
|
|
entity: { select: { slug: true, name: true } },
|
|
model: { select: { slug: true, name: true, provider: true } },
|
|
tags: { select: { tag: { select: { slug: true, name: true } } } },
|
|
_count: { select: { likes: true, comments: true, bookmarks: true } },
|
|
},
|
|
}),
|
|
]);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-zinc-50">
|
|
<div className="mx-auto max-w-5xl px-6 py-10">
|
|
<div className="flex flex-col gap-6">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex flex-col">
|
|
<p className="text-sm font-medium text-zinc-500">모프</p>
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
프롬프트 검색
|
|
</h1>
|
|
</div>
|
|
<Link
|
|
href="/entities"
|
|
className="text-sm font-semibold text-zinc-900 underline underline-offset-4"
|
|
>
|
|
대상으로 이동
|
|
</Link>
|
|
</div>
|
|
|
|
<form className="rounded-2xl border border-zinc-200 bg-white p-4">
|
|
<div className="grid gap-3 md:grid-cols-4">
|
|
<div className="md:col-span-2">
|
|
<label className="block text-xs font-semibold text-zinc-700">
|
|
키워드
|
|
</label>
|
|
<input
|
|
name="query"
|
|
defaultValue={query}
|
|
placeholder="예: 요약, 코드 리뷰, STAR…"
|
|
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm outline-none focus:border-zinc-400"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-zinc-700">
|
|
대상(Entity)
|
|
</label>
|
|
<select
|
|
name="entity"
|
|
defaultValue={entity}
|
|
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm outline-none focus:border-zinc-400"
|
|
>
|
|
<option value="">전체</option>
|
|
{entities.map((e) => (
|
|
<option key={e.slug} value={e.slug}>
|
|
{e.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-zinc-700">
|
|
정렬
|
|
</label>
|
|
<select
|
|
name="sort"
|
|
defaultValue={sort}
|
|
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm outline-none focus:border-zinc-400"
|
|
>
|
|
<option value="new">최신</option>
|
|
<option value="popular">인기(좋아요)</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-zinc-700">
|
|
태그
|
|
</label>
|
|
<select
|
|
name="tag"
|
|
defaultValue={tag}
|
|
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm outline-none focus:border-zinc-400"
|
|
>
|
|
<option value="">전체</option>
|
|
{tags.map((t) => (
|
|
<option key={t.slug} value={t.slug}>
|
|
{t.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-semibold text-zinc-700">
|
|
모델
|
|
</label>
|
|
<select
|
|
name="model"
|
|
defaultValue={model}
|
|
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 text-sm outline-none focus:border-zinc-400"
|
|
>
|
|
<option value="">전체</option>
|
|
{models.map((m) => (
|
|
<option key={m.slug} value={m.slug}>
|
|
{m.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-end gap-2">
|
|
<button
|
|
type="submit"
|
|
className="h-11 w-full rounded-xl bg-zinc-900 px-4 text-sm font-semibold text-white hover:bg-zinc-800"
|
|
>
|
|
적용
|
|
</button>
|
|
<Link
|
|
href="/prompts"
|
|
className="inline-flex h-11 shrink-0 items-center justify-center rounded-xl border border-zinc-200 bg-white px-4 text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
|
|
>
|
|
초기화
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="rounded-2xl border border-zinc-200 bg-white p-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold">결과</h2>
|
|
<p className="text-sm text-zinc-500">{prompts.length}개</p>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-4">
|
|
{prompts.map((p) => (
|
|
<Link
|
|
key={p.id}
|
|
href={`/prompts/${p.id}`}
|
|
className="rounded-2xl border border-zinc-200 bg-zinc-50 p-5 hover:bg-white"
|
|
>
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex flex-col gap-1">
|
|
<p className="text-sm font-semibold">{p.title}</p>
|
|
<p className="text-xs text-zinc-500">
|
|
{p.entity.name}
|
|
{p.model ? ` · ${p.model.name}` : ""}
|
|
</p>
|
|
</div>
|
|
<div className="text-right text-xs text-zinc-600">
|
|
<p>좋아요 {p._count.likes}</p>
|
|
<p>댓글 {p._count.comments}</p>
|
|
<p>북마크 {p._count.bookmarks}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{p.descriptionMd ? (
|
|
<p className="mt-2 line-clamp-2 text-sm text-zinc-700">
|
|
{p.descriptionMd}
|
|
</p>
|
|
) : null}
|
|
|
|
{p.tags.length ? (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{p.tags.map((t) => (
|
|
<span
|
|
key={t.tag.slug}
|
|
className="rounded-full border border-zinc-200 bg-white px-2 py-0.5 text-xs text-zinc-700"
|
|
>
|
|
#{t.tag.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</Link>
|
|
))}
|
|
{!prompts.length ? (
|
|
<div className="rounded-2xl border border-zinc-200 bg-white p-6 text-sm text-zinc-600">
|
|
결과가 없습니다. 필터를 줄이거나 키워드를 바꿔보세요.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-zinc-200 bg-white p-5">
|
|
<p className="text-sm font-semibold">API</p>
|
|
<p className="mt-1 text-sm text-zinc-600">
|
|
검색 API:{" "}
|
|
<code className="rounded bg-zinc-50 px-1">/api/prompts</code>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|