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:
53
apps/web/Dockerfile
Normal file
53
apps/web/Dockerfile
Normal 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"]
|
||||
|
||||
149
apps/web/docs/deploy-server.md
Normal file
149
apps/web/docs/deploy-server.md
Normal 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(권장): Let’s 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
9
apps/web/next.config.ts
Normal 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
41
apps/web/package.json
Normal 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
197
apps/web/prisma/seed.ts
Normal 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);
|
||||
});
|
||||
|
||||
45
apps/web/src/app/api/entities/[slug]/route.ts
Normal file
45
apps/web/src/app/api/entities/[slug]/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
67
apps/web/src/app/api/entities/route.ts
Normal file
67
apps/web/src/app/api/entities/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
10
apps/web/src/app/api/health/route.ts
Normal file
10
apps/web/src/app/api/health/route.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
|
||||
64
apps/web/src/app/api/prompts/[id]/route.ts
Normal file
64
apps/web/src/app/api/prompts/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
80
apps/web/src/app/api/prompts/route.ts
Normal file
80
apps/web/src/app/api/prompts/route.ts
Normal 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 });
|
||||
}
|
||||
|
||||
164
apps/web/src/app/entities/[slug]/page.tsx
Normal file
164
apps/web/src/app/entities/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
116
apps/web/src/app/entities/page.tsx
Normal file
116
apps/web/src/app/entities/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
27
apps/web/src/app/globals.css
Normal file
27
apps/web/src/app/globals.css
Normal 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;
|
||||
}
|
||||
34
apps/web/src/app/layout.tsx
Normal file
34
apps/web/src/app/layout.tsx
Normal 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
76
apps/web/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
apps/web/src/app/prompts/[id]/page.tsx
Normal file
256
apps/web/src/app/prompts/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
260
apps/web/src/app/prompts/page.tsx
Normal file
260
apps/web/src/app/prompts/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
15
apps/web/src/lib/prisma.ts
Normal file
15
apps/web/src/lib/prisma.ts
Normal 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;
|
||||
|
||||
Reference in New Issue
Block a user