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

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# 운영용(도커)
# 아래 값을 복사해 .env 로 만들고 채우세요.
# Next.js 앱이 사용할 DB (원격 Postgres)
DATABASE_URL="postgresql://ncue:<PASSWORD>@ncue.net:5432/all_prompt?schema=public"
# Caddy(HTTPS) 인증서 발급용 이메일(필수)
CADDY_EMAIL="you@example.com"
# (선택) db_init 서비스용 psql 환경변수
PGHOST="ncue.net"
PGDATABASE="all_prompt"
PGUSER="ncue"
PGPASSWORD="<PASSWORD>"
# (선택) 프론트에서 참조할 사이트 URL
NEXT_PUBLIC_SITE_URL="https://prompt.ncue.net"

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Python
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Virtualenv
venv/
.venv/
# Local SQLite DB (generated by running the MVP)
all_prompt.db
*.db
# OS / Editor
.DS_Store
# Node (existing apps/web)
node_modules/
.next/
dist/
build/
out/

9
Caddyfile Normal file
View File

@@ -0,0 +1,9 @@
{
email {$CADDY_EMAIL}
}
prompt.ncue.net {
encode zstd gzip
reverse_proxy web:3000
}

125
PROMPT.txt Normal file
View File

@@ -0,0 +1,125 @@
당신은 MVP 제작에 특화된 시니어 풀스택 엔지니어입니다.
아래 요구사항을 만족하는 프롬프트 공유 커뮤니티 웹 서비스를 설계하고 구현하세요.
## 1. 서비스 개요
- 서비스명: all prompt
- 한국어 이름: 모프
- 목적: 누구나 쉽게 AI 프롬프트를
- 탐색
- 등록
- 복사
- 간단히 평가(좋아요)
할 수 있는 초경량 커뮤니티
## 2. 기술 스택 (최소 구성)
- Backend: Python + FastAPI
- Frontend: Jinja2 Template + HTMX (React 사용 금지)
- DB: SQLite (초기), ORM은 SQLAlchemy
- Auth: 로그인 없이 시작 (닉네임 기반, 쿠키 저장)
- 실행: `python main.py` 로 바로 실행 가능
- 배포 고려하지 않음 (로컬/소규모 서버용)
## 3. UX / UI 원칙
- 매우 단순할 것
- 회원가입 없음
- 프롬프트 등록은 버튼 한 번
- 모바일에서도 읽기 쉬운 UI
- Tailwind CDN 사용 (빌드 없음)
## 4. 핵심 기능
### 4.1 프롬프트
- 프롬프트 목록 보기
- 프롬프트 상세 보기
- 프롬프트 등록
- 프롬프트 복사 버튼
- 좋아요 버튼 (중복 방지: IP 또는 쿠키 기반)
### 4.2 카테고리
- 예시:
- 글쓰기
- 코딩
- 업무 자동화
- 이미지 생성
- 데이터 분석
- 프롬프트는 하나의 카테고리에만 속함
### 4.3 검색
- 제목 + 프롬프트 내용 LIKE 검색
- 한 페이지에 20개 노출
## 5. DB 설계 (반드시 테이블 정의 포함)
- host: ncue.net
- database: all_prompt
- user: ncue
- password: ncue5004!
- 필요한 테이블은 직접 생성하시오
### users
- id (PK)
- nickname (string)
- created_at (datetime)
### categories
- id (PK)
- name (string)
- slug (string)
### prompts
- id (PK)
- title (string)
- content (text)
- description (text, nullable)
- category_id (FK)
- author_nickname (string)
- created_at (datetime)
- copy_count (int)
### likes
- id (PK)
- prompt_id (FK)
- user_identifier (string) # 쿠키 or IP 해시
- created_at (datetime)
## 6. URL 설계
- GET / → 프롬프트 리스트
- GET /prompt/{id} → 프롬프트 상세
- GET /new → 프롬프트 등록 폼
- POST /new → 프롬프트 저장
- POST /like/{id} → 좋아요
- GET /search?q= → 검색
## 7. 디렉토리 구조
- main.py
- models.py
- database.py
- templates/
- base.html
- index.html
- detail.html
- new.html
- static/
- (비워도 됨)
## 8. 구현 방식
- FastAPI + Jinja2 TemplateResponse 사용
- 모든 코드는 하나의 저장소에서 바로 실행 가능
- migration 도구 사용 금지 (create_all 사용)
- 주석을 충분히 작성해 초보자도 이해 가능하게
## 9. 추가 조건
- "나중에 로그인 붙이기 쉬운 구조"로 작성
- 코드 가독성 최우선
- 불필요한 추상화 금지
- Cursor가 파일 단위로 코드를 생성하도록 안내
## 10. 결과물
- 완전 실행 가능한 코드
- 실행 방법 설명 포함
- 초기 카테고리 seed 코드 포함
이 요구사항을 기준으로
1⃣ 전체 구조 설명
2⃣ DB 모델 코드
3⃣ main.py
4⃣ 템플릿 파일들
을 순서대로 작성하세요.

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# all prompt (모프)
AI 프롬프트 공유 커뮤니티 플랫폼. 나무위키처럼 **대상(Entity) 중심으로 문서를 탐색**하다가 프롬프트를 발견/이해/기여할 수 있게 설계한다.
## 핵심 컨셉
- **Entity(대상) 중심 구조**: 모든 프롬프트는 특정 Entity 문서에 귀속된다.
- **프롬프트는 문서(위키) 형태**: 원문 + 설명 + 예시 + 모델 + 태그 + 버전 히스토리 + 리믹스(파생).
- **권한 모델**
- 비회원: 열람
- 회원: 등록/댓글/좋아요/북마크
- 기여자: 수정 제안(리비전 생성)
- 관리자: 승인/롤백/신고 처리
## DB 스키마
`db/schema.sql`에 PostgreSQL DDL이 있으며, `prisma/schema.prisma`는 동일 구조의 Prisma 매핑이다.
### 원격 DB 적용(권장: 로컬에서 실행)
보안상 이 저장소/문서에는 비밀번호를 포함하지 않는다. 아래처럼 환경변수로 전달해서 실행한다.
```bash
export PGPASSWORD='<password>'
psql -h ncue.net -U ncue -d all_prompt -f db/schema.sql
```
## 도커로 운영(ubuntu 22.04)
루트에 `docker-compose.yml`, `Caddyfile`이 있으며 **HTTPS 포함**으로 `prompt.ncue.net`을 바로 띄울 수 있다.
```bash
cp .env.example .env
# .env에서 DATABASE_URL, CADDY_EMAIL, (필요 시) PGPASSWORD 채우기
docker compose up -d --build
```
스키마/시드(필요 시 1회):
```bash
docker compose --profile init run --rm db_init
docker compose --profile init run --rm db_seed
```
## 기술 스택(가정)
- Frontend: Next.js (App Router)
- Backend: Next.js API(Route Handlers) 또는 NestJS
- DB: PostgreSQL
- ORM: Prisma
- Auth: OAuth + Email
## 폴더 구조(예시)
`docs/architecture.md` 참고.

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;

88
database.py Normal file
View File

@@ -0,0 +1,88 @@
"""
database.py
- 기본 DB는 SQLite 파일(`all_prompt.db`)을 사용합니다.
- 나중에 PostgreSQL 등으로 바꾸기 쉽도록 `MOPF_DATABASE_URL` 환경변수를 지원합니다.
- migration 도구 없이 `create_all()`로 테이블을 자동 생성합니다.
요구사항에 등장한 원격 DB 정보(참고):
host: ncue.net
database: all_prompt
user: ncue
password: (문서/코드에 하드코딩하지 말고 환경변수로 전달 권장)
"""
from __future__ import annotations
import os
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from models import Base, Category
def _default_sqlite_url() -> str:
# 루트에서 `python main.py`를 실행하는 것을 전제로, 현재 작업 디렉토리에 DB 파일이 생성됩니다.
return "sqlite:///./all_prompt.db"
# 이 저장소에는 기존(Next.js/Prisma 등)에서 `DATABASE_URL`을 쓰는 코드가 이미 있을 수 있습니다.
# 따라서 Python MVP는 충돌을 피하려고 별도의 환경변수명을 사용합니다.
DATABASE_URL = os.getenv("MOPF_DATABASE_URL", _default_sqlite_url())
# SQLite는 멀티스레드에서 같은 커넥션을 공유할 때 옵션이 필요합니다.
connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(
DATABASE_URL,
connect_args=connect_args,
future=True,
)
SessionLocal = sessionmaker(
bind=engine,
autocommit=False,
autoflush=False,
expire_on_commit=False, # 템플릿 렌더링에서 접근하기 편하게
class_=Session,
)
def get_db() -> Generator[Session, None, None]:
"""FastAPI dependency로 사용하는 DB 세션."""
db = SessionLocal()
try:
yield db
finally:
db.close()
DEFAULT_CATEGORIES = [
# (name, slug)
("글쓰기", "writing"),
("코딩", "coding"),
("업무 자동화", "automation"),
("이미지 생성", "image-generation"),
("데이터 분석", "data-analysis"),
]
def init_db_and_seed() -> None:
"""
- 테이블 생성(create_all)
- 초기 카테고리 seed(이미 있으면 건너뜀)
"""
Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
existing = db.query(Category).count()
if existing == 0:
for name, slug in DEFAULT_CATEGORIES:
db.add(Category(name=name, slug=slug))
db.commit()
finally:
db.close()

69
docker-compose.yml Normal file
View File

@@ -0,0 +1,69 @@
services:
web:
build:
context: ./apps/web
dockerfile: Dockerfile
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: ${DATABASE_URL}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://prompt.ncue.net}
restart: unless-stopped
networks:
- allprompt
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
CADDY_EMAIL: ${CADDY_EMAIL}
depends_on:
- web
restart: unless-stopped
networks:
- allprompt
# 1회성: 스키마 적용(필요할 때만)
db_init:
image: postgres:16
profiles: ["init"]
volumes:
- ./apps/web/db/schema.sql:/schema.sql:ro
environment:
PGHOST: ${PGHOST:-ncue.net}
PGDATABASE: ${PGDATABASE:-all_prompt}
PGUSER: ${PGUSER:-ncue}
PGPASSWORD: ${PGPASSWORD}
entrypoint: ["/bin/bash", "-lc"]
command: ["psql -v ON_ERROR_STOP=1 -f /schema.sql"]
networks:
- allprompt
# 1회성: 시드 데이터(필요할 때만)
db_seed:
build:
context: ./apps/web
dockerfile: Dockerfile
profiles: ["init"]
environment:
NODE_ENV: production
DATABASE_URL: ${DATABASE_URL}
entrypoint: ["/bin/bash", "-lc"]
command: ["node -e \"console.log('Seeding...')\" && npx prisma db seed --schema prisma/schema.prisma"]
networks:
- allprompt
networks:
allprompt:
driver: bridge
volumes:
caddy_data:
caddy_config:

View File

@@ -0,0 +1,94 @@
# Ubuntu 22.04 + Docker로 운영 배포 (prompt.ncue.net)
목표: `https://prompt.ncue.net` → Caddy(자동 HTTPS) → `web`(Next.js) → PostgreSQL(`ncue.net/all_prompt`)
## 1) 서버 사전 준비
### 1.1 DNS
- `prompt.ncue.net` A 레코드가 서버 공인 IP를 가리키도록 설정
### 1.2 방화벽
- TCP 80, 443 오픈
### 1.3 Docker 설치
```bash
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo $VERSION_CODENAME) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
```
## 2) 배포
```bash
sudo mkdir -p /opt/all-prompt
sudo chown -R $USER:$USER /opt/all-prompt
cd /opt/all-prompt
git clone <YOUR_REPO_URL> .
cp .env.example .env
nano .env
```
`.env` 필수:
- `DATABASE_URL`: `postgresql://ncue:<PASSWORD>@ncue.net:5432/all_prompt?schema=public`
- `CADDY_EMAIL`: 인증서 발급용 이메일
실행:
```bash
docker compose up -d --build
docker compose ps
```
확인:
- `https://prompt.ncue.net/api/health`
- `https://prompt.ncue.net/entities`
- `https://prompt.ncue.net/prompts`
## 3) 초기 스키마/시드(필요할 때만 1회)
> 원격 DB에 테이블이 없다면 실행.
```bash
docker compose --profile init run --rm db_init
docker compose --profile init run --rm db_seed
```
## 4) 운영 팁
### 로그
```bash
docker compose logs -f --tail=200 web
docker compose logs -f --tail=200 caddy
```
### 업데이트
```bash
git pull
docker compose up -d --build
```
### 인증서/설정 데이터
- Caddy 데이터는 도커 볼륨(`caddy_data`, `caddy_config`)에 유지됨

View File

@@ -0,0 +1,163 @@
# Ubuntu 22.04(22.14가 아니라 보통 22.04입니다) + systemd + Caddy로 모프(FastAPI) 배포
목표:
- `https://prompt.ncue.net` → Caddy(자동 HTTPS) → `uvicorn(FastAPI)`(localhost:8000)
- DB는 기본 **SQLite 파일**(서버 로컬) 사용
> 기존 저장소에는 Next.js/도커 배포 문서(`docs/deploy-ubuntu-docker.md`)가 있습니다.
> 이 문서는 **루트의 `main.py`(FastAPI 버전 모프)** 를 서버에 올리는 방법입니다.
---
## 0) 전제
- DNS: `prompt.ncue.net` A 레코드가 서버 공인 IP를 가리킴
- 방화벽: TCP 80/443 오픈
---
## 1) 서버 패키지 설치
```bash
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git
```
---
## 2) 코드 배치
```bash
sudo mkdir -p /opt/mopf
sudo mkdir -p /opt/mopf/data
sudo chown -R $USER:$USER /opt/mopf
cd /opt/mopf
git clone <YOUR_REPO_URL> .
```
---
## 3) 가상환경 + 의존성 설치
```bash
cd /opt/mopf
python3 -m venv venv
./venv/bin/pip install -r requirements.txt
```
---
## 4) systemd로 앱 상시 실행
1) 서비스 파일 복사
```bash
sudo cp /opt/mopf/ops/mopf.service /etc/systemd/system/mopf.service
```
2) 권한/유저 확인
- 기본 템플릿은 `www-data`로 실행합니다.
- `/opt/mopf``www-data`가 읽을 수 있어야 하고, DB 파일 디렉토리(`/opt/mopf/data`)는 쓰기가 가능해야 합니다.
```bash
sudo chown -R www-data:www-data /opt/mopf
sudo chmod -R 755 /opt/mopf
sudo chmod -R 775 /opt/mopf/data
```
3) 환경변수 수정(권장)
`/etc/systemd/system/mopf.service`에서 아래를 원하는 값으로 바꾸세요.
- `MOPF_DATABASE_URL=sqlite:////opt/mopf/data/all_prompt.db`
- `MOPF_SALT=CHANGE_ME_TO_RANDOM_STRING` ← 반드시 랜덤 문자열로 변경 권장
4) 실행/자동시작
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now mopf
sudo systemctl status mopf --no-pager
```
로그 확인:
```bash
journalctl -u mopf -f
```
---
## 5) Caddy로 HTTPS 리버스 프록시
### 옵션 A) Caddy를 “호스트에 직접 설치”(가장 간단)
```bash
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
```
Caddy 설정:
```bash
sudo cp /opt/mopf/ops/Caddyfile.mopf /etc/caddy/Caddyfile
sudo mkdir -p /etc/caddy
sudo nano /etc/caddy/Caddyfile
```
`CADDY_EMAIL`은 보통 systemd 환경변수로 넣습니다:
```bash
sudo systemctl edit caddy
```
내용 예시:
```ini
[Service]
Environment="CADDY_EMAIL=you@example.com"
```
적용/재시작:
```bash
sudo systemctl daemon-reload
sudo systemctl restart caddy
sudo systemctl status caddy --no-pager
```
### 옵션 B) “기존 docker-compose의 Caddy”를 그대로 쓴다면
이미 `docker-compose.yml`에서 Caddy가 80/443을 점유 중이면,
`Caddyfile``reverse_proxy web:3000``reverse_proxy 127.0.0.1:8000`으로 바꿔야 합니다.
---
## 6) 동작 확인
- `https://prompt.ncue.net/` 접속
- 프롬프트 등록: `https://prompt.ncue.net/new`
- 검색: `https://prompt.ncue.net/search?q=...`
서버 로컬에서 빠른 체크:
```bash
curl -I http://127.0.0.1:8000/
```
---
## 7) 업데이트(코드 갱신)
```bash
cd /opt/mopf
sudo -u www-data git pull
sudo systemctl restart mopf
```

445
main.py Normal file
View File

@@ -0,0 +1,445 @@
"""
main.py
FastAPI + Jinja2 + HTMX + SQLite(SQLAlchemy)로 만드는 초경량 프롬프트 공유 커뮤니티 "모프(all prompt)".
실행:
python main.py
핵심 UX:
- 회원가입/로그인 없음
- 닉네임은 쿠키에 저장(처음 프롬프트 등록 시 입력)
- 프롬프트: 탐색/등록/복사/좋아요
"""
from __future__ import annotations
import hashlib
import os
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any
import uvicorn
from fastapi import Depends, FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from sqlalchemy import func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from database import get_db, init_db_and_seed
from models import Category, Like, Prompt, User
APP_NAME = "all prompt"
APP_KO_NAME = "모프"
COOKIE_UID = "mopf_uid" # 브라우저별 고유 식별자(익명)
COOKIE_NICKNAME = "mopf_nickname"
# 식별자 해시에 사용하는 salt(로컬 MVP라 간단히). 운영이면 환경변수로 설정 권장.
IDENTIFIER_SALT = os.getenv("MOPF_SALT", "mopf-local-dev-salt")
PAGE_SIZE = 20
@asynccontextmanager
async def lifespan(app: FastAPI):
# migration 도구 없이 create_all + seed
init_db_and_seed()
yield
app = FastAPI(title=f"{APP_NAME} ({APP_KO_NAME})", lifespan=lifespan)
templates = Jinja2Templates(directory="templates")
# static 폴더가 비어 있어도 mount 가능
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.middleware("http")
async def ensure_uid_cookie(request: Request, call_next):
"""
브라우저마다 고유 uid 쿠키를 하나 발급합니다.
- 좋아요 중복 방지(쿠키 기반)
- 익명 닉네임 자동 생성 시에도 사용
"""
uid = request.cookies.get(COOKIE_UID)
response = await call_next(request)
if not uid:
uid = uuid.uuid4().hex
response.set_cookie(
key=COOKIE_UID,
value=uid,
max_age=60 * 60 * 24 * 365 * 5, # 5년
httponly=True,
samesite="lax",
)
return response
def _client_ip(request: Request) -> str:
"""
프록시 뒤일 수 있으니 X-Forwarded-For를 우선 사용합니다.
로컬 개발/소규모 서버용이므로 최소 구현만 합니다.
"""
xff = request.headers.get("x-forwarded-for")
if xff:
return xff.split(",")[0].strip()
if request.client:
return request.client.host
return "0.0.0.0"
def user_identifier(request: Request) -> str:
"""
쿠키 uid 또는 IP를 기반으로 식별자를 만들고, DB에는 해시만 저장합니다.
(요구사항: 쿠키 or IP 해시)
"""
raw = request.cookies.get(COOKIE_UID) or _client_ip(request)
digest = hashlib.sha256(f"{IDENTIFIER_SALT}:{raw}".encode("utf-8")).hexdigest()
return digest
def current_nickname(request: Request) -> str | None:
nick = request.cookies.get(COOKIE_NICKNAME)
if not nick:
return None
nick = nick.strip()
return nick or None
def _get_or_create_user(db: Session, nickname: str) -> None:
"""중복 nick은 unique 제약으로 방어하고, 실패 시 무시."""
if not nickname:
return
if db.query(User.id).filter(User.nickname == nickname).first():
return
db.add(User(nickname=nickname))
try:
db.commit()
except IntegrityError:
db.rollback()
def _like_container_html(prompt_id: int, liked: bool, like_count: int) -> str:
"""
HTMX가 교체할 작은 HTML 조각.
- 템플릿 파일을 추가하지 않기 위해(요구된 템플릿 4개 유지) 서버에서 간단히 생성.
- user 입력이 들어가지 않는 숫자/ID만 포함하므로 XSS 위험이 낮음.
"""
label = "좋아요" if not liked else "좋아요✓"
disabled = "disabled" if liked else ""
btn_classes = (
"px-3 py-1 rounded border text-sm "
+ ("bg-gray-100 text-gray-500 cursor-not-allowed" if liked else "bg-white hover:bg-gray-50")
)
return f"""
<div id="like-{prompt_id}">
<button
class="{btn_classes}"
hx-post="/like/{prompt_id}"
hx-target="#like-{prompt_id}"
hx-swap="outerHTML"
{disabled}
>
{label} <span class="font-semibold">({like_count})</span>
</button>
</div>
""".strip()
def _paginate(page: int) -> tuple[int, int]:
page = max(page, 1)
offset = (page - 1) * PAGE_SIZE
return page, offset
def _list_prompts(
db: Session,
request: Request,
*,
q: str | None,
page: int,
category_id: int | None,
) -> dict[str, Any]:
"""목록/검색 화면에서 공용으로 쓰는 조회 로직."""
page, offset = _paginate(page)
base = db.query(Prompt).join(Category, Prompt.category_id == Category.id)
if category_id:
base = base.filter(Prompt.category_id == category_id)
if q:
# SQLite에서 대소문자 차이를 줄이기 위해 lower+LIKE 사용(초경량 MVP).
q2 = q.strip()
if q2:
like = f"%{q2.lower()}%"
base = base.filter(
or_(
func.lower(Prompt.title).like(like),
func.lower(Prompt.content).like(like),
)
)
total = base.with_entities(func.count(Prompt.id)).scalar() or 0
prompts = (
base.order_by(Prompt.created_at.desc())
.offset(offset)
.limit(PAGE_SIZE)
.all()
)
prompt_ids = [p.id for p in prompts]
like_counts: dict[int, int] = {}
liked_ids: set[int] = set()
if prompt_ids:
like_counts_rows = (
db.query(Like.prompt_id, func.count(Like.id))
.filter(Like.prompt_id.in_(prompt_ids))
.group_by(Like.prompt_id)
.all()
)
like_counts = {pid: int(cnt) for pid, cnt in like_counts_rows}
ident = user_identifier(request)
liked_rows = (
db.query(Like.prompt_id)
.filter(Like.prompt_id.in_(prompt_ids), Like.user_identifier == ident)
.all()
)
liked_ids = {pid for (pid,) in liked_rows}
categories = db.query(Category).order_by(Category.name.asc()).all()
return {
"page": page,
"page_size": PAGE_SIZE,
"total": total,
"prompts": prompts,
"categories": categories,
"like_counts": like_counts,
"liked_ids": liked_ids,
"category_id": category_id,
"q": q or "",
"now": datetime.now(),
}
@app.get("/", response_class=HTMLResponse)
def index(
request: Request,
page: int = 1,
category: int | None = None,
db: Session = Depends(get_db),
):
ctx = _list_prompts(db, request, q=None, page=page, category_id=category)
ctx.update(
{
"request": request,
"app_name": APP_NAME,
"app_ko_name": APP_KO_NAME,
"nickname": current_nickname(request),
}
)
return templates.TemplateResponse("index.html", ctx)
@app.get("/search", response_class=HTMLResponse)
def search(
request: Request,
q: str = "",
page: int = 1,
category: int | None = None,
db: Session = Depends(get_db),
):
q = (q or "").strip()
ctx = _list_prompts(db, request, q=q, page=page, category_id=category)
ctx.update(
{
"request": request,
"app_name": APP_NAME,
"app_ko_name": APP_KO_NAME,
"nickname": current_nickname(request),
}
)
return templates.TemplateResponse("index.html", ctx)
@app.get("/prompt/{id}", response_class=HTMLResponse)
def prompt_detail(
request: Request,
id: int,
db: Session = Depends(get_db),
):
prompt = db.query(Prompt).filter(Prompt.id == id).first()
if not prompt:
raise HTTPException(status_code=404, detail="프롬프트를 찾을 수 없습니다.")
like_count = db.query(func.count(Like.id)).filter(Like.prompt_id == id).scalar() or 0
liked = (
db.query(Like.id)
.filter(Like.prompt_id == id, Like.user_identifier == user_identifier(request))
.first()
is not None
)
categories = db.query(Category).order_by(Category.name.asc()).all()
return templates.TemplateResponse(
"detail.html",
{
"request": request,
"app_name": APP_NAME,
"app_ko_name": APP_KO_NAME,
"nickname": current_nickname(request),
"prompt": prompt,
"categories": categories,
"like_count": like_count,
"liked": liked,
},
)
@app.get("/new", response_class=HTMLResponse)
def new_prompt_form(
request: Request,
db: Session = Depends(get_db),
):
categories = db.query(Category).order_by(Category.name.asc()).all()
return templates.TemplateResponse(
"new.html",
{
"request": request,
"app_name": APP_NAME,
"app_ko_name": APP_KO_NAME,
"nickname": current_nickname(request),
"categories": categories,
"error": None,
},
)
@app.post("/new")
def create_prompt(
request: Request,
title: str = Form(...),
content: str = Form(...),
category_id: int = Form(...),
description: str | None = Form(None),
nickname: str | None = Form(None),
db: Session = Depends(get_db),
):
title = (title or "").strip()
content = (content or "").strip()
description = (description or "").strip() if description is not None else None
if not title or not content:
categories = db.query(Category).order_by(Category.name.asc()).all()
return templates.TemplateResponse(
"new.html",
{
"request": request,
"app_name": APP_NAME,
"app_ko_name": APP_KO_NAME,
"nickname": current_nickname(request),
"categories": categories,
"error": "제목과 프롬프트 내용은 필수입니다.",
},
status_code=400,
)
# 닉네임: 쿠키에 있으면 우선, 없으면 폼 입력, 그래도 없으면 uid 기반 자동 생성
nick = current_nickname(request) or (nickname or "").strip()
uid = request.cookies.get(COOKIE_UID, "")
if not nick:
nick = f"익명-{uid[:6] or 'user'}"
# 카테고리 존재 여부 확인(잘못된 ID 방지)
cat = db.query(Category).filter(Category.id == category_id).first()
if not cat:
raise HTTPException(status_code=400, detail="카테고리가 올바르지 않습니다.")
# users 테이블에도 nickname을 기록(나중에 로그인 붙이기 쉬움)
_get_or_create_user(db, nick)
prompt = Prompt(
title=title,
content=content,
description=description or None,
category_id=category_id,
author_nickname=nick,
copy_count=0,
)
db.add(prompt)
db.commit()
db.refresh(prompt)
resp = RedirectResponse(url=f"/prompt/{prompt.id}", status_code=303)
# 닉네임 쿠키 저장(“로그인 없이 시작”)
if not current_nickname(request) and nick:
resp.set_cookie(
key=COOKIE_NICKNAME,
value=nick,
max_age=60 * 60 * 24 * 365 * 5,
httponly=False, # UI에 표시/폼 기본값에 사용
samesite="lax",
)
return resp
@app.post("/like/{id}", response_class=HTMLResponse)
def like_prompt(
request: Request,
id: int,
db: Session = Depends(get_db),
):
# 프롬프트 존재 확인
prompt_exists = db.query(Prompt.id).filter(Prompt.id == id).first()
if not prompt_exists:
raise HTTPException(status_code=404, detail="프롬프트를 찾을 수 없습니다.")
ident = user_identifier(request)
# 이미 좋아요가 있으면 그대로(중복 방지)
existing = db.query(Like.id).filter(Like.prompt_id == id, Like.user_identifier == ident).first()
if not existing:
db.add(Like(prompt_id=id, user_identifier=ident))
try:
db.commit()
except IntegrityError:
# 유니크 제약으로 중복이 걸릴 수 있으니 안전하게 처리
db.rollback()
like_count = db.query(func.count(Like.id)).filter(Like.prompt_id == id).scalar() or 0
liked = (
db.query(Like.id)
.filter(Like.prompt_id == id, Like.user_identifier == ident)
.first()
is not None
)
return HTMLResponse(_like_container_html(id, liked, int(like_count)))
@app.post("/copy/{id}")
def increment_copy_count(
request: Request,
id: int,
db: Session = Depends(get_db),
):
prompt = db.query(Prompt).filter(Prompt.id == id).first()
if not prompt:
raise HTTPException(status_code=404, detail="프롬프트를 찾을 수 없습니다.")
prompt.copy_count = int(prompt.copy_count or 0) + 1
db.commit()
return JSONResponse({"copy_count": int(prompt.copy_count)})
if __name__ == "__main__":
# 개발 편의상 uvicorn을 코드에서 직접 실행합니다.
# 포트 변경: `PORT=8001 python main.py`
port = int(os.getenv("PORT", "8000"))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)

89
models.py Normal file
View File

@@ -0,0 +1,89 @@
"""
models.py
요구사항의 테이블을 SQLAlchemy ORM 모델로 정의합니다.
테이블:
- users
- categories
- prompts
- likes
핵심 포인트:
- likes에는 (prompt_id, user_identifier) 유니크 제약을 걸어 "중복 좋아요"를 DB 레벨에서도 방지합니다.
- 프롬프트 검색은 title/content LIKE로 구현합니다(초경량 MVP 목적).
- 나중에 로그인(계정/세션)을 붙이기 쉽도록 users 테이블을 별도로 유지합니다.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
nickname: Mapped[str] = mapped_column(String(40), unique=True, index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
slug: Mapped[str] = mapped_column(String(60), unique=True, index=True, nullable=False)
prompts: Mapped[list["Prompt"]] = relationship(back_populates="category")
class Prompt(Base):
__tablename__ = "prompts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(120), index=True, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
category_id: Mapped[int] = mapped_column(ForeignKey("categories.id"), index=True, nullable=False)
# MVP에서는 로그인 없이 nickname 문자열로 작성자를 기록합니다.
author_nickname: Mapped[str] = mapped_column(String(40), index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True, nullable=False)
copy_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
category: Mapped["Category"] = relationship(back_populates="prompts")
likes: Mapped[list["Like"]] = relationship(back_populates="prompt", cascade="all, delete-orphan")
__table_args__ = (
# 정렬/필터가 잦은 필드 위주로 인덱스(SQLite에서도 도움).
Index("ix_prompts_category_created", "category_id", "created_at"),
)
class Like(Base):
__tablename__ = "likes"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
prompt_id: Mapped[int] = mapped_column(ForeignKey("prompts.id"), index=True, nullable=False)
# 쿠키 UUID 또는 IP 기반 식별자를 해시한 값(개인정보 최소화)
user_identifier: Mapped[str] = mapped_column(String(64), index=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
prompt: Mapped["Prompt"] = relationship(back_populates="likes")
__table_args__ = (
UniqueConstraint("prompt_id", "user_identifier", name="uq_likes_prompt_user"),
)

12
ops/Caddyfile.mopf Normal file
View File

@@ -0,0 +1,12 @@
{
# 인증서 발급용 이메일(서버에서 환경변수로 주는 것을 권장)
email {$CADDY_EMAIL}
}
prompt.ncue.net {
encode zstd gzip
# FastAPI(uvicorn)로 리버스 프록시
reverse_proxy 127.0.0.1:8000
}

29
ops/mopf.service Normal file
View File

@@ -0,0 +1,29 @@
[Unit]
Description=모프(all prompt) FastAPI 서비스
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/mopf
# venv 경로는 설치 방식에 맞게 수정
Environment="PATH=/opt/mopf/venv/bin"
# DB/식별자 salt (필수는 아님, 운영에서는 salt 변경 권장)
Environment="MOPF_DATABASE_URL=sqlite:////opt/mopf/data/all_prompt.db"
Environment="MOPF_SALT=CHANGE_ME_TO_RANDOM_STRING"
# uvicorn을 main.py 내부에서 실행하므로 python main.py로 실행
ExecStart=/opt/mopf/venv/bin/python /opt/mopf/main.py
Restart=always
RestartSec=2
# 로그는 journalctl로 확인
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
fastapi>=0.115
uvicorn>=0.30
jinja2>=3.1
sqlalchemy>=2.0
python-multipart>=0.0.9

1
static/.gitkeep Normal file
View File

@@ -0,0 +1 @@

59
templates/base.html Normal file
View File

@@ -0,0 +1,59 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ app_ko_name }} · {{ app_name }}</title>
<!-- Tailwind CDN (빌드 없음) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body class="bg-gray-50 text-gray-900">
<div class="min-h-screen">
<header class="sticky top-0 z-10 bg-white/90 backdrop-blur border-b">
<div class="max-w-3xl mx-auto px-4 py-3 flex items-center gap-3">
<a href="/" class="font-bold tracking-tight">
{{ app_ko_name }}
<span class="text-gray-400 font-normal text-sm">({{ app_name }})</span>
</a>
<div class="ml-auto flex items-center gap-2">
<form action="/search" method="get" class="flex items-center gap-2">
<input
name="q"
value="{{ q|default('') }}"
placeholder="제목/내용 검색"
class="w-40 sm:w-56 px-3 py-2 text-sm rounded border bg-white focus:outline-none focus:ring"
/>
{% if category_id %}
<input type="hidden" name="category" value="{{ category_id }}" />
{% endif %}
<button class="px-3 py-2 text-sm rounded bg-gray-900 text-white hover:bg-gray-800">
검색
</button>
</form>
<a href="/new" class="px-3 py-2 text-sm rounded border bg-white hover:bg-gray-50">
+ 등록
</a>
</div>
</div>
<div class="max-w-3xl mx-auto px-4 pb-3 text-xs text-gray-500">
{% if nickname %}
현재 닉네임: <span class="font-semibold text-gray-700">{{ nickname }}</span>
{% else %}
닉네임은 프롬프트 등록 시 자동 저장됩니다.
{% endif %}
</div>
</header>
<main class="max-w-3xl mx-auto px-4 py-6">
{% block content %}{% endblock %}
</main>
<footer class="max-w-3xl mx-auto px-4 py-10 text-xs text-gray-400">
초경량 프롬프트 커뮤니티 · 모프
</footer>
</div>
</body>
</html>

107
templates/detail.html Normal file
View File

@@ -0,0 +1,107 @@
{% extends "base.html" %}
{% block content %}
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h1 class="text-2xl font-bold leading-snug">{{ prompt.title }}</h1>
<div class="mt-2 text-xs text-gray-500 flex flex-wrap items-center gap-2">
<span class="px-2 py-0.5 rounded bg-gray-100 text-gray-700">
{% for c in categories %}
{% if c.id == prompt.category_id %}{{ c.name }}{% endif %}
{% endfor %}
</span>
<span>작성자: <span class="font-semibold text-gray-700">{{ prompt.author_nickname }}</span></span>
<span>복사 <span id="copy-count" class="font-semibold text-gray-700">{{ prompt.copy_count }}</span></span>
</div>
{% if prompt.description %}
<div class="mt-3 text-sm text-gray-700 whitespace-pre-wrap">{{ prompt.description }}</div>
{% endif %}
</div>
<div class="shrink-0 flex flex-col gap-2">
<div id="like-{{ prompt.id }}">
<button
class="px-3 py-2 rounded border text-sm {% if liked %}bg-gray-100 text-gray-500 cursor-not-allowed{% else %}bg-white hover:bg-gray-50{% endif %}"
hx-post="/like/{{ prompt.id }}"
hx-target="#like-{{ prompt.id }}"
hx-swap="outerHTML"
{% if liked %}disabled{% endif %}
>
{% if liked %}좋아요✓{% else %}좋아요{% endif %}
<span class="font-semibold">({{ like_count }})</span>
</button>
</div>
<button
id="copy-btn"
class="px-3 py-2 rounded bg-gray-900 text-white text-sm hover:bg-gray-800"
type="button"
>
복사
</button>
<a href="/new" class="px-3 py-2 rounded border bg-white text-sm hover:bg-gray-50 text-center">
+ 새 프롬프트
</a>
</div>
</div>
<div class="mt-6">
<div class="text-sm text-gray-600 mb-2">프롬프트</div>
<pre
id="prompt-content"
class="p-4 bg-white border rounded text-sm overflow-auto whitespace-pre-wrap leading-relaxed"
>{{ prompt.content }}</pre>
<div id="copy-toast" class="mt-2 text-xs text-gray-500 hidden">클립보드에 복사했습니다.</div>
</div>
<script>
(function () {
const btn = document.getElementById("copy-btn");
const pre = document.getElementById("prompt-content");
const toast = document.getElementById("copy-toast");
const copyCountEl = document.getElementById("copy-count");
async function incCopyCount() {
try {
const res = await fetch("/copy/{{ prompt.id }}", { method: "POST" });
const data = await res.json();
if (copyCountEl && typeof data.copy_count === "number") {
copyCountEl.textContent = String(data.copy_count);
}
} catch (e) {
// 실패해도 UX는 유지(복사 기능이 핵심)
}
}
async function copyText(text) {
// 최신 브라우저는 navigator.clipboard 사용
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
// HTTP/구형 환경 fallback
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
btn.addEventListener("click", async () => {
const text = pre.textContent || "";
try {
await copyText(text);
toast.classList.remove("hidden");
setTimeout(() => toast.classList.add("hidden"), 1200);
incCopyCount();
} catch (e) {
alert("복사에 실패했습니다. 다시 시도해주세요.");
}
});
})();
</script>
{% endblock %}

106
templates/index.html Normal file
View File

@@ -0,0 +1,106 @@
{% extends "base.html" %}
{% block content %}
<div class="flex items-baseline justify-between gap-3">
<div>
<div class="text-sm text-gray-500"><span class="font-semibold text-gray-800">{{ total }}</span></div>
{% if q %}
<div class="text-xs text-gray-500 mt-1">검색어: <span class="font-semibold text-gray-700">{{ q }}</span></div>
{% endif %}
</div>
<a href="/" class="text-sm text-gray-600 hover:text-gray-900 underline">전체 보기</a>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<a
href="{% if q %}/search?q={{ q|urlencode }}{% else %}/{% endif %}"
class="px-3 py-1 rounded-full text-sm border {% if not category_id %}bg-gray-900 text-white border-gray-900{% else %}bg-white hover:bg-gray-50{% endif %}"
>
전체
</a>
{% for c in categories %}
<a
href="{% if q %}/search?q={{ q|urlencode }}&category={{ c.id }}{% else %}/?category={{ c.id }}{% endif %}"
class="px-3 py-1 rounded-full text-sm border {% if category_id==c.id %}bg-gray-900 text-white border-gray-900{% else %}bg-white hover:bg-gray-50{% endif %}"
>
{{ c.name }}
</a>
{% endfor %}
</div>
<div class="mt-6 space-y-3">
{% if prompts|length == 0 %}
<div class="p-6 bg-white border rounded text-sm text-gray-600">
아직 프롬프트가 없습니다. <a class="underline" href="/new">첫 프롬프트를 등록</a>해보세요.
</div>
{% endif %}
{% for p in prompts %}
<div class="p-4 bg-white border rounded">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1">
<a href="/prompt/{{ p.id }}" class="text-lg font-semibold leading-snug hover:underline">
{{ p.title }}
</a>
<div class="mt-1 text-xs text-gray-500 flex flex-wrap items-center gap-2">
<span class="px-2 py-0.5 rounded bg-gray-100 text-gray-700">
{% for c in categories %}
{% if c.id == p.category_id %}{{ c.name }}{% endif %}
{% endfor %}
</span>
<span>작성자: <span class="font-semibold text-gray-700">{{ p.author_nickname }}</span></span>
<span>복사 <span class="font-semibold text-gray-700">{{ p.copy_count }}</span></span>
</div>
{% if p.description %}
<div class="mt-2 text-sm text-gray-700">{{ p.description }}</div>
{% endif %}
</div>
<div class="shrink-0">
<div id="like-{{ p.id }}">
{% set liked = (p.id in liked_ids) %}
{% set cnt = like_counts.get(p.id, 0) %}
<button
class="px-3 py-1 rounded border text-sm {% if liked %}bg-gray-100 text-gray-500 cursor-not-allowed{% else %}bg-white hover:bg-gray-50{% endif %}"
hx-post="/like/{{ p.id }}"
hx-target="#like-{{ p.id }}"
hx-swap="outerHTML"
{% if liked %}disabled{% endif %}
>
{% if liked %}좋아요✓{% else %}좋아요{% endif %}
<span class="font-semibold">({{ cnt }})</span>
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% set has_prev = page > 1 %}
{% set has_next = (page * page_size) < total %}
{% if has_prev or has_next %}
<div class="mt-6 flex items-center justify-between text-sm">
<div>
{% if has_prev %}
{% if q %}
<a class="underline" href="/search?q={{ q|urlencode }}&page={{ page-1 }}{% if category_id %}&category={{ category_id }}{% endif %}">← 이전</a>
{% else %}
<a class="underline" href="/?page={{ page-1 }}{% if category_id %}&category={{ category_id }}{% endif %}">← 이전</a>
{% endif %}
{% endif %}
</div>
<div class="text-gray-500">페이지 {{ page }}</div>
<div>
{% if has_next %}
{% if q %}
<a class="underline" href="/search?q={{ q|urlencode }}&page={{ page+1 }}{% if category_id %}&category={{ category_id }}{% endif %}">다음 →</a>
{% else %}
<a class="underline" href="/?page={{ page+1 }}{% if category_id %}&category={{ category_id }}{% endif %}">다음 →</a>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

81
templates/new.html Normal file
View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block content %}
<h1 class="text-xl font-bold">프롬프트 등록</h1>
<p class="mt-2 text-sm text-gray-600">
회원가입 없이 바로 등록할 수 있어요. 닉네임은 쿠키에 저장됩니다.
</p>
{% if error %}
<div class="mt-4 p-3 rounded border bg-red-50 text-sm text-red-700">
{{ error }}
</div>
{% endif %}
<form action="/new" method="post" class="mt-6 space-y-4">
<div>
<label class="block text-sm font-semibold mb-1">닉네임 (처음 1회만)</label>
<input
name="nickname"
value="{{ nickname|default('') }}"
placeholder="예: 민트초코"
class="w-full px-3 py-2 rounded border bg-white focus:outline-none focus:ring"
/>
<div class="mt-1 text-xs text-gray-500">비워도 되지만, 자동으로 익명 닉네임이 생성됩니다.</div>
</div>
<div>
<label class="block text-sm font-semibold mb-1">카테고리</label>
<select
name="category_id"
class="w-full px-3 py-2 rounded border bg-white focus:outline-none focus:ring"
required
>
{% for c in categories %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-semibold mb-1">제목</label>
<input
name="title"
placeholder="예: 회의록 요약 프롬프트"
class="w-full px-3 py-2 rounded border bg-white focus:outline-none focus:ring"
required
/>
</div>
<div>
<label class="block text-sm font-semibold mb-1">프롬프트 내용</label>
<textarea
name="content"
rows="10"
placeholder="여기에 프롬프트를 붙여넣으세요"
class="w-full px-3 py-2 rounded border bg-white focus:outline-none focus:ring font-mono text-sm"
required
></textarea>
</div>
<div>
<label class="block text-sm font-semibold mb-1">설명 (선택)</label>
<textarea
name="description"
rows="3"
placeholder="언제/어떻게 쓰면 좋은지, 주의점 등"
class="w-full px-3 py-2 rounded border bg-white focus:outline-none focus:ring text-sm"
></textarea>
</div>
<div class="flex items-center gap-2">
<button class="px-4 py-2 rounded bg-gray-900 text-white hover:bg-gray-800">
등록
</button>
<a href="/" class="px-4 py-2 rounded border bg-white hover:bg-gray-50">
취소
</a>
</div>
</form>
{% endblock %}