9thblogs / app.py
Nagomi-SugarHeart's picture
Update app.py
223bbb9 verified
Raw
History Blame
35.4 kB
import streamlit as st
import google.generativeai as genai
import requests
import os
import re
import io
import time
from PIL import Image
GEMINI_KEY = os.environ.get("GEMINI_API_KEY")
CMS_SERVICE_ID = os.environ.get("MICROCMS_SERVICE_ID")
CMS_API_KEY = os.environ.get("MICROCMS_API_KEY")
ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "1234")
AUTHOR_LIST = ["にき", "かん", "なごみ", "蜜柑餅"]
SPACE_URL = "https://nagomi-sugarheart-wasemas9thblogs.hf.space"
ARTICLES_PER_PAGE = 10
TEMPLATES = {
"テンプレートなし": "",
"📅 イベントレポート": "<h2>イベント概要</h2><p>イベント名:<br>日時:<br>場所:</p><h2>内容</h2><p>ここに内容を書いてください。</p><h2>感想</h2><p>ここに感想を書いてください。</p>",
"📝 日常記事": "<h2>はじめに</h2><p>ここに導入を書いてください。</p><h2>本文</h2><p>ここに本文を書いてください。</p><h2>おわりに</h2><p>ここにまとめを書いてください。</p>",
"🎵 ライブ・コンサートレポート": "<h2>公演情報</h2><p>公演名:<br>日時:<br>会場:<br>出演:</p><h2>セットリスト</h2><p>1. <br>2. <br>3. </p><h2>見どころ・感想</h2><p>ここに感想を書いてください。</p>",
"📣 お知らせ": "<h2>お知らせ</h2><p>ここにお知らせ内容を書いてください。</p><h2>詳細</h2><p>日時:<br>場所:<br>参加方法:</p>",
}
if GEMINI_KEY:
genai.configure(api_key=GEMINI_KEY)
st.markdown("""
<style>
.ql-toolbar {
position: sticky !important;
top: 0 !important;
z-index: 999 !important;
background: white !important;
}
input, textarea, select {
font-size: 16px !important;
}
/* 戻るボタンや続きを読むのリンク装飾 */
.nav-link {
display: inline-block;
padding: 8px 16px;
background-color: #f0f2f6;
color: #31333F;
text-decoration: none;
border-radius: 8px;
font-size: 14px;
margin-bottom: 16px;
border: 1px solid #e0e0e0;
}
.nav-link:hover {
background-color: #e0e4eb;
color: #31333F;
}
.read-more {
display: inline-block;
margin-top: 8px;
font-size: 14px;
color: #0068c9;
text-decoration: none;
font-weight: bold;
}
.read-more:hover {
text-decoration: underline;
}
</style>
""", unsafe_allow_html=True)
def clean_ai_html(text):
text = re.sub(r'```html?', '', text, flags=re.IGNORECASE)
text = text.replace("```", "")
return text.strip()
def clean_quill_html(html):
html = re.sub(r'<figure>', '<div style="margin:8px 0;">', html)
html = re.sub(r'</figure>', '</div>', html)
html = re.sub(r'(<p>\s*<br\s*/?>\s*</p>)(\s*<p>\s*<img)', r'\2', html)
html = re.sub(r'(<p>\s*<img[^>]*/?>\s*</p>)(\s*<p>\s*<br\s*/?>\s*</p>)', r'\1', html)
html = re.sub(r'(<p>\s*<br\s*/?>\s*</p>)(\s*<img)', r'\2', html)
html = re.sub(r'(<img[^>]*/?>)(\s*<p>\s*<br\s*/?>\s*</p>)', r'\1', html)
html = re.sub(r'(<p>\s*<br\s*/?>\s*</p>)(\s*<div)', r'\2', html)
html = re.sub(r'(</div>)(\s*<p>\s*<br\s*/?>\s*</p>)', r'\1', html)
return html.strip()
def strip_html(html):
return re.sub(r'<[^>]*?>', '', html)
def make_snippet(content_html):
paragraphs = re.split(r'</p>|</h[1-6]>|<br\s*/?>', content_html)
for p in paragraphs:
text = strip_html(p).strip()
if text:
return text[:60] + "..." if len(text) > 60 else text
return ""
# スクロールバーのネストを解消し、自然なページ拡張を行うHTMLレンダリング関数
def render_html_native(html):
wrapped = f"""
<div class="blog-content">
<style>
.blog-content {{ font-family: sans-serif; font-size: 16px; color: #111; word-break: break-word; line-height: 1.6; }}
.blog-content img {{ max-width: 100%; height: auto; display: block; border-radius: 8px; margin: 16px 0; }}
.blog-content p {{ margin: 0.8em 0; }}
.blog-content h1, .blog-content h2, .blog-content h3 {{ margin: 1.2em 0 0.6em; border-bottom: 1px solid #eee; padding-bottom: 4px; }}
.blog-content a {{ color: #0068c9; text-decoration: none; }}
.blog-content a:hover {{ text-decoration: underline; }}
.blog-content blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 16px; color: #555; }}
</style>
{html}
</div>
"""
st.markdown(wrapped, unsafe_allow_html=True)
def get_tags(article):
tags_str = article.get("tags", "")
return [t.strip() for t in tags_str.split(',') if t.strip()] if tags_str else []
def render_tag_badges(tags):
if not tags:
return
badges = ' '.join([
f'<span style="background:#e8f4f8;border-radius:12px;padding:2px 10px;'
f'font-size:12px;margin:2px;display:inline-block;color:#2c7bb6;">#{t}</span>'
for t in tags
])
st.markdown(badges, unsafe_allow_html=True)
def upload_image_to_microcms(image_file, max_width=1200):
try:
img = Image.open(image_file)
try:
import PIL.ExifTags
exif = img._getexif()
if exif:
for tag, val in exif.items():
if PIL.ExifTags.TAGS.get(tag) == 'Orientation':
if val == 3: img = img.rotate(180, expand=True)
elif val == 6: img = img.rotate(270, expand=True)
elif val == 8: img = img.rotate(90, expand=True)
except:
pass
if img.width > max_width:
ratio = max_width / img.width
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
buf = io.BytesIO()
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buf, format="JPEG", quality=85)
buf.seek(0)
url = f"https://{CMS_SERVICE_ID}.microcms-management.io/api/v1/media"
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
files = {"file": ("upload.jpg", buf, "image/jpeg")}
res = requests.post(url, headers=headers, files=files)
if res.status_code not in [200, 201]:
st.error(f"アップロードエラー詳細: {res.status_code} / {res.text}")
return None
return res.json().get("url")
except Exception as e:
st.error(f"画像アップロードエラー: {e}")
return None
@st.cache_data(ttl=60)
def get_articles(limit=ARTICLES_PER_PAGE, offset=0):
if not CMS_SERVICE_ID or not CMS_API_KEY:
return [], 0
url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
try:
response = requests.get(url, headers=headers, params={"limit": limit, "offset": offset})
response.raise_for_status()
data = response.json()
return data.get("contents", []), data.get("totalCount", 0)
except:
return [], 0
@st.cache_data(ttl=60)
def get_all_articles():
if not CMS_SERVICE_ID or not CMS_API_KEY:
return []
url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
try:
response = requests.get(url, headers=headers, params={"limit": 100})
response.raise_for_status()
return response.json().get("contents", [])
except:
return []
def get_comments(article_id):
if not CMS_SERVICE_ID or not CMS_API_KEY:
return []
url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/comments"
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
try:
response = requests.get(url, headers=headers, params={
"filters": f"articleId[equals]{article_id}",
"limit": 100,
"orders": "createdAt",
})
response.raise_for_status()
return response.json().get("contents", [])
except:
return []
def post_comment(article_id, name, body):
if not CMS_SERVICE_ID or not CMS_API_KEY:
return False
url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/comments"
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY, "Content-Type": "application/json"}
try:
res = requests.post(url, headers=headers, json={"articleId": article_id, "name": name, "body": body})
return res.status_code in [200, 201]
except:
return False
params = st.query_params
if "id" in params:
article_id = params["id"]
all_articles = get_all_articles()
article = next((a for a in all_articles if a["id"] == article_id), None)
if article:
# ブラウザの「戻る」が機能するリンク式の戻るボタン
st.markdown('<a href="?" target="_self" class="nav-link">← 一覧に戻る</a>', unsafe_allow_html=True)
eyecatch = article.get("eyecatch")
if eyecatch and isinstance(eyecatch, dict):
st.image(eyecatch.get("url", ""), use_container_width=True)
title_text = article.get("title", "無題")
st.title(title_text)
st.caption(f"👤 {article.get('author')} | 📅 {article.get('publishedAt')[:10]}")
article_tags = get_tags(article)
render_tag_badges(article_tags)
st.divider()
content = clean_quill_html(article.get("content", ""))
# スクロールバーなしでネイティブ描画
render_html_native(content)
st.divider()
# 共有リンクの改善 (コピーしやすい形式へ)
share_url = f"{SPACE_URL}?id={article_id}"
share_text = f"【記事】{title_text}\n{share_url}"
st.write("🔗 **この記事をシェアする** (右上のコピーボタンを押してください)")
st.code(share_text, language="")
st.divider()
st.subheader("💬 コメント")
comments = get_comments(article_id)
if comments:
for c in comments:
c_name = c.get("name", "名無し")
c_body = c.get("body", "")
c_date = c.get("createdAt", "")[:10]
st.markdown(f"**{c_name}** · {c_date}")
st.write(c_body)
st.divider()
else:
st.caption("まだコメントはありません。")
st.subheader("コメントを投稿")
with st.form("comment_form", clear_on_submit=True):
comment_name = st.text_input("お名前", max_chars=50)
comment_body = st.text_area("コメント", max_chars=500, height=120)
submitted = st.form_submit_button("投稿する", use_container_width=True)
if submitted:
if not comment_name.strip():
st.error("お名前を入力してください")
elif not comment_body.strip():
st.error("コメントを入力してください")
else:
if post_comment(article_id, comment_name.strip(), comment_body.strip()):
st.success("投稿しました!")
st.rerun()
else:
st.error("投稿に失敗しました。時間をおいて再試行してください。")
else:
st.error("記事が見つかりませんでした。")
st.markdown('<a href="?" target="_self" class="nav-link">← 一覧に戻る</a>', unsafe_allow_html=True)
else:
with st.sidebar:
st.title("⚙️ 設定")
model_choice = st.selectbox("使用するAIモデル", ["gemini-2.5-flash", "gemini-2.5-pro"], index=0)
show_drafts_in_view = st.checkbox("閲覧画面で下書きも表示する", value=False)
tab_view, tab_manage = st.tabs(["📖 ブログを読む", "🛠️ 記事の管理・投稿"])
with tab_view:
st.title("サークル活動ブログ")
all_articles = get_all_articles()
all_tags = sorted(set(tag for a in all_articles for tag in get_tags(a)))
search_col1, search_col2, search_col3 = st.columns(3)
with search_col1:
search_query = st.text_input("🔍 本文・タイトル検索", "", key="search_text")
with search_col2:
author_filter = st.selectbox("👤 著者で絞り込み", ["全員"] + AUTHOR_LIST, key="search_author")
with search_col3:
tag_filter = st.selectbox("🏷️ タグで絞り込み", ["全タグ"] + all_tags, key="tag_filter")
if "view_page" not in st.session_state: st.session_state.view_page = 0
if "last_search" not in st.session_state: st.session_state.last_search = ""
if search_query != st.session_state.last_search:
st.session_state.view_page = 0
st.session_state.last_search = search_query
filtered_articles = []
for a in all_articles:
title = a.get("title", "無題")
content_html = a.get("content", "")
author = a.get("author", "不明")
if title.startswith("【下書き】") and not show_drafts_in_view:
continue
article_tags = get_tags(a)
clean_text = strip_html(content_html)
author_match = (author_filter == "全員") or (author_filter == author)
text_match = (not search_query) or (
search_query.lower() in title.lower() or search_query.lower() in clean_text.lower()
)
tag_match = (tag_filter == "全タグ") or (tag_filter in article_tags)
if author_match and text_match and tag_match:
filtered_articles.append(a)
total_count = len(filtered_articles)
total_pages = max(1, -(-total_count // ARTICLES_PER_PAGE))
if st.session_state.view_page >= total_pages:
st.session_state.view_page = 0
offset = st.session_state.view_page * ARTICLES_PER_PAGE
page_articles = filtered_articles[offset:offset + ARTICLES_PER_PAGE]
for a in page_articles:
title = a.get("title", "無題")
content_html = a.get("content", "")
author = a.get("author", "不明")
pub_date = a.get("publishedAt", "-----")[:10]
eyecatch = a.get("eyecatch")
snippet = make_snippet(content_html)
article_tags = get_tags(a)
article_url_params = f"?id={a['id']}"
with st.container():
# サムネイル画像のレイアウト(1:3の比率で小さく表示)
if eyecatch and isinstance(eyecatch, dict):
col_img, col_txt = st.columns([1, 3])
with col_img:
st.image(eyecatch.get("url", ""), use_container_width=True)
with col_txt:
st.subheader(title)
st.caption(f"👤 {author} | 📅 {pub_date}")
render_tag_badges(article_tags)
st.write(f"<div style='color:#555; font-size:14px; margin: 8px 0;'>{snippet}</div>", unsafe_allow_html=True)
st.markdown(f'<a href="{article_url_params}" target="_self" class="read-more">続きを読む →</a>', unsafe_allow_html=True)
else:
st.subheader(title)
st.caption(f"👤 {author} | 📅 {pub_date}")
render_tag_badges(article_tags)
st.write(f"<div style='color:#555; font-size:14px; margin: 8px 0;'>{snippet}</div>", unsafe_allow_html=True)
st.markdown(f'<a href="{article_url_params}" target="_self" class="read-more">続きを読む →</a>', unsafe_allow_html=True)
st.divider()
if total_count > ARTICLES_PER_PAGE:
st.caption(f"{total_count}件中 {offset+1}{min(offset+ARTICLES_PER_PAGE, total_count)}件を表示")
col_prev, col_page, col_next = st.columns([1, 2, 1])
with col_prev:
if st.session_state.view_page > 0:
if st.button("← 前へ", use_container_width=True):
st.session_state.view_page -= 1
st.rerun()
with col_page:
st.markdown(
f"<p style='text-align:center;'>{st.session_state.view_page + 1} / {total_pages} ページ</p>",
unsafe_allow_html=True
)
with col_next:
if st.session_state.view_page < total_pages - 1:
if st.button("次へ →", use_container_width=True):
st.session_state.view_page += 1
st.rerun()
with tab_manage:
st.title("管理画面")
password = st.text_input("合言葉", type="password")
if password == ADMIN_PASSWORD:
st.success("ログイン成功!")
if "edit_id" not in st.session_state: st.session_state.edit_id = None
if "edit_data" not in st.session_state: st.session_state.edit_data = {"title": "", "author": AUTHOR_LIST[0], "content": ""}
if "edit_tags" not in st.session_state: st.session_state.edit_tags = []
if "editor_mode" not in st.session_state: st.session_state.editor_mode = "rich"
if "working_html" not in st.session_state: st.session_state.working_html = ""
if "eyecatch_url" not in st.session_state: st.session_state.eyecatch_url = ""
if "inline_image_urls" not in st.session_state: st.session_state.inline_image_urls = []
if "delete_confirm_id" not in st.session_state: st.session_state.delete_confirm_id = None
if "quill_key" not in st.session_state: st.session_state.quill_key = 0
# タグ入力欄のステート初期化
if "tags_input_field" not in st.session_state: st.session_state.tags_input_field = ""
st.divider()
mode_label = f"📝 記事の編集 (ID: {st.session_state.edit_id})" if st.session_state.edit_id else "✍️ 新規記事の投稿"
st.subheader(mode_label)
col_t, col_a = st.columns([2, 1])
with col_t:
title_input = st.text_input("記事タイトル", value=st.session_state.edit_data["title"])
with col_a:
try:
def_auth_idx = AUTHOR_LIST.index(st.session_state.edit_data["author"])
except:
def_auth_idx = 0
author_select = st.selectbox("著者", AUTHOR_LIST, index=def_auth_idx)
tags_input = st.text_input(
"🏷️ タグ(カンマ区切り)",
placeholder="例: イベント, 日常, ライブ",
key="tags_input_field"
)
st.divider()
st.subheader("📋 テンプレート")
template_choice = st.selectbox("テンプレートを選択", list(TEMPLATES.keys()), key="template_select")
if st.button("✅ このテンプレートを適用"):
selected_html = TEMPLATES[template_choice]
if selected_html:
st.session_state.working_html = selected_html
st.session_state.quill_key += 1
st.rerun()
else:
st.warning("テンプレートなしが選択されています")
st.divider()
st.subheader("🖼️ キャッチ画像(サムネイル)")
if st.session_state.eyecatch_url:
st.image(st.session_state.eyecatch_url, width=300)
if st.button("🗑️ キャッチ画像を削除"):
st.session_state.eyecatch_url = ""
st.rerun()
else:
eyecatch_file = st.file_uploader("キャッチ画像をアップロード(JPG/PNG)", type=["jpg", "jpeg", "png"], key="eyecatch_uploader")
if eyecatch_file:
col_ec1, col_ec2 = st.columns(2)
with col_ec1:
ec_max_w = st.number_input("最大横幅(px)", min_value=200, max_value=2000, value=1200, step=100, key="ec_maxw")
with col_ec2:
st.write("")
if st.button("☁️ アップロード", key="ec_upload_btn"):
with st.spinner("アップロード中..."):
url = upload_image_to_microcms(eyecatch_file, max_width=int(ec_max_w))
if url:
st.session_state.eyecatch_url = url
st.success("アップロード成功!")
st.rerun()
st.divider()
st.subheader("📷 本文内差し込み画像")
st.caption("アップロード後、ボタンでエディタに挿入またはHTMLタグをコピーしてください")
inline_files = st.file_uploader("差し込み画像(複数選択可)", type=["jpg", "jpeg", "png"], accept_multiple_files=True, key="inline_uploader")
col_in1, col_in2 = st.columns(2)
with col_in1:
inline_max_w = st.number_input("最大横幅(px)", min_value=200, max_value=2000, value=900, step=100, key="inline_maxw")
with col_in2:
st.write("")
if st.button("☁️ 選択画像を一括アップロード", key="inline_upload_btn"):
if inline_files:
new_urls = []
for f in inline_files:
with st.spinner(f"{f.name} をアップロード中..."):
url = upload_image_to_microcms(f, max_width=int(inline_max_w))
if url:
new_urls.append({"name": f.name, "url": url, "max_w": int(inline_max_w)})
st.session_state.inline_image_urls.extend(new_urls)
if new_urls:
st.success(f"{len(new_urls)}枚アップロード完了!")
st.rerun()
else:
st.warning("画像を選択してください")
if st.session_state.inline_image_urls:
st.markdown("**アップロード済み差し込み画像**")
for i, img_info in enumerate(st.session_state.inline_image_urls):
max_w = img_info.get("max_w", 900)
img_tag = f'<img src="{img_info["url"]}" alt="{img_info["name"]}" style="width:{max_w}px;max-width:100%;height:auto;display:block;" />'
with st.container():
col_il1, col_il2 = st.columns([1, 2])
with col_il1:
st.caption(img_info["name"])
st.image(img_info["url"], width=100)
with col_il2:
st.code(img_tag, language=None)
col_btn1, col_btn2 = st.columns(2)
with col_btn1:
if st.session_state.editor_mode == "rich":
if st.button("📌 エディタに挿入", key=f"insert_btn_{i}", use_container_width=True):
st.session_state.working_html += img_tag
st.session_state.quill_key += 1
st.rerun()
with col_btn2:
if st.button("🗑️ 削除", key=f"del_inline_{i}", use_container_width=True):
st.session_state.inline_image_urls.pop(i)
st.rerun()
st.divider()
st.divider()
st.subheader("📝 本文エディタ")
col_mode1, col_mode2 = st.columns(2)
with col_mode1:
if st.button("🖊️ リッチテキストモード", use_container_width=True,
type="primary" if st.session_state.editor_mode == "rich" else "secondary"):
st.session_state.editor_mode = "rich"
st.rerun()
with col_mode2:
if st.button("💻 HTMLモード", use_container_width=True,
type="primary" if st.session_state.editor_mode == "html" else "secondary"):
st.session_state.editor_mode = "html"
st.rerun()
if st.session_state.editor_mode == "rich":
try:
from streamlit_quill import st_quill
rich_html = st_quill(
value=st.session_state.working_html,
html=True,
toolbar=[
["bold", "italic", "underline", "strike"],
[{"header": [1, 2, 3, False]}],
[{"color": []}, {"background": []}],
[{"list": "ordered"}, {"list": "bullet"}],
["blockquote", "code-block"],
["link"],
["clean"]
],
key=f"quill_editor_{st.session_state.quill_key}"
)
if rich_html is not None:
st.session_state.working_html = rich_html
except ImportError:
st.error("streamlit-quill が未インストールです。")
else:
edited_html = st.text_area("HTMLコード(直接編集)", value=st.session_state.working_html, height=300, key="html_editor")
st.session_state.working_html = edited_html
st.markdown("---")
raw_content = st.text_area("✨ AI装飾用メモ", height=120, key="ai_raw")
if st.button("✨ AIモードで装飾"):
if raw_content:
try:
model = genai.GenerativeModel(model_name=model_choice)
prompt = f"以下の文章をブログ記事用に装飾してください。本文の内容は全く変更をしないで装飾のみに務めること。装飾はモダンでオシャレに。HTMLのみ出力すること。本文: {raw_content}"
with st.spinner("AIが執筆中..."):
ai_response = model.generate_content(prompt)
st.session_state.working_html = clean_ai_html(ai_response.text)
st.rerun()
except Exception as e:
st.error(f"AIエラー: {e}")
else:
st.warning("AI装飾用メモを入力してください")
with st.expander("🪄 AIにデザインの修正を指示する"):
feedback_input = st.text_input("修正内容", key="ai_feedback")
if st.button("再修正を実行"):
if feedback_input and st.session_state.working_html:
try:
model = genai.GenerativeModel(model_name=model_choice)
feedback_prompt = f"""現在のHTMLコードを、以下の指示に基づいて修正してください。
本文の内容は絶対に変更せず、HTMLタグやスタイルのみを変更すること。
出力は純粋なHTMLコードのみを返してください。
【現在のHTML】{st.session_state.working_html}
【修正指示】{feedback_input}"""
with st.spinner("再修正中..."):
ai_response = model.generate_content(feedback_prompt)
st.session_state.working_html = clean_ai_html(ai_response.text)
st.rerun()
except Exception as e:
st.error(f"AIエラー: {e}")
if st.session_state.working_html:
st.divider()
st.subheader("👀 プレビュー")
render_html_native(clean_quill_html(st.session_state.working_html))
st.divider()
col_save1, col_save2 = st.columns(2)
with col_save1:
default_is_draft = title_input.startswith("【下書き】")
status_save = st.radio("保存ステータス", ["公開", "下書き"], index=1 if default_is_draft else 0, horizontal=True)
with col_save2:
if st.session_state.edit_id:
if st.button("✖ 編集をキャンセル"):
st.session_state.edit_id = None
st.session_state.edit_data = {"title": "", "author": AUTHOR_LIST[0], "content": ""}
st.session_state.edit_tags = []
st.session_state.tags_input_field = ""
st.session_state.working_html = ""
st.session_state.eyecatch_url = ""
st.session_state.inline_image_urls = []
st.session_state.editor_mode = "rich"
st.session_state.quill_key += 1
st.rerun()
if st.button("💾 この内容で保存する", use_container_width=True):
if not title_input:
st.error("タイトルを入力してください")
elif not st.session_state.working_html:
st.error("本文を入力してください")
else:
url_base = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY, "Content-Type": "application/json"}
clean_title = title_input.replace("【下書き】", "").strip()
save_title = f"【下書き】{clean_title}" if status_save == "下書き" else clean_title
tag_list = [t.strip() for t in tags_input.split(',') if t.strip()]
cleaned_content = clean_quill_html(st.session_state.working_html)
post_data = {"title": save_title, "content": cleaned_content, "author": author_select, "tags": ','.join(tag_list)}
if st.session_state.eyecatch_url:
post_data["eyecatch"] = st.session_state.eyecatch_url
try:
if st.session_state.edit_id:
res = requests.patch(f"{url_base}/{st.session_state.edit_id}", headers=headers, json=post_data)
else:
res = requests.post(url_base, headers=headers, json=post_data)
if res.status_code in [200, 201]:
st.success("保存完了!")
st.cache_data.clear()
st.session_state.edit_id = None
st.session_state.edit_data = {"title": "", "author": AUTHOR_LIST[0], "content": ""}
st.session_state.edit_tags = []
st.session_state.tags_input_field = ""
st.session_state.working_html = ""
st.session_state.eyecatch_url = ""
st.session_state.inline_image_urls = []
st.session_state.editor_mode = "rich"
st.session_state.quill_key += 1
st.rerun()
else:
st.error(f"エラー: {res.text}")
except Exception as e:
st.error(f"通信エラー: {e}")
st.divider()
st.subheader("📋 既存記事の管理")
manage_articles = get_all_articles()
for ma in manage_articles:
ma_id = ma["id"]
ma_title = ma.get("title", "無題")
ma_tags = get_tags(ma)
col_m1, col_m2, col_m3 = st.columns([3, 1, 1])
with col_m1:
st.write(f"**{ma_title}**")
st.caption(f"ID: {ma_id} | {ma.get('author')}")
render_tag_badges(ma_tags)
with col_m2:
if st.button("📝 編集", key=f"edit_btn_{ma_id}"):
st.session_state.edit_id = ma_id
st.session_state.edit_data = {"title": ma.get("title"), "author": ma.get("author"), "content": ma.get("content", "")}
# タグのセッションステートを確実に入力フィールドへ連携
extracted_tags = get_tags(ma)
st.session_state.edit_tags = extracted_tags
st.session_state.tags_input_field = ', '.join(extracted_tags)
st.session_state.working_html = ma.get("content", "")
ec = ma.get("eyecatch")
st.session_state.eyecatch_url = ec.get("url", "") if isinstance(ec, dict) else (ec or "")
st.session_state.inline_image_urls = []
st.session_state.editor_mode = "rich"
st.session_state.quill_key += 1
st.rerun()
with col_m3:
if st.session_state.delete_confirm_id == ma_id:
st.warning("本当に削除しますか?")
col_yes, col_no = st.columns(2)
with col_yes:
if st.button("✅ はい", key=f"del_yes_{ma_id}"):
res = requests.delete(
f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog/{ma_id}",
headers={"X-MICROCMS-API-KEY": CMS_API_KEY}
)
st.cache_data.clear()
st.session_state.delete_confirm_id = None
if res.status_code in [200, 202, 204]:
st.success("削除完了")
else:
st.error(f"削除失敗: {res.status_code}")
st.rerun()
with col_no:
if st.button("❌ いいえ", key=f"del_no_{ma_id}"):
st.session_state.delete_confirm_id = None
st.rerun()
else:
if st.button("🗑️ 削除", key=f"del_btn_{ma_id}"):
st.session_state.delete_confirm_id = ma_id
st.rerun()
st.divider()
elif password != "":
st.error("合言葉が違います。")