なごみ commited on
Commit
cec19d8
·
unverified ·
2 Parent(s): 0defbd11c4b972

Merge pull request #34 from nagomi-sugarheart/claude/add-comment-count-time-afn6i

Browse files
Files changed (3) hide show
  1. CLAUDE.md +1 -1
  2. main.py +37 -8
  3. templates/blog_detail.html +96 -18
CLAUDE.md CHANGED
@@ -164,7 +164,7 @@
164
  | **contentRich** | 本文(リッチ) | リッチテキストエリア | microCMS 管理画面直接編集用 |
165
  | **contentBlocks** | ブロックデータ | テキストエリア | ブロック配列 JSON(編集用) |
166
 
167
- ### 7.2 coment(リスト型・コメント)
168
  | フィールドID | 表示名 | データ型 | 用途 |
169
  | :--- | :--- | :--- | :--- |
170
  | **articleId** | 記事ID | テキストフィールド | 対象記事の ID |
 
164
  | **contentRich** | 本文(リッチ) | リッチテキストエリア | microCMS 管理画面直接編集用 |
165
  | **contentBlocks** | ブロックデータ | テキストエリア | ブロック配列 JSON(編集用) |
166
 
167
+ ### 7.2 comments(リスト型・コメント)
168
  | フィールドID | 表示名 | データ型 | 用途 |
169
  | :--- | :--- | :--- | :--- |
170
  | **articleId** | 記事ID | テキストフィールド | 対象記事の ID |
main.py CHANGED
@@ -166,14 +166,28 @@ def get_comments(article_id: str) -> list:
166
  except Exception:
167
  return []
168
 
169
- def post_comment_api(article_id: str, name: str, body: str) -> bool:
170
  try:
171
- r = http_requests.post(cms_url("comments"), headers=CMS_HEADERS_POST(),
172
- json={"articleId": article_id, "name": name, "body": body})
 
 
173
  return r.status_code in [200, 201]
174
  except Exception:
175
  return False
176
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  def migrate_comments(old_id: str, new_id: str) -> int:
178
  try:
179
  r = http_requests.get(cms_url("comments"), headers=CMS_HEADERS_GET(),
@@ -304,17 +318,17 @@ async def index(request: Request, id: str = None, page: int = 0,
304
  if not article:
305
  return templates.TemplateResponse(request, "blog_detail.html",
306
  {"article": None, "comments": []})
307
- comments = get_comments(id)
 
308
  base_url = SPACE_URL or str(request.base_url).rstrip("/")
309
- # contentBlocks が存在する場合はブロックから直接レンダリングし
310
- # microCMS の contentRich フィールドによるスタイル除去を回避する
311
  if article.get("contentBlocks"):
312
  rendered_content = render_blocks_to_html(blocks_from_article(article))
313
  else:
314
  rendered_content = clean_html(get_content(article))
315
  return templates.TemplateResponse(request, "blog_detail.html", {
316
  "article": article,
317
- "comments": comments,
 
318
  "content": rendered_content,
319
  "tags": get_tags(article),
320
  "space_url": base_url,
@@ -462,6 +476,9 @@ async def api_save(request: Request):
462
  was_draft = existing.get("title", "").startswith("【下書き】")
463
 
464
  if was_draft and becomes_public:
 
 
 
465
  http_requests.delete(f"{base_url}/{article_id}", headers=CMS_HEADERS_GET())
466
  r = http_requests.post(base_url, headers=CMS_HEADERS_POST(), json=post_data)
467
  if r.status_code in [200, 201]:
@@ -638,7 +655,19 @@ async def api_comment(request: Request):
638
  article_id = data.get("article_id", "")
639
  name = data.get("name", "わせます太郎").strip() or "わせます太郎"
640
  body = data.get("body", "").strip()
 
641
  if not body:
642
  return JSONResponse({"success": False, "error": "コメントを入力してください"})
643
- ok = post_comment_api(article_id, name, body)
644
  return JSONResponse({"success": ok})
 
 
 
 
 
 
 
 
 
 
 
 
166
  except Exception:
167
  return []
168
 
169
+ def post_comment_api(article_id: str, name: str, body: str, parent_id: str = "") -> bool:
170
  try:
171
+ payload = {"articleId": article_id, "name": name, "body": body}
172
+ if parent_id:
173
+ payload["parentId"] = parent_id
174
+ r = http_requests.post(cms_url("comments"), headers=CMS_HEADERS_POST(), json=payload)
175
  return r.status_code in [200, 201]
176
  except Exception:
177
  return False
178
 
179
+ def build_comment_tree(comments: list) -> list:
180
+ by_id = {c["id"]: {**c, "replies": []} for c in comments}
181
+ roots = []
182
+ for c in comments:
183
+ node = by_id[c["id"]]
184
+ pid = c.get("parentId", "")
185
+ if pid and pid in by_id:
186
+ by_id[pid]["replies"].append(node)
187
+ else:
188
+ roots.append(node)
189
+ return roots
190
+
191
  def migrate_comments(old_id: str, new_id: str) -> int:
192
  try:
193
  r = http_requests.get(cms_url("comments"), headers=CMS_HEADERS_GET(),
 
318
  if not article:
319
  return templates.TemplateResponse(request, "blog_detail.html",
320
  {"article": None, "comments": []})
321
+ flat_comments = get_comments(id)
322
+ comment_tree = build_comment_tree(flat_comments)
323
  base_url = SPACE_URL or str(request.base_url).rstrip("/")
 
 
324
  if article.get("contentBlocks"):
325
  rendered_content = render_blocks_to_html(blocks_from_article(article))
326
  else:
327
  rendered_content = clean_html(get_content(article))
328
  return templates.TemplateResponse(request, "blog_detail.html", {
329
  "article": article,
330
+ "comments": comment_tree,
331
+ "comment_count": len(flat_comments),
332
  "content": rendered_content,
333
  "tags": get_tags(article),
334
  "space_url": base_url,
 
476
  was_draft = existing.get("title", "").startswith("【下書き】")
477
 
478
  if was_draft and becomes_public:
479
+ likes_count = int(existing.get("likes", 0) or 0)
480
+ if likes_count:
481
+ post_data["likes"] = likes_count
482
  http_requests.delete(f"{base_url}/{article_id}", headers=CMS_HEADERS_GET())
483
  r = http_requests.post(base_url, headers=CMS_HEADERS_POST(), json=post_data)
484
  if r.status_code in [200, 201]:
 
655
  article_id = data.get("article_id", "")
656
  name = data.get("name", "わせます太郎").strip() or "わせます太郎"
657
  body = data.get("body", "").strip()
658
+ parent_id = data.get("parent_id", "")
659
  if not body:
660
  return JSONResponse({"success": False, "error": "コメントを入力してください"})
661
+ ok = post_comment_api(article_id, name, body, parent_id)
662
  return JSONResponse({"success": ok})
663
+
664
+ @app.post("/api/like/{article_id}")
665
+ async def api_like(article_id: str):
666
+ article = get_article(article_id)
667
+ if not article:
668
+ return JSONResponse({"success": False, "likes": 0})
669
+ current = int(article.get("likes", 0) or 0)
670
+ new_count = current + 1
671
+ r = http_requests.patch(cms_url(f"blog/{article_id}"), headers=CMS_HEADERS_POST(),
672
+ json={"likes": new_count})
673
+ return JSONResponse({"success": r.status_code in [200, 201], "likes": new_count})
templates/blog_detail.html CHANGED
@@ -20,6 +20,43 @@
20
  <meta name="twitter:title" content="{{ og_title }}">
21
  {% endif %}
22
  {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  {% block body %}
24
  <div class="max-w-2xl mx-auto px-4 py-6">
25
  <a href="/" class="inline-block mb-4 text-sm text-gray-500 dark:text-gray-400 hover:text-[#bf283a] dark:hover:text-[#f08090] transition">← 一覧に戻る</a>
@@ -29,12 +66,13 @@
29
  {% else %}
30
  {% set ec = article.get("eyecatch") %}
31
  {% set ec_url = ec.get("url","") if ec is mapping else (ec or "") %}
 
32
  {% if ec_url %}
33
  <img src="{{ ec_url }}" alt="" class="w-full h-64 object-cover rounded-xl mb-6">
34
  {% endif %}
35
 
36
  <h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">{{ article.get("title","無題") }}</h1>
37
- <p class="text-sm text-gray-400 dark:text-gray-500 mb-3">👤 {{ article.get("author","") }} | 📅 {{ article.get("publishedAt","")[:10] }} | <a href="#comments" class="hover:text-[#bf283a] dark:hover:text-[#f08090] transition">💬 {{ comments | length }}件</a></p>
38
  <div class="mb-4">
39
  {% for t in tags %}<span class="tag-badge">#{{ t }}</span>{% endfor %}
40
  </div>
@@ -43,9 +81,18 @@
43
  <div class="blog-content text-gray-800 dark:text-gray-200">{{ content | safe }}</div>
44
  <hr class="my-6 border-gray-200 dark:border-gray-700">
45
 
 
 
 
 
 
 
 
 
 
46
  <!-- シェア -->
47
  {% if space_url %}
48
- {% set share_link = space_url ~ "?id=" ~ article.get("id","") %}
49
  {% set share_text = article.get("title","") ~ " | わせます9期ブログ\n" ~ share_link %}
50
  <div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 mb-6 border dark:border-gray-700">
51
  <p class="text-sm font-bold text-gray-600 dark:text-gray-300 mb-3">🔗 この記事をシェア���る</p>
@@ -73,25 +120,19 @@
73
  <h2 class="text-lg font-bold text-gray-700 dark:text-gray-200 mb-4">💬 コメント</h2>
74
  {% if comments %}
75
  {% for c in comments %}
76
- <div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl p-4 mb-3">
77
- <div class="flex justify-between items-center mb-1">
78
- <span class="font-bold text-sm text-gray-700 dark:text-gray-200">{{ c.get("name","名無し") }}</span>
79
- <span class="text-xs text-gray-400 dark:text-gray-500">{{ c.get("createdAt","") | to_jst }}</span>
80
- </div>
81
- <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed">{{ c.get("body","") }}</p>
82
- </div>
83
  {% endfor %}
84
  {% else %}
85
  <p class="text-sm text-gray-400 dark:text-gray-500 mb-4">まだコメントはありません。</p>
86
  {% endif %}
87
 
88
- <div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl p-4">
89
  <h3 class="font-bold text-sm text-gray-700 dark:text-gray-200 mb-3">コメントを投稿</h3>
90
  <input id="comment-name" type="text" placeholder="名前(省略可)" maxlength="50"
91
  class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm mb-2 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099]">
92
  <textarea id="comment-body" placeholder="コメントを入力..." maxlength="500" rows="3"
93
  class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm mb-3 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099] resize-none"></textarea>
94
- <button onclick="submitComment('{{ article.get("id","") }}')"
95
  class="w-full bg-[#bf283a] text-white py-2 rounded-lg text-sm font-bold hover:bg-[#a01f2f] transition">投稿する</button>
96
  <p id="comment-msg" class="text-sm text-center mt-2 hidden"></p>
97
  </div>
@@ -99,22 +140,59 @@
99
  {% endif %}
100
  </div>
101
  <script>
102
- async function submitComment(articleId) {
103
- const name = document.getElementById('comment-name').value.trim();
104
- const body = document.getElementById('comment-body').value.trim();
105
- const msg = document.getElementById('comment-msg');
106
- if (!body) { showMsg(msg, 'コメントを入力してください', 'text-red-500'); return; }
 
 
107
  const res = await fetch('/api/comment', {
108
  method: 'POST', headers: {'Content-Type': 'application/json'},
109
- body: JSON.stringify({article_id: articleId, name, body})
110
  });
111
  const data = await res.json();
112
  if (data.success) { location.reload(); }
113
- else { showMsg(msg, '投稿に失敗しました', 'text-red-500'); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  }
 
115
  function showMsg(el, text, cls) {
116
  el.textContent = text; el.className = `text-sm text-center mt-2 ${cls}`; el.classList.remove('hidden');
117
  }
 
 
 
 
 
 
 
 
 
 
118
  </script>
119
  {% if article and 'twitter-tweet' in content %}
120
  <script async src="https://platform.twitter.com/widgets.js"></script>
 
20
  <meta name="twitter:title" content="{{ og_title }}">
21
  {% endif %}
22
  {% endblock %}
23
+
24
+ {% macro render_comment(c, article_id, depth) %}
25
+ <div class="{% if depth > 0 %}ml-5 pl-3 border-l-2 border-pink-100 dark:border-gray-600{% endif %} mb-3">
26
+ <div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl p-4">
27
+ <div class="flex justify-between items-center mb-1">
28
+ <span class="font-bold text-sm text-gray-700 dark:text-gray-200">{{ c.get("name","名無し") }}</span>
29
+ <span class="text-xs text-gray-400 dark:text-gray-500">{{ c.get("createdAt","") | to_jst }}</span>
30
+ </div>
31
+ <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">{{ c.get("body","") }}</p>
32
+ <button onclick="toggleReplyForm('{{ c.id }}')"
33
+ class="text-xs text-gray-400 dark:text-gray-500 mt-2 hover:text-[#bf283a] dark:hover:text-[#f08090] transition">
34
+ ↩ 返信する
35
+ </button>
36
+ <div id="reply-{{ c.id }}" class="hidden mt-3 space-y-2">
37
+ <input id="reply-name-{{ c.id }}" type="text" placeholder="名前(省略可)" maxlength="50"
38
+ class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099]">
39
+ <textarea id="reply-body-{{ c.id }}" placeholder="返信を入力..." maxlength="500" rows="2"
40
+ class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099] resize-none"></textarea>
41
+ <p id="reply-msg-{{ c.id }}" class="text-sm hidden"></p>
42
+ <div class="flex gap-2">
43
+ <button onclick="submitComment('{{ article_id }}', '{{ c.id }}')"
44
+ class="flex-1 bg-[#bf283a] text-white py-1.5 rounded-lg text-sm font-bold hover:bg-[#a01f2f] transition">
45
+ 返信する
46
+ </button>
47
+ <button onclick="toggleReplyForm('{{ c.id }}')"
48
+ class="px-3 border dark:border-gray-600 text-gray-500 dark:text-gray-400 py-1.5 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition">
49
+ キャンセル
50
+ </button>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ {% for reply in c.replies %}
55
+ {{ render_comment(reply, article_id, depth + 1) }}
56
+ {% endfor %}
57
+ </div>
58
+ {% endmacro %}
59
+
60
  {% block body %}
61
  <div class="max-w-2xl mx-auto px-4 py-6">
62
  <a href="/" class="inline-block mb-4 text-sm text-gray-500 dark:text-gray-400 hover:text-[#bf283a] dark:hover:text-[#f08090] transition">← 一覧に戻る</a>
 
66
  {% else %}
67
  {% set ec = article.get("eyecatch") %}
68
  {% set ec_url = ec.get("url","") if ec is mapping else (ec or "") %}
69
+ {% set article_id = article.get("id","") %}
70
  {% if ec_url %}
71
  <img src="{{ ec_url }}" alt="" class="w-full h-64 object-cover rounded-xl mb-6">
72
  {% endif %}
73
 
74
  <h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">{{ article.get("title","無題") }}</h1>
75
+ <p class="text-sm text-gray-400 dark:text-gray-500 mb-3">👤 {{ article.get("author","") }} | 📅 {{ article.get("publishedAt","")[:10] }} | <a href="#comments" class="hover:text-[#bf283a] dark:hover:text-[#f08090] transition">💬 {{ comment_count }}件</a></p>
76
  <div class="mb-4">
77
  {% for t in tags %}<span class="tag-badge">#{{ t }}</span>{% endfor %}
78
  </div>
 
81
  <div class="blog-content text-gray-800 dark:text-gray-200">{{ content | safe }}</div>
82
  <hr class="my-6 border-gray-200 dark:border-gray-700">
83
 
84
+ <!-- いいね -->
85
+ <div class="flex justify-center mb-8">
86
+ <button id="like-btn" onclick="likeArticle('{{ article_id }}')"
87
+ class="flex flex-col items-center gap-1 px-8 py-4 rounded-2xl border-2 border-[#e09099] dark:border-[#c07080] hover:bg-[#fdf0f2] dark:hover:bg-[#3d1a20] transition group">
88
+ <span id="like-icon" class="text-3xl transition-transform group-hover:scale-110">♡</span>
89
+ <span id="like-count" class="text-sm font-bold text-[#bf283a] dark:text-[#f08090]">{{ article.get("likes", 0) or 0 }}</span>
90
+ </button>
91
+ </div>
92
+
93
  <!-- シェア -->
94
  {% if space_url %}
95
+ {% set share_link = space_url ~ "?id=" ~ article_id %}
96
  {% set share_text = article.get("title","") ~ " | わせます9期ブログ\n" ~ share_link %}
97
  <div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 mb-6 border dark:border-gray-700">
98
  <p class="text-sm font-bold text-gray-600 dark:text-gray-300 mb-3">🔗 この記事をシェア���る</p>
 
120
  <h2 class="text-lg font-bold text-gray-700 dark:text-gray-200 mb-4">💬 コメント</h2>
121
  {% if comments %}
122
  {% for c in comments %}
123
+ {{ render_comment(c, article_id, 0) }}
 
 
 
 
 
 
124
  {% endfor %}
125
  {% else %}
126
  <p class="text-sm text-gray-400 dark:text-gray-500 mb-4">まだコメントはありません。</p>
127
  {% endif %}
128
 
129
+ <div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl p-4 mt-4">
130
  <h3 class="font-bold text-sm text-gray-700 dark:text-gray-200 mb-3">コメントを投稿</h3>
131
  <input id="comment-name" type="text" placeholder="名前(省略可)" maxlength="50"
132
  class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm mb-2 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099]">
133
  <textarea id="comment-body" placeholder="コメントを入力..." maxlength="500" rows="3"
134
  class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm mb-3 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099] resize-none"></textarea>
135
+ <button onclick="submitComment('{{ article_id }}')"
136
  class="w-full bg-[#bf283a] text-white py-2 rounded-lg text-sm font-bold hover:bg-[#a01f2f] transition">投稿する</button>
137
  <p id="comment-msg" class="text-sm text-center mt-2 hidden"></p>
138
  </div>
 
140
  {% endif %}
141
  </div>
142
  <script>
143
+ async function submitComment(articleId, parentId = '') {
144
+ const nameEl = parentId ? document.getElementById(`reply-name-${parentId}`) : document.getElementById('comment-name');
145
+ const bodyEl = parentId ? document.getElementById(`reply-body-${parentId}`) : document.getElementById('comment-body');
146
+ const msgEl = parentId ? document.getElementById(`reply-msg-${parentId}`) : document.getElementById('comment-msg');
147
+ const name = nameEl.value.trim();
148
+ const body = bodyEl.value.trim();
149
+ if (!body) { showMsg(msgEl, 'コメントを入力してください', 'text-red-500'); return; }
150
  const res = await fetch('/api/comment', {
151
  method: 'POST', headers: {'Content-Type': 'application/json'},
152
+ body: JSON.stringify({article_id: articleId, name, body, parent_id: parentId})
153
  });
154
  const data = await res.json();
155
  if (data.success) { location.reload(); }
156
+ else { showMsg(msgEl, '投稿に失敗しました', 'text-red-500'); }
157
+ }
158
+
159
+ function toggleReplyForm(commentId) {
160
+ const el = document.getElementById(`reply-${commentId}`);
161
+ el.classList.toggle('hidden');
162
+ if (!el.classList.contains('hidden')) {
163
+ el.querySelector('textarea').focus();
164
+ }
165
+ }
166
+
167
+ async function likeArticle(articleId) {
168
+ const key = `liked_${articleId}`;
169
+ if (localStorage.getItem(key)) return;
170
+ const btn = document.getElementById('like-btn');
171
+ btn.disabled = true;
172
+ const res = await fetch(`/api/like/${articleId}`, { method: 'POST' });
173
+ const data = await res.json();
174
+ if (data.success) {
175
+ localStorage.setItem(key, '1');
176
+ document.getElementById('like-icon').textContent = '❤️';
177
+ document.getElementById('like-count').textContent = data.likes;
178
+ btn.classList.add('bg-[#fdf0f2]', 'dark:bg-[#3d1a20]');
179
+ }
180
+ btn.disabled = false;
181
  }
182
+
183
  function showMsg(el, text, cls) {
184
  el.textContent = text; el.className = `text-sm text-center mt-2 ${cls}`; el.classList.remove('hidden');
185
  }
186
+
187
+ // ページ読み込み時:すでにいいね済みなら見た目を反映
188
+ (function() {
189
+ const articleId = '{{ article.get("id","") if article else "" }}';
190
+ if (articleId && localStorage.getItem(`liked_${articleId}`)) {
191
+ document.getElementById('like-icon').textContent = '❤️';
192
+ const btn = document.getElementById('like-btn');
193
+ if (btn) btn.classList.add('bg-[#fdf0f2]', 'dark:bg-[#3d1a20]');
194
+ }
195
+ })();
196
  </script>
197
  {% if article and 'twitter-tweet' in content %}
198
  <script async src="https://platform.twitter.com/widgets.js"></script>