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:
18
.env.example
Normal file
18
.env.example
Normal 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
26
.gitignore
vendored
Normal 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
9
Caddyfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
email {$CADDY_EMAIL}
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt.ncue.net {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy web:3000
|
||||||
|
}
|
||||||
|
|
||||||
125
PROMPT.txt
Normal file
125
PROMPT.txt
Normal 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
56
README.md
Normal 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
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;
|
||||||
|
|
||||||
88
database.py
Normal file
88
database.py
Normal 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
69
docker-compose.yml
Normal 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:
|
||||||
|
|
||||||
94
docs/deploy-ubuntu-docker.md
Normal file
94
docs/deploy-ubuntu-docker.md
Normal 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`)에 유지됨
|
||||||
|
|
||||||
163
docs/deploy-ubuntu-fastapi.md
Normal file
163
docs/deploy-ubuntu-fastapi.md
Normal 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
445
main.py
Normal 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
89
models.py
Normal 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
12
ops/Caddyfile.mopf
Normal 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
29
ops/mopf.service
Normal 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
6
requirements.txt
Normal 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
1
static/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
59
templates/base.html
Normal file
59
templates/base.html
Normal 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
107
templates/detail.html
Normal 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
106
templates/index.html
Normal 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
81
templates/new.html
Normal 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 %}
|
||||||
|
|
||||||
Reference in New Issue
Block a user