)(\s*
\s*
\s*
)', r'\1', html)
return html.strip()
def strip_html(html):
return re.sub(r'<[^>]*?>', '', html)
def make_snippet(content_html):
paragraphs = re.split(r'||
', 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"""
{html}
"""
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'
#{t}'
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('
← 一覧に戻る', 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('
← 一覧に戻る', 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"
{snippet}
", unsafe_allow_html=True)
st.markdown(f'
続きを読む →', unsafe_allow_html=True)
else:
st.subheader(title)
st.caption(f"👤 {author} | 📅 {pub_date}")
render_tag_badges(article_tags)
st.write(f"
{snippet}
", unsafe_allow_html=True)
st.markdown(f'
続きを読む →', 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"
{st.session_state.view_page + 1} / {total_pages} ページ
",
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'

'
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("合言葉が違います。")