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

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

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

59
templates/base.html Normal file
View File

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

107
templates/detail.html Normal file
View File

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

106
templates/index.html Normal file
View File

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

81
templates/new.html Normal file
View File

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