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:
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