Initial commit: add FastAPI MVP (모프) and existing web app

Includes FastAPI+Jinja2+HTMX+SQLite implementation with seed categories, plus deployment templates.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dsyoon
2026-02-16 17:17:22 +09:00
commit 27540269b7
37 changed files with 3246 additions and 0 deletions

53
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
FROM node:20-bookworm-slim AS deps
WORKDIR /app
# system deps (openssl for prisma engines; ca-certs for TLS)
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci
FROM node:20-bookworm-slim AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/package.json ./package.json
COPY --from=deps /app/package-lock.json ./package-lock.json
COPY --from=deps /app/prisma ./prisma
COPY src ./src
COPY public ./public
COPY next.config.ts tsconfig.json postcss.config.mjs eslint.config.mjs ./
RUN npx prisma generate --schema prisma/schema.prisma
RUN npm run build
FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates openssl \
&& rm -rf /var/lib/apt/lists/*
# Next standalone output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -0,0 +1,149 @@
# 서버에서 바로 실행 배포 가이드 (도커 없이)
목표: `prompt.ncue.net` → (Nginx 리버스프록시) → `localhost:3000`(Next.js)
## 1) 서버 준비
- Node.js LTS 설치(권장 20.x 이상)
- PostgreSQL은 외부(`ncue.net/all_prompt`) 사용
- `git`, `build-essential`(일부 패키지 빌드 필요 시) 설치
## 2) 코드 배포(예시)
```bash
cd /opt
sudo git clone <YOUR_REPO_URL> all-prompt
cd /opt/all-prompt/apps/web
sudo npm ci
```
## 3) 환경변수 설정
`.env`를 생성한다(예: `/opt/all-prompt/apps/web/.env`).
```bash
DATABASE_URL="postgresql://ncue:<PASSWORD>@ncue.net:5432/all_prompt?schema=public"
NEXT_PUBLIC_SITE_URL="https://prompt.ncue.net"
NODE_ENV="production"
PORT=3000
```
## 4) DB 스키마 적용(1회)
> 이미 DB에 테이블이 있다면 생략 가능.
### A안(권장): psql로 DDL 적용
```bash
export PGPASSWORD="<PASSWORD>"
psql -h ncue.net -U ncue -d all_prompt -f db/schema.sql
```
### B안: Prisma db push(DDL 대신 Prisma 기준)
```bash
npm run prisma:generate
npm run db:push
```
## 5) 시드 데이터(1회)
```bash
npm run db:seed
```
## 6) 빌드/실행
```bash
npm run build
npm run start
```
이제 `http://localhost:3000`에서 동작해야 한다.
## 7) systemd로 상시 실행
### 7.1 서비스 유저(권장)
```bash
sudo useradd -r -s /usr/sbin/nologin allprompt || true
sudo chown -R allprompt:allprompt /opt/all-prompt
```
### 7.2 유닛 파일 생성
`/etc/systemd/system/allprompt-web.service`
```ini
[Unit]
Description=all prompt (모프) Next.js web
After=network.target
[Service]
Type=simple
User=allprompt
WorkingDirectory=/opt/all-prompt/apps/web
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/opt/all-prompt/apps/web/.env
ExecStart=/usr/bin/npm run start
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
적용:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now allprompt-web
sudo systemctl status allprompt-web
```
## 8) Nginx 리버스프록시로 prompt.ncue.net 연결
### 8.1 Nginx 설치 및 서버블록
`/etc/nginx/sites-available/prompt.ncue.net`
```nginx
server {
listen 80;
server_name prompt.ncue.net;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
활성화:
```bash
sudo ln -s /etc/nginx/sites-available/prompt.ncue.net /etc/nginx/sites-enabled/prompt.ncue.net
sudo nginx -t && sudo systemctl reload nginx
```
### 8.2 HTTPS(권장): Lets Encrypt
```bash
sudo apt-get update
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d prompt.ncue.net
```
## 9) 배포 후 확인
- `https://prompt.ncue.net/api/health`
- `https://prompt.ncue.net/entities`
- `https://prompt.ncue.net/prompts`

9
apps/web/next.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
poweredByHeader: false,
serverExternalPackages: ["@prisma/client"],
};
export default nextConfig;

41
apps/web/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"prisma:generate": "prisma generate",
"db:push": "prisma db push --schema prisma/schema.prisma",
"db:migrate": "prisma migrate dev --schema prisma/schema.prisma",
"db:seed": "prisma db seed --schema prisma/schema.prisma"
},
"dependencies": {
"@prisma/client": "^6.16.0",
"@tailwindcss/typography": "^0.5.19",
"next": "16.1.6",
"prisma": "^6.16.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
},
"prisma": {
"schema": "prisma/schema.prisma",
"seed": "tsx prisma/seed.ts"
}
}

197
apps/web/prisma/seed.ts Normal file
View File

@@ -0,0 +1,197 @@
import { PrismaClient, PromptModelProvider } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
// 최소 사용자(시드): 운영 초기에는 OAuth/Email 붙이기 전까지 작성자 null 허용
// 카테고리
const catModels = await prisma.entityCategory.upsert({
where: { slug: "models" },
update: {},
create: { slug: "models", name: "모델", description: "LLM/이미지 모델" },
});
const catWork = await prisma.entityCategory.upsert({
where: { slug: "work" },
update: {},
create: { slug: "work", name: "업무", description: "업무/상황 기반 대상" },
});
// 엔티티
const entityChatGPT = await prisma.entity.upsert({
where: { slug: "chatgpt" },
update: {},
create: {
slug: "chatgpt",
name: "ChatGPT",
summary: "대화형 LLM 기반 생산성/개발/글쓰기 도구",
contentMd:
"## 개요\nChatGPT는 자연어로 지시를 내리면 다양한 결과물을 생성하는 대화형 모델입니다.\n\n## 추천 사용\n- 요약/정리\n- 문서 초안\n- 코드 리뷰/디버깅\n",
categoryId: catModels.id,
},
});
const entityInterview = await prisma.entity.upsert({
where: { slug: "interview" },
update: {},
create: {
slug: "interview",
name: "면접",
summary: "면접 준비/질문/답변 구조화",
contentMd:
"## 개요\n면접은 직무 역량과 커뮤니케이션을 검증하는 과정입니다.\n\n## 프롬프트 활용\n- 경험 정리(STAR)\n- 예상 질문 대비\n- 모의면접\n",
categoryId: catWork.id,
},
});
// 모델
const modelChatGPT = await prisma.promptModel.upsert({
where: { slug: "chatgpt" },
update: {},
create: {
slug: "chatgpt",
provider: PromptModelProvider.openai,
name: "ChatGPT (OpenAI)",
description: "OpenAI ChatGPT 계열",
},
});
const modelClaude = await prisma.promptModel.upsert({
where: { slug: "claude" },
update: {},
create: {
slug: "claude",
provider: PromptModelProvider.anthropic,
name: "Claude (Anthropic)",
description: "Anthropic Claude 계열",
},
});
// 태그
const tagSummary = await prisma.tag.upsert({
where: { slug: "summary" },
update: {},
create: { slug: "summary", name: "요약" },
});
const tagDev = await prisma.tag.upsert({
where: { slug: "dev" },
update: {},
create: { slug: "dev", name: "개발" },
});
const tagInterview = await prisma.tag.upsert({
where: { slug: "interview" },
update: {},
create: { slug: "interview", name: "면접" },
});
// Prompt 생성 헬퍼(버전 1 + current_version 세팅 + 예시 + 태그)
async function createPromptWithV1(args: {
entityId: string;
title: string;
descriptionMd?: string;
modelId?: string;
promptText: string;
changelog?: string;
tags: string[]; // tag ids
examples?: Array<{ input?: string; output?: string; note?: string }>;
}) {
return prisma.$transaction(async (tx) => {
const prompt = await tx.prompt.create({
data: {
entityId: args.entityId,
title: args.title,
descriptionMd: args.descriptionMd,
modelId: args.modelId,
},
});
const v1 = await tx.promptVersion.create({
data: {
promptId: prompt.id,
versionNo: 1,
promptText: args.promptText,
changelog: args.changelog ?? "초기 등록",
isApproved: true,
approvedAt: new Date(),
},
});
await tx.prompt.update({
where: { id: prompt.id },
data: { currentVersionId: v1.id },
});
if (args.tags.length) {
await tx.promptTag.createMany({
data: args.tags.map((tagId) => ({ promptId: prompt.id, tagId })),
skipDuplicates: true,
});
}
if (args.examples?.length) {
await tx.promptExample.createMany({
data: args.examples.map((ex) => ({
promptVersionId: v1.id,
inputExample: ex.input,
outputExample: ex.output,
note: ex.note,
})),
});
}
return { promptId: prompt.id, versionId: v1.id };
});
}
// 샘플 프롬프트
await createPromptWithV1({
entityId: entityChatGPT.id,
title: "긴 글 핵심 요약 + 액션아이템 추출",
descriptionMd:
"회의록/기사/긴 문서를 붙여넣고 **핵심 요약 + 액션아이템**을 구조화해서 뽑습니다.",
modelId: modelChatGPT.id,
promptText:
"너는 뛰어난 문서 편집자다.\n\n아래 텍스트를 읽고 다음 형식으로 답해라:\n1) 5줄 요약\n2) 핵심 포인트(불릿 5개)\n3) 액션 아이템(담당/기한 포함, 모르면 '미정')\n4) 리스크/의문점\n\n[텍스트]\n{{TEXT}}\n",
tags: [tagSummary.id],
examples: [
{
input: "TEXT=오늘 회의에서 A는 3/1까지…",
output: "1) 5줄 요약…\n2) 핵심 포인트…\n3) 액션 아이템…",
},
],
});
await createPromptWithV1({
entityId: entityChatGPT.id,
title: "코드 리뷰어(버그/보안/성능 체크리스트)",
descriptionMd:
"PR diff를 주면 **버그/보안/성능/가독성** 관점에서 리뷰 코멘트를 작성합니다.",
modelId: modelClaude.id,
promptText:
"너는 시니어 코드 리뷰어다.\n\n다음을 수행해라:\n- 잠재 버그\n- 보안 취약점\n- 성능 병목\n- 코드 스타일/가독성\n- 테스트 제안\n\n출력은 섹션별로 bullet로.\n\n[DIFF]\n{{DIFF}}\n",
tags: [tagDev.id],
});
await createPromptWithV1({
entityId: entityInterview.id,
title: "STAR 기반 경험 정리 + 면접 답변 리라이트",
descriptionMd:
"경험을 STAR로 재구성하고, 면접 답변으로 자연스럽게 리라이트합니다.",
modelId: modelChatGPT.id,
promptText:
"너는 면접 코치다.\n\n아래 경험을 STAR(상황/과제/행동/결과)로 정리하고, 60~90초 답변 스크립트로 리라이트해라.\n- 과장 금지, 수치/근거 우선\n- 리스크/실패가 있으면 학습 포인트 포함\n\n[경험]\n{{EXP}}\n",
tags: [tagInterview.id],
});
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

View File

@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const entity = await prisma.entity.findUnique({
where: { slug },
select: {
id: true,
slug: true,
name: true,
summary: true,
contentMd: true,
updatedAt: true,
category: { select: { slug: true, name: true } },
prompts: {
where: { isPublished: true },
orderBy: { updatedAt: "desc" },
take: 20,
select: {
id: true,
title: true,
descriptionMd: true,
updatedAt: true,
model: { select: { slug: true, name: true, provider: true } },
_count: { select: { likes: true, comments: true, bookmarks: true } },
},
},
},
});
if (!entity) {
return NextResponse.json(
{ ok: false, error: "NOT_FOUND" },
{ status: 404 },
);
}
return NextResponse.json({ ok: true, entity });
}

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
const QuerySchema = z.object({
query: z.string().trim().min(1).max(100).optional(),
category: z.string().trim().min(1).max(100).optional(), // category slug
limit: z.coerce.number().int().min(1).max(50).optional().default(20),
offset: z.coerce.number().int().min(0).max(5000).optional().default(0),
sort: z.enum(["name", "recent"]).optional().default("name"),
});
export async function GET(req: Request) {
const url = new URL(req.url);
const parsed = QuerySchema.safeParse({
query: url.searchParams.get("query") ?? undefined,
category: url.searchParams.get("category") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
offset: url.searchParams.get("offset") ?? undefined,
sort: url.searchParams.get("sort") ?? undefined,
});
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "INVALID_QUERY", detail: parsed.error.flatten() },
{ status: 400 },
);
}
const { query, category, limit, offset, sort } = parsed.data;
const where = {
isPublished: true,
...(category ? { category: { slug: category } } : {}),
...(query
? {
OR: [
{ name: { contains: query, mode: "insensitive" as const } },
{ slug: { contains: query, mode: "insensitive" as const } },
{ summary: { contains: query, mode: "insensitive" as const } },
],
}
: {}),
};
const entities = await prisma.entity.findMany({
where,
orderBy:
sort === "recent"
? ({ updatedAt: "desc" } as const)
: ({ name: "asc" } as const),
take: limit,
skip: offset,
select: {
id: true,
slug: true,
name: true,
summary: true,
updatedAt: true,
category: { select: { slug: true, name: true } },
_count: { select: { prompts: true } },
},
});
return NextResponse.json({ ok: true, items: entities });
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({
ok: true,
service: "all prompt (모프)",
time: new Date().toISOString(),
});
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const prompt = await prisma.prompt.findUnique({
where: { id },
select: {
id: true,
title: true,
descriptionMd: true,
createdAt: true,
updatedAt: true,
entity: { select: { id: true, slug: true, name: true } },
model: { select: { slug: true, name: true, provider: true } },
sourcePrompt: { select: { id: true, title: true } },
remixes: { take: 20, select: { id: true, title: true } },
tags: { select: { tag: { select: { slug: true, name: true } } } },
_count: { select: { likes: true, comments: true, bookmarks: true } },
currentVersion: {
select: {
id: true,
versionNo: true,
promptText: true,
createdAt: true,
isApproved: true,
examples: {
select: {
id: true,
inputExample: true,
outputExample: true,
note: true,
},
},
},
},
versions: {
orderBy: { versionNo: "desc" },
take: 30,
select: {
id: true,
versionNo: true,
changelog: true,
createdAt: true,
isApproved: true,
},
},
},
});
if (!prompt) {
return NextResponse.json(
{ ok: false, error: "NOT_FOUND" },
{ status: 404 },
);
}
return NextResponse.json({ ok: true, prompt });
}

View File

@@ -0,0 +1,80 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";
const QuerySchema = z.object({
query: z.string().trim().min(1).max(200).optional(),
entity: z.string().trim().min(1).max(120).optional(), // entity slug
tag: z.string().trim().min(1).max(120).optional(), // tag slug
model: z.string().trim().min(1).max(120).optional(), // model slug
sort: z.enum(["new", "popular"]).optional().default("new"),
limit: z.coerce.number().int().min(1).max(50).optional().default(20),
offset: z.coerce.number().int().min(0).max(5000).optional().default(0),
});
export async function GET(req: Request) {
const url = new URL(req.url);
const parsed = QuerySchema.safeParse({
query: url.searchParams.get("query") ?? undefined,
entity: url.searchParams.get("entity") ?? undefined,
tag: url.searchParams.get("tag") ?? undefined,
model: url.searchParams.get("model") ?? undefined,
sort: url.searchParams.get("sort") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
offset: url.searchParams.get("offset") ?? undefined,
});
if (!parsed.success) {
return NextResponse.json(
{ ok: false, error: "INVALID_QUERY", detail: parsed.error.flatten() },
{ status: 400 },
);
}
const { query, entity, tag, model, sort, limit, offset } = parsed.data;
const where = {
isPublished: true,
...(entity ? { entity: { slug: entity } } : {}),
...(model ? { model: { slug: model } } : {}),
...(tag
? { tags: { some: { tag: { slug: tag } } } }
: {}),
...(query
? {
OR: [
{ title: { contains: query, mode: "insensitive" as const } },
{
descriptionMd: { contains: query, mode: "insensitive" as const },
},
],
}
: {}),
};
const orderBy =
sort === "popular"
? ({ likes: { _count: "desc" } } as const)
: ({ createdAt: "desc" } as const);
const prompts = await prisma.prompt.findMany({
where,
orderBy,
take: limit,
skip: offset,
select: {
id: true,
title: true,
descriptionMd: true,
createdAt: true,
updatedAt: 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 NextResponse.json({ ok: true, items: prompts });
}

View File

@@ -0,0 +1,164 @@
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { prisma } from "@/lib/prisma";
export default async function EntityDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const entity = await prisma.entity.findUnique({
where: { slug },
select: {
id: true,
slug: true,
name: true,
summary: true,
contentMd: true,
updatedAt: true,
category: { select: { slug: true, name: true } },
prompts: {
where: { isPublished: true },
orderBy: { updatedAt: "desc" },
take: 50,
select: {
id: true,
title: true,
descriptionMd: true,
updatedAt: 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 } },
},
},
},
});
if (!entity) {
return (
<div className="min-h-screen bg-zinc-50">
<div className="mx-auto max-w-5xl px-6 py-16">
<div className="rounded-2xl border border-zinc-200 bg-white p-8">
<p className="text-sm font-semibold"> .</p>
<Link
href="/entities"
className="mt-4 inline-block text-sm font-semibold underline underline-offset-4"
>
</Link>
</div>
</div>
</div>
);
}
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-start justify-between gap-6">
<div className="flex flex-col gap-2">
<Link
href="/entities"
className="text-sm font-semibold text-zinc-600 hover:text-zinc-900"
>
</Link>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">
{entity.name}
</h1>
<p className="text-sm text-zinc-500">
{entity.category?.name ?? "미분류"} · {entity.slug}
</p>
</div>
{entity.summary ? (
<p className="max-w-2xl text-sm leading-7 text-zinc-700">
{entity.summary}
</p>
) : null}
</div>
<Link
href={`/prompts?entity=${entity.slug}`}
className="mt-8 inline-flex h-10 shrink-0 items-center justify-center rounded-full bg-zinc-900 px-4 text-sm font-semibold text-white hover:bg-zinc-800"
>
</Link>
</div>
<div className="rounded-2xl border border-zinc-200 bg-white p-8">
<div className="prose prose-zinc max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{entity.contentMd ?? "## 문서가 비어있습니다.\n\n기여로 채워주세요."}
</ReactMarkdown>
</div>
</div>
<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">{entity.prompts.length}</p>
</div>
<div className="mt-4 grid gap-4">
{entity.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.model ? (
<p className="text-xs text-zinc-500">{p.model.name}</p>
) : (
<p className="text-xs text-zinc-500"> </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>
))}
</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/entities/{entity.slug}
</code>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
export default async function EntitiesPage({
searchParams,
}: {
searchParams: Promise<{ query?: string }>;
}) {
const { query } = await searchParams;
const q = (query ?? "").trim();
const items = await prisma.entity.findMany({
where: {
isPublished: true,
...(q
? {
OR: [
{ name: { contains: q, mode: "insensitive" } },
{ slug: { contains: q, mode: "insensitive" } },
{ summary: { contains: q, mode: "insensitive" } },
],
}
: {}),
},
orderBy: { name: "asc" },
take: 50,
select: {
slug: true,
name: true,
summary: true,
updatedAt: true,
category: { select: { slug: true, name: true } },
_count: { select: { prompts: 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">(Entity)</h1>
</div>
<Link
href="/prompts"
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">
<label className="block text-xs font-semibold text-zinc-700">
</label>
<div className="mt-2 flex gap-2">
<input
name="query"
defaultValue={q}
placeholder="예: ChatGPT, 면접, 마케팅 카피…"
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm outline-none focus:border-zinc-400"
/>
<button
type="submit"
className="h-11 shrink-0 rounded-xl bg-zinc-900 px-4 text-sm font-semibold text-white hover:bg-zinc-800"
>
</button>
</div>
</form>
<div className="grid gap-4 md:grid-cols-2">
{items.map((e) => (
<Link
key={e.slug}
href={`/entities/${e.slug}`}
className="rounded-2xl border border-zinc-200 bg-white p-5 hover:bg-zinc-50"
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold">{e.name}</p>
<p className="text-xs text-zinc-500">{e.slug}</p>
</div>
<div className="text-right">
<p className="text-xs font-semibold text-zinc-700">
{e._count.prompts} prompts
</p>
<p className="text-xs text-zinc-500">
{e.category?.name ?? "미분류"}
</p>
</div>
</div>
{e.summary ? (
<p className="mt-3 line-clamp-2 text-sm text-zinc-600">
{e.summary}
</p>
) : null}
</Link>
))}
</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/entities</code>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "모프 (all prompt)",
description: "대상(Entity) 중심 프롬프트 공유 커뮤니티",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

76
apps/web/src/app/page.tsx Normal file
View File

@@ -0,0 +1,76 @@
import Link from "next/link";
export default function Home() {
return (
<div className="min-h-screen bg-zinc-50 text-zinc-900">
<div className="mx-auto max-w-5xl px-6 py-16">
<div className="rounded-3xl border border-zinc-200 bg-white p-10 shadow-sm">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-zinc-500"> · all prompt</p>
<h1 className="text-3xl font-semibold tracking-tight">
(Entity)
</h1>
<p className="max-w-2xl text-base leading-7 text-zinc-600">
.
.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/entities"
className="inline-flex h-11 items-center justify-center rounded-full bg-zinc-900 px-5 text-sm font-semibold text-white hover:bg-zinc-800"
>
</Link>
<Link
href="/prompts"
className="inline-flex h-11 items-center justify-center rounded-full border border-zinc-200 bg-white px-5 text-sm font-semibold text-zinc-900 hover:bg-zinc-50"
>
</Link>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-zinc-200 p-5">
<p className="text-sm font-semibold"> </p>
<p className="mt-1 text-sm text-zinc-600">
ChatGPT·Midjourney·
</p>
</div>
<div className="rounded-2xl border border-zinc-200 p-5">
<p className="text-sm font-semibold"> </p>
<p className="mt-1 text-sm text-zinc-600">
////
</p>
</div>
<div className="rounded-2xl border border-zinc-200 p-5">
<p className="text-sm font-semibold"></p>
<p className="mt-1 text-sm text-zinc-600">
</p>
</div>
</div>
<div className="flex items-center justify-between rounded-2xl bg-zinc-50 p-5">
<div>
<p className="text-sm font-semibold">API </p>
<p className="text-sm text-zinc-600">
<code className="rounded bg-white px-1">/api/health</code>
</p>
</div>
<Link
href="/api/health"
className="text-sm font-semibold text-zinc-900 underline underline-offset-4"
>
health
</Link>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,256 @@
import Link from "next/link";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { prisma } from "@/lib/prisma";
function CodeBlock({
title,
content,
}: {
title: string;
content: string | null | undefined;
}) {
return (
<div className="rounded-2xl border border-zinc-200 bg-white">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<p className="text-xs font-semibold text-zinc-700">{title}</p>
</div>
<pre className="overflow-auto p-4 text-sm leading-6">
<code>{content ?? ""}</code>
</pre>
</div>
);
}
export default async function PromptDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const prompt = await prisma.prompt.findUnique({
where: { id },
select: {
id: true,
title: true,
descriptionMd: true,
createdAt: true,
updatedAt: 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 } },
sourcePrompt: { select: { id: true, title: true } },
remixes: { take: 20, select: { id: true, title: true } },
currentVersion: {
select: {
id: true,
versionNo: true,
promptText: true,
createdAt: true,
isApproved: true,
examples: {
select: {
id: true,
inputExample: true,
outputExample: true,
note: true,
},
},
},
},
versions: {
orderBy: { versionNo: "desc" },
take: 30,
select: { id: true, versionNo: true, changelog: true, createdAt: true },
},
},
});
if (!prompt) {
return (
<div className="min-h-screen bg-zinc-50">
<div className="mx-auto max-w-5xl px-6 py-16">
<div className="rounded-2xl border border-zinc-200 bg-white p-8">
<p className="text-sm font-semibold"> .</p>
<Link
href="/prompts"
className="mt-4 inline-block text-sm font-semibold underline underline-offset-4"
>
</Link>
</div>
</div>
</div>
);
}
const cv = prompt.currentVersion;
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 flex-col gap-2">
<Link
href="/prompts"
className="text-sm font-semibold text-zinc-600 hover:text-zinc-900"
>
</Link>
<h1 className="text-2xl font-semibold tracking-tight">{prompt.title}</h1>
<div className="flex flex-wrap items-center gap-2 text-sm text-zinc-600">
<Link
href={`/entities/${prompt.entity.slug}`}
className="font-semibold text-zinc-900 underline underline-offset-4"
>
{prompt.entity.name}
</Link>
<span>·</span>
<span>{prompt.model?.name ?? "모델 미지정"}</span>
<span>·</span>
<span> {prompt._count.likes}</span>
<span>·</span>
<span> {prompt._count.bookmarks}</span>
<span>·</span>
<span> {prompt._count.comments}</span>
</div>
{prompt.tags.length ? (
<div className="mt-1 flex flex-wrap gap-2">
{prompt.tags.map((t) => (
<Link
key={t.tag.slug}
href={`/prompts?tag=${t.tag.slug}`}
className="rounded-full border border-zinc-200 bg-white px-2 py-0.5 text-xs text-zinc-700 hover:bg-zinc-50"
>
#{t.tag.name}
</Link>
))}
</div>
) : null}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<CodeBlock title={`프롬프트 원문 (v${cv?.versionNo ?? "?"})`} content={cv?.promptText} />
<div className="rounded-2xl border border-zinc-200 bg-white p-6">
<p className="text-sm font-semibold"></p>
<div className="prose prose-zinc mt-3 max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{prompt.descriptionMd ?? "설명이 아직 없습니다."}
</ReactMarkdown>
</div>
{prompt.sourcePrompt ? (
<div className="mt-6 rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
<p className="text-xs font-semibold text-zinc-700"> </p>
<Link
href={`/prompts/${prompt.sourcePrompt.id}`}
className="mt-1 inline-block text-sm font-semibold underline underline-offset-4"
>
{prompt.sourcePrompt.title}
</Link>
</div>
) : null}
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-zinc-200 bg-white p-6">
<p className="text-sm font-semibold">/ </p>
<div className="mt-4 grid gap-3">
{cv?.examples?.length ? (
cv.examples.map((ex, idx) => (
<div
key={ex.id}
className="rounded-2xl border border-zinc-200 bg-zinc-50 p-4"
>
<p className="text-xs font-semibold text-zinc-700">
{idx + 1}
</p>
{ex.inputExample ? (
<div className="mt-2">
<p className="text-xs font-semibold text-zinc-600">
</p>
<pre className="mt-1 overflow-auto rounded-xl bg-white p-3 text-xs leading-5">
<code>{ex.inputExample}</code>
</pre>
</div>
) : null}
{ex.outputExample ? (
<div className="mt-2">
<p className="text-xs font-semibold text-zinc-600">
</p>
<pre className="mt-1 overflow-auto rounded-xl bg-white p-3 text-xs leading-5">
<code>{ex.outputExample}</code>
</pre>
</div>
) : null}
{ex.note ? (
<p className="mt-2 text-xs text-zinc-600">{ex.note}</p>
) : null}
</div>
))
) : (
<p className="text-sm text-zinc-600"> .</p>
)}
</div>
</div>
<div className="rounded-2xl border border-zinc-200 bg-white p-6">
<p className="text-sm font-semibold"> </p>
<div className="mt-3 grid gap-2">
{prompt.versions.map((v) => (
<div
key={v.id}
className="flex items-center justify-between rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3"
>
<div className="flex flex-col">
<p className="text-sm font-semibold">v{v.versionNo}</p>
<p className="text-xs text-zinc-600">
{v.changelog ?? "변경 요약 없음"}
</p>
</div>
<p className="text-xs text-zinc-500">
{new Date(v.createdAt).toLocaleDateString("ko-KR")}
</p>
</div>
))}
</div>
{prompt.remixes.length ? (
<div className="mt-6">
<p className="text-sm font-semibold"> ()</p>
<div className="mt-2 grid gap-2">
{prompt.remixes.map((r) => (
<Link
key={r.id}
href={`/prompts/${r.id}`}
className="rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm font-semibold hover:bg-white"
>
{r.title}
</Link>
))}
</div>
</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/{prompt.id}</code>
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,260 @@
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>
);
}

View File

@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;