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