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 = { "テンプレートなし": "", "📅 イベントレポート": "

イベント概要

イベント名:
日時:
場所:

内容

ここに内容を書いてください。

感想

ここに感想を書いてください。

", "📝 日常記事": "

はじめに

ここに導入を書いてください。

本文

ここに本文を書いてください。

おわりに

ここにまとめを書いてください。

", "🎵 ライブ・コンサートレポート": "

公演情報

公演名:
日時:
会場:
出演:

セットリスト

1.
2.
3.

見どころ・感想

ここに感想を書いてください。

", "📣 お知らせ": "

お知らせ

ここにお知らせ内容を書いてください。

詳細

日時:
場所:
参加方法:

", } if GEMINI_KEY: genai.configure(api_key=GEMINI_KEY) st.markdown(""" """, 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'
', '
', html) html = re.sub(r'
', '', html) html = re.sub(r'(

\s*\s*

)(\s*

\s*\s*]*/?>\s*

)(\s*

\s*\s*

)', r'\1', html) html = re.sub(r'(

\s*\s*

)(\s*]*/?>)(\s*

\s*\s*

)', r'\1', html) html = re.sub(r'(

\s*\s*

)(\s*)(\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'{img_info[' 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("合言葉が違います。")