Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -6,9 +6,8 @@ import os
|
|
| 6 |
import re
|
| 7 |
import io
|
| 8 |
import time
|
| 9 |
-
from PIL import Image
|
| 10 |
|
| 11 |
-
# --- 初期設定 ---
|
| 12 |
GEMINI_KEY = os.environ.get("GEMINI_API_KEY")
|
| 13 |
CMS_SERVICE_ID = os.environ.get("MICROCMS_SERVICE_ID")
|
| 14 |
CMS_API_KEY = os.environ.get("MICROCMS_API_KEY")
|
|
@@ -19,33 +18,32 @@ ARTICLES_PER_PAGE = 10
|
|
| 19 |
|
| 20 |
TEMPLATES = {
|
| 21 |
"テンプレートなし": "",
|
| 22 |
-
"📅 イベントレポート": "
|
| 23 |
-
<p>
|
| 24 |
-
<h2>
|
| 25 |
-
<p>ここに内容を書いてください。</p>
|
| 26 |
-
<h2>感想</h2>
|
| 27 |
-
<p>ここに感想を書いてください。</p>""",
|
| 28 |
-
"📝 日常記事": """<h2>はじめに</h2>
|
| 29 |
-
<p>ここに導入を書いてください。</p>
|
| 30 |
-
<h2>本文</h2>
|
| 31 |
-
<p>ここに本文を書いてください。</p>
|
| 32 |
-
<h2>おわりに</h2>
|
| 33 |
-
<p>ここにまとめを書いてください。</p>""",
|
| 34 |
-
"🎵 ライブ・コンサートレポート": """<h2>公演情報</h2>
|
| 35 |
-
<p>公演名:<br>日時:<br>会場:<br>出演:</p>
|
| 36 |
-
<h2>セットリスト</h2>
|
| 37 |
-
<p>1. <br>2. <br>3. </p>
|
| 38 |
-
<h2>見どころ・感想</h2>
|
| 39 |
-
<p>ここに感想を書いてください。</p>""",
|
| 40 |
-
"📣 お知らせ": """<h2>お知らせ</h2>
|
| 41 |
-
<p>ここにお知らせ内容を書いてください。</p>
|
| 42 |
-
<h2>詳細</h2>
|
| 43 |
-
<p>日時:<br>場所:<br>参加方法:</p>""",
|
| 44 |
}
|
| 45 |
|
| 46 |
if GEMINI_KEY:
|
| 47 |
genai.configure(api_key=GEMINI_KEY)
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def clean_ai_html(text):
|
| 50 |
text = re.sub(r'```html?', '', text, flags=re.IGNORECASE)
|
| 51 |
text = text.replace('```', '')
|
|
@@ -57,19 +55,17 @@ def clean_quill_html(html):
|
|
| 57 |
html = re.sub(r'</figure>', '</div>', html)
|
| 58 |
return html.strip()
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
def render_html(html, height=600):
|
| 61 |
wrapped = f"""
|
| 62 |
<html><head><style>
|
| 63 |
-
body {{
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
color: #111;
|
| 68 |
-
word-break: break-word;
|
| 69 |
-
}}
|
| 70 |
-
img {{ max-width: 100%; height: auto; display: block; }}
|
| 71 |
-
p {{ margin: 0.5em 0; }}
|
| 72 |
-
h1,h2,h3 {{ margin: 0.8em 0 0.4em; }}
|
| 73 |
</style></head>
|
| 74 |
<body>{html}</body></html>
|
| 75 |
"""
|
|
@@ -92,8 +88,7 @@ def upload_image_to_microcms(image_file, max_width=1200):
|
|
| 92 |
|
| 93 |
if img.width > max_width:
|
| 94 |
ratio = max_width / img.width
|
| 95 |
-
|
| 96 |
-
img = img.resize(new_size, Image.LANCZOS)
|
| 97 |
|
| 98 |
buf = io.BytesIO()
|
| 99 |
if img.mode in ("RGBA", "P"):
|
|
@@ -109,7 +104,6 @@ def upload_image_to_microcms(image_file, max_width=1200):
|
|
| 109 |
if res.status_code not in [200, 201]:
|
| 110 |
st.error(f"アップロードエラー詳細: {res.status_code} / {res.text}")
|
| 111 |
return None
|
| 112 |
-
|
| 113 |
return res.json().get("url")
|
| 114 |
except Exception as e:
|
| 115 |
st.error(f"画像アップロードエラー: {e}")
|
|
@@ -120,9 +114,8 @@ def get_articles(limit=ARTICLES_PER_PAGE, offset=0):
|
|
| 120 |
return [], 0
|
| 121 |
url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
|
| 122 |
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
|
| 123 |
-
params = {"limit": limit, "offset": offset}
|
| 124 |
try:
|
| 125 |
-
response = requests.get(url, headers=headers, params=
|
| 126 |
response.raise_for_status()
|
| 127 |
data = response.json()
|
| 128 |
return data.get("contents", []), data.get("totalCount", 0)
|
|
@@ -134,15 +127,13 @@ def get_all_articles():
|
|
| 134 |
return []
|
| 135 |
url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
|
| 136 |
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
|
| 137 |
-
params = {"limit": 100}
|
| 138 |
try:
|
| 139 |
-
response = requests.get(url, headers=headers, params=
|
| 140 |
response.raise_for_status()
|
| 141 |
return response.json().get("contents", [])
|
| 142 |
except:
|
| 143 |
return []
|
| 144 |
|
| 145 |
-
# --- 画面制御 ---
|
| 146 |
params = st.query_params
|
| 147 |
|
| 148 |
if "id" in params:
|
|
@@ -178,11 +169,7 @@ if "id" in params:
|
|
| 178 |
else:
|
| 179 |
with st.sidebar:
|
| 180 |
st.title("⚙️ 設定")
|
| 181 |
-
model_choice = st.selectbox(
|
| 182 |
-
"使用するAIモデル",
|
| 183 |
-
["gemini-2.5-flash", "gemini-2.5-pro"],
|
| 184 |
-
index=0
|
| 185 |
-
)
|
| 186 |
show_drafts_in_view = st.checkbox("閲覧画面で下書きも表示する", value=False)
|
| 187 |
|
| 188 |
tab_view, tab_manage = st.tabs(["📖 ブログを読む", "🛠️ 記事の管理・投稿"])
|
|
@@ -193,13 +180,10 @@ else:
|
|
| 193 |
with search_col1:
|
| 194 |
search_query = st.text_input("🔍 本文・タイトル検索", "", key="search_text")
|
| 195 |
with search_col2:
|
| 196 |
-
|
| 197 |
-
author_filter = st.selectbox("👤 著者で絞り込み", search_author_list, key="search_author")
|
| 198 |
|
| 199 |
-
if "view_page" not in st.session_state:
|
| 200 |
-
|
| 201 |
-
if "last_search" not in st.session_state:
|
| 202 |
-
st.session_state.last_search = ""
|
| 203 |
if search_query != st.session_state.last_search:
|
| 204 |
st.session_state.view_page = 0
|
| 205 |
st.session_state.last_search = search_query
|
|
@@ -219,11 +203,11 @@ else:
|
|
| 219 |
pub_date = a.get("publishedAt", "-----")[:10]
|
| 220 |
eyecatch = a.get("eyecatch")
|
| 221 |
|
| 222 |
-
|
| 223 |
-
if is_draft and not show_drafts_in_view:
|
| 224 |
continue
|
| 225 |
|
| 226 |
-
|
|
|
|
| 227 |
|
| 228 |
if ((author_filter == "全員") or (author_filter == author)) and \
|
| 229 |
(search_query.lower() in title.lower() or search_query.lower() in clean_text.lower()):
|
|
@@ -232,33 +216,33 @@ else:
|
|
| 232 |
st.image(eyecatch.get("url", ""), use_container_width=True)
|
| 233 |
st.subheader(title)
|
| 234 |
st.caption(f"👤 {author} | 📅 {pub_date}")
|
|
|
|
| 235 |
snippet = clean_text[:100] + "..."
|
| 236 |
-
st.
|
| 237 |
if st.button("続きを読む", key=f"read_{a['id']}"):
|
| 238 |
st.query_params.id = a["id"]
|
| 239 |
st.rerun()
|
| 240 |
st.divider()
|
| 241 |
|
| 242 |
-
if not search_query and author_filter == "全員":
|
| 243 |
total_pages = -(-total_count // ARTICLES_PER_PAGE)
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
if st.
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
if st.
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
st.rerun()
|
| 259 |
|
| 260 |
with tab_manage:
|
| 261 |
-
st.title("管理画面")
|
| 262 |
password = st.text_input("合言葉", type="password")
|
| 263 |
|
| 264 |
if password == ADMIN_PASSWORD:
|
|
@@ -271,34 +255,12 @@ else:
|
|
| 271 |
if "eyecatch_url" not in st.session_state: st.session_state.eyecatch_url = ""
|
| 272 |
if "inline_image_urls" not in st.session_state: st.session_state.inline_image_urls = []
|
| 273 |
if "delete_confirm_id" not in st.session_state: st.session_state.delete_confirm_id = None
|
| 274 |
-
if "autosave_html" not in st.session_state: st.session_state.autosave_html = ""
|
| 275 |
-
if "autosave_title" not in st.session_state: st.session_state.autosave_title = ""
|
| 276 |
-
if "autosave_time" not in st.session_state: st.session_state.autosave_time = None
|
| 277 |
if "quill_key" not in st.session_state: st.session_state.quill_key = 0
|
| 278 |
-
# ✅ コピー表示用フラグ
|
| 279 |
-
if "copied_index" not in st.session_state: st.session_state.copied_index = None
|
| 280 |
|
| 281 |
st.divider()
|
| 282 |
mode_label = f"📝 記事の編集 (ID: {st.session_state.edit_id})" if st.session_state.edit_id else "✍️ 新規記事の投稿"
|
| 283 |
st.subheader(mode_label)
|
| 284 |
|
| 285 |
-
if st.session_state.autosave_html and not st.session_state.working_html:
|
| 286 |
-
saved_time = st.session_state.autosave_time or "不明"
|
| 287 |
-
st.info(f"💾 自動保存された下書きがあります({saved_time})")
|
| 288 |
-
col_r1, col_r2 = st.columns(2)
|
| 289 |
-
with col_r1:
|
| 290 |
-
if st.button("📂 復元する"):
|
| 291 |
-
st.session_state.working_html = st.session_state.autosave_html
|
| 292 |
-
st.session_state.edit_data["title"] = st.session_state.autosave_title
|
| 293 |
-
st.session_state.quill_key += 1
|
| 294 |
-
st.rerun()
|
| 295 |
-
with col_r2:
|
| 296 |
-
if st.button("🗑️ 破棄する"):
|
| 297 |
-
st.session_state.autosave_html = ""
|
| 298 |
-
st.session_state.autosave_title = ""
|
| 299 |
-
st.session_state.autosave_time = None
|
| 300 |
-
st.rerun()
|
| 301 |
-
|
| 302 |
col_t, col_a = st.columns([2, 1])
|
| 303 |
with col_t:
|
| 304 |
title_input = st.text_input("記事タイトル", value=st.session_state.edit_data["title"])
|
|
@@ -322,7 +284,6 @@ else:
|
|
| 322 |
st.warning("テンプレートなしが選択されています")
|
| 323 |
|
| 324 |
st.divider()
|
| 325 |
-
|
| 326 |
st.subheader("🖼️ キャッチ画像(サムネイル)")
|
| 327 |
if st.session_state.eyecatch_url:
|
| 328 |
st.image(st.session_state.eyecatch_url, width=300)
|
|
@@ -330,19 +291,15 @@ else:
|
|
| 330 |
st.session_state.eyecatch_url = ""
|
| 331 |
st.rerun()
|
| 332 |
else:
|
| 333 |
-
eyecatch_file = st.file_uploader(
|
| 334 |
-
"キャッチ画像をアップロード(JPG/PNG)",
|
| 335 |
-
type=["jpg", "jpeg", "png"],
|
| 336 |
-
key="eyecatch_uploader"
|
| 337 |
-
)
|
| 338 |
if eyecatch_file:
|
| 339 |
-
col_ec1, col_ec2 = st.columns(
|
| 340 |
with col_ec1:
|
| 341 |
ec_max_w = st.number_input("最大横幅(px)", min_value=200, max_value=2000, value=1200, step=100, key="ec_maxw")
|
| 342 |
with col_ec2:
|
| 343 |
st.write("")
|
| 344 |
if st.button("☁️ アップロード", key="ec_upload_btn"):
|
| 345 |
-
with st.spinner("
|
| 346 |
url = upload_image_to_microcms(eyecatch_file, max_width=int(ec_max_w))
|
| 347 |
if url:
|
| 348 |
st.session_state.eyecatch_url = url
|
|
@@ -350,18 +307,12 @@ else:
|
|
| 350 |
st.rerun()
|
| 351 |
|
| 352 |
st.divider()
|
| 353 |
-
|
| 354 |
st.subheader("📷 本文内差し込み画像")
|
| 355 |
st.caption("アップロード後、ボタンでエディタに挿入またはHTMLタグをコピーしてください")
|
| 356 |
|
| 357 |
-
inline_files = st.file_uploader(
|
| 358 |
-
"差し込み画像(複数選択可)",
|
| 359 |
-
type=["jpg", "jpeg", "png"],
|
| 360 |
-
accept_multiple_files=True,
|
| 361 |
-
key="inline_uploader"
|
| 362 |
-
)
|
| 363 |
|
| 364 |
-
col_in1, col_in2 = st.columns(
|
| 365 |
with col_in1:
|
| 366 |
inline_max_w = st.number_input("最大横幅(px)", min_value=200, max_value=2000, value=900, step=100, key="inline_maxw")
|
| 367 |
with col_in2:
|
|
@@ -386,15 +337,12 @@ else:
|
|
| 386 |
for i, img_info in enumerate(st.session_state.inline_image_urls):
|
| 387 |
max_w = img_info.get("max_w", 900)
|
| 388 |
img_tag = f'<img src="{img_info["url"]}" alt="{img_info["name"]}" style="width:{max_w}px;max-width:100%;height:auto;display:block;" />'
|
| 389 |
-
|
| 390 |
with st.container():
|
| 391 |
col_il1, col_il2 = st.columns([1, 2])
|
| 392 |
with col_il1:
|
| 393 |
st.caption(img_info["name"])
|
| 394 |
st.image(img_info["url"], width=100)
|
| 395 |
-
|
| 396 |
with col_il2:
|
| 397 |
-
# ✅ st.codeでコピー可能な表示(Streamlit標準のコピーアイコン付き)
|
| 398 |
st.code(img_tag, language=None)
|
| 399 |
col_btn1, col_btn2 = st.columns(2)
|
| 400 |
with col_btn1:
|
|
@@ -410,7 +358,6 @@ else:
|
|
| 410 |
st.divider()
|
| 411 |
|
| 412 |
st.divider()
|
| 413 |
-
|
| 414 |
st.subheader("📝 本文エディタ")
|
| 415 |
col_mode1, col_mode2 = st.columns(2)
|
| 416 |
with col_mode1:
|
|
@@ -445,33 +392,25 @@ else:
|
|
| 445 |
st.session_state.working_html = rich_html
|
| 446 |
except ImportError:
|
| 447 |
st.error("streamlit-quill が未インストールです。")
|
| 448 |
-
|
| 449 |
else:
|
| 450 |
-
edited_html = st.text_area(
|
| 451 |
-
"HTMLコード(直接編集)",
|
| 452 |
-
value=st.session_state.working_html,
|
| 453 |
-
height=300,
|
| 454 |
-
key="html_editor"
|
| 455 |
-
)
|
| 456 |
st.session_state.working_html = edited_html
|
| 457 |
|
| 458 |
st.markdown("---")
|
| 459 |
-
raw_content = st.text_area("✨ AI装飾用メモ
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
else:
|
| 474 |
-
st.warning("AI装飾用メモを入力してください")
|
| 475 |
|
| 476 |
with st.expander("🪄 AIにデザインの修正を指示する"):
|
| 477 |
feedback_input = st.text_input("修正内容", key="ai_feedback")
|
|
@@ -479,33 +418,22 @@ else:
|
|
| 479 |
if feedback_input and st.session_state.working_html:
|
| 480 |
try:
|
| 481 |
model = genai.GenerativeModel(model_name=model_choice)
|
| 482 |
-
feedback_prompt = f"""
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
【修正指示】
|
| 489 |
-
{feedback_input}
|
| 490 |
-
"""
|
| 491 |
-
with st.spinner('再修正中...'):
|
| 492 |
ai_response = model.generate_content(feedback_prompt)
|
| 493 |
st.session_state.working_html = clean_ai_html(ai_response.text)
|
| 494 |
st.rerun()
|
| 495 |
except Exception as e:
|
| 496 |
st.error(f"AIエラー: {e}")
|
| 497 |
|
| 498 |
-
if st.session_state.working_html and title_input:
|
| 499 |
-
st.session_state.autosave_html = st.session_state.working_html
|
| 500 |
-
st.session_state.autosave_title = title_input
|
| 501 |
-
st.session_state.autosave_time = time.strftime("%H:%M")
|
| 502 |
-
st.caption(f"💾 自動保存済み({st.session_state.autosave_time})")
|
| 503 |
-
|
| 504 |
if st.session_state.working_html:
|
| 505 |
st.divider()
|
| 506 |
st.subheader("👀 プレビュー")
|
| 507 |
-
|
| 508 |
-
render_html(preview_html, height=400)
|
| 509 |
|
| 510 |
st.divider()
|
| 511 |
col_save1, col_save2 = st.columns(2)
|
|
@@ -534,13 +462,8 @@ else:
|
|
| 534 |
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY, "Content-Type": "application/json"}
|
| 535 |
clean_title = title_input.replace("【下書き】", "").strip()
|
| 536 |
save_title = f"【下書き】{clean_title}" if status_save == "下書き" else clean_title
|
| 537 |
-
|
| 538 |
cleaned_content = clean_quill_html(st.session_state.working_html)
|
| 539 |
-
post_data = {
|
| 540 |
-
"title": save_title,
|
| 541 |
-
"content": cleaned_content,
|
| 542 |
-
"author": author_select,
|
| 543 |
-
}
|
| 544 |
if st.session_state.eyecatch_url:
|
| 545 |
post_data["eyecatch"] = st.session_state.eyecatch_url
|
| 546 |
|
|
@@ -552,9 +475,6 @@ else:
|
|
| 552 |
|
| 553 |
if res.status_code in [200, 201]:
|
| 554 |
st.success("保存完了!")
|
| 555 |
-
st.session_state.autosave_html = ""
|
| 556 |
-
st.session_state.autosave_title = ""
|
| 557 |
-
st.session_state.autosave_time = None
|
| 558 |
st.session_state.edit_id = None
|
| 559 |
st.session_state.edit_data = {"title": "", "author": AUTHOR_LIST[0], "content": ""}
|
| 560 |
st.session_state.working_html = ""
|
|
@@ -581,11 +501,7 @@ else:
|
|
| 581 |
with col_m2:
|
| 582 |
if st.button("📝 編集", key=f"edit_btn_{ma_id}"):
|
| 583 |
st.session_state.edit_id = ma_id
|
| 584 |
-
st.session_state.edit_data = {
|
| 585 |
-
"title": ma.get("title"),
|
| 586 |
-
"author": ma.get("author"),
|
| 587 |
-
"content": ma.get("content", "")
|
| 588 |
-
}
|
| 589 |
st.session_state.working_html = ma.get("content", "")
|
| 590 |
ec = ma.get("eyecatch")
|
| 591 |
st.session_state.eyecatch_url = ec.get("url", "") if isinstance(ec, dict) else (ec or "")
|
|
|
|
| 6 |
import re
|
| 7 |
import io
|
| 8 |
import time
|
| 9 |
+
from PIL import Image
|
| 10 |
|
|
|
|
| 11 |
GEMINI_KEY = os.environ.get("GEMINI_API_KEY")
|
| 12 |
CMS_SERVICE_ID = os.environ.get("MICROCMS_SERVICE_ID")
|
| 13 |
CMS_API_KEY = os.environ.get("MICROCMS_API_KEY")
|
|
|
|
| 18 |
|
| 19 |
TEMPLATES = {
|
| 20 |
"テンプレートなし": "",
|
| 21 |
+
"📅 イベントレポート": "<h2>イベント概要</h2><p>イベント名:<br>日時:<br>場所:</p><h2>内容</h2><p>ここに内容を書いてください。</p><h2>感想</h2><p>ここに感想を書いてください。</p>",
|
| 22 |
+
"📝 日常記事": "<h2>はじめに</h2><p>ここに導入を書いてください。</p><h2>本文</h2><p>ここに本文を書いてください。</p><h2>おわりに</h2><p>ここにまとめを書いてください。</p>",
|
| 23 |
+
"🎵 ライブ・コンサートレポート": "<h2>公演情報</h2><p>公演名:<br>日時:<br>会場:<br>出演:</p><h2>セットリスト</h2><p>1. <br>2. <br>3. </p><h2>見どころ・感想</h2><p>ここに感想を書いてください。</p>",
|
| 24 |
+
"📣 お知らせ": "<h2>お知らせ</h2><p>ここにお知らせ内容を書いてください。</p><h2>詳細</h2><p>日時:<br>場所:<br>参加方法:</p>",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
if GEMINI_KEY:
|
| 28 |
genai.configure(api_key=GEMINI_KEY)
|
| 29 |
|
| 30 |
+
# --- Quillツールバー固定&スマホ拡大抑制CSS ---
|
| 31 |
+
st.markdown("""
|
| 32 |
+
<style>
|
| 33 |
+
/* Quillツールバーを固定 */
|
| 34 |
+
.ql-toolbar {
|
| 35 |
+
position: sticky !important;
|
| 36 |
+
top: 0 !important;
|
| 37 |
+
z-index: 999 !important;
|
| 38 |
+
background: white !important;
|
| 39 |
+
}
|
| 40 |
+
/* スマホでの自動拡大を抑制 */
|
| 41 |
+
input, textarea, select {
|
| 42 |
+
font-size: 16px !important;
|
| 43 |
+
}
|
| 44 |
+
</style>
|
| 45 |
+
""", unsafe_allow_html=True)
|
| 46 |
+
|
| 47 |
def clean_ai_html(text):
|
| 48 |
text = re.sub(r'```html?', '', text, flags=re.IGNORECASE)
|
| 49 |
text = text.replace('```', '')
|
|
|
|
| 55 |
html = re.sub(r'</figure>', '</div>', html)
|
| 56 |
return html.strip()
|
| 57 |
|
| 58 |
+
# --- HTMLタグを除去してプレーンテキストに ---
|
| 59 |
+
def strip_html(html):
|
| 60 |
+
return re.sub(r'<[^>]*?>', '', html)
|
| 61 |
+
|
| 62 |
def render_html(html, height=600):
|
| 63 |
wrapped = f"""
|
| 64 |
<html><head><style>
|
| 65 |
+
body {{ margin:0; padding:0; font-family:sans-serif; font-size:16px; color:#111; word-break:break-word; }}
|
| 66 |
+
img {{ max-width:100%; height:auto; display:block; }}
|
| 67 |
+
p {{ margin:0.5em 0; }}
|
| 68 |
+
h1,h2,h3 {{ margin:0.8em 0 0.4em; }}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</style></head>
|
| 70 |
<body>{html}</body></html>
|
| 71 |
"""
|
|
|
|
| 88 |
|
| 89 |
if img.width > max_width:
|
| 90 |
ratio = max_width / img.width
|
| 91 |
+
img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
|
|
|
|
| 92 |
|
| 93 |
buf = io.BytesIO()
|
| 94 |
if img.mode in ("RGBA", "P"):
|
|
|
|
| 104 |
if res.status_code not in [200, 201]:
|
| 105 |
st.error(f"アップロードエラー詳細: {res.status_code} / {res.text}")
|
| 106 |
return None
|
|
|
|
| 107 |
return res.json().get("url")
|
| 108 |
except Exception as e:
|
| 109 |
st.error(f"画像アップロードエラー: {e}")
|
|
|
|
| 114 |
return [], 0
|
| 115 |
url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
|
| 116 |
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
|
|
|
|
| 117 |
try:
|
| 118 |
+
response = requests.get(url, headers=headers, params={"limit": limit, "offset": offset})
|
| 119 |
response.raise_for_status()
|
| 120 |
data = response.json()
|
| 121 |
return data.get("contents", []), data.get("totalCount", 0)
|
|
|
|
| 127 |
return []
|
| 128 |
url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
|
| 129 |
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
|
|
|
|
| 130 |
try:
|
| 131 |
+
response = requests.get(url, headers=headers, params={"limit": 100})
|
| 132 |
response.raise_for_status()
|
| 133 |
return response.json().get("contents", [])
|
| 134 |
except:
|
| 135 |
return []
|
| 136 |
|
|
|
|
| 137 |
params = st.query_params
|
| 138 |
|
| 139 |
if "id" in params:
|
|
|
|
| 169 |
else:
|
| 170 |
with st.sidebar:
|
| 171 |
st.title("⚙️ 設定")
|
| 172 |
+
model_choice = st.selectbox("使用するAIモデル", ["gemini-2.5-flash", "gemini-2.5-pro"], index=0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
show_drafts_in_view = st.checkbox("閲覧画面で下書きも表示する", value=False)
|
| 174 |
|
| 175 |
tab_view, tab_manage = st.tabs(["📖 ブログを読む", "🛠️ 記事の管理・投稿"])
|
|
|
|
| 180 |
with search_col1:
|
| 181 |
search_query = st.text_input("🔍 本文・タイトル検索", "", key="search_text")
|
| 182 |
with search_col2:
|
| 183 |
+
author_filter = st.selectbox("👤 著者で絞り込み", ["全員"] + AUTHOR_LIST, key="search_author")
|
|
|
|
| 184 |
|
| 185 |
+
if "view_page" not in st.session_state: st.session_state.view_page = 0
|
| 186 |
+
if "last_search" not in st.session_state: st.session_state.last_search = ""
|
|
|
|
|
|
|
| 187 |
if search_query != st.session_state.last_search:
|
| 188 |
st.session_state.view_page = 0
|
| 189 |
st.session_state.last_search = search_query
|
|
|
|
| 203 |
pub_date = a.get("publishedAt", "-----")[:10]
|
| 204 |
eyecatch = a.get("eyecatch")
|
| 205 |
|
| 206 |
+
if title.startswith("【下書き】") and not show_drafts_in_view:
|
|
|
|
| 207 |
continue
|
| 208 |
|
| 209 |
+
# ✅ HTMLタグを除去したプレーンテキスト
|
| 210 |
+
clean_text = strip_html(content_html)
|
| 211 |
|
| 212 |
if ((author_filter == "全員") or (author_filter == author)) and \
|
| 213 |
(search_query.lower() in title.lower() or search_query.lower() in clean_text.lower()):
|
|
|
|
| 216 |
st.image(eyecatch.get("url", ""), use_container_width=True)
|
| 217 |
st.subheader(title)
|
| 218 |
st.caption(f"👤 {author} | 📅 {pub_date}")
|
| 219 |
+
# ✅ st.captionに変更してHTMLが描画されないように
|
| 220 |
snippet = clean_text[:100] + "..."
|
| 221 |
+
st.caption(snippet)
|
| 222 |
if st.button("続きを読む", key=f"read_{a['id']}"):
|
| 223 |
st.query_params.id = a["id"]
|
| 224 |
st.rerun()
|
| 225 |
st.divider()
|
| 226 |
|
| 227 |
+
if not search_query and author_filter == "全員" and total_count > ARTICLES_PER_PAGE:
|
| 228 |
total_pages = -(-total_count // ARTICLES_PER_PAGE)
|
| 229 |
+
st.caption(f"{total_count}件中 {offset+1}〜{min(offset+ARTICLES_PER_PAGE, total_count)}件を表示")
|
| 230 |
+
col_prev, col_page, col_next = st.columns([1, 2, 1])
|
| 231 |
+
with col_prev:
|
| 232 |
+
if st.session_state.view_page > 0:
|
| 233 |
+
if st.button("← 前へ", use_container_width=True):
|
| 234 |
+
st.session_state.view_page -= 1
|
| 235 |
+
st.rerun()
|
| 236 |
+
with col_page:
|
| 237 |
+
st.markdown(f"<p style='text-align:center;'>{st.session_state.view_page + 1} / {total_pages} ページ</p>", unsafe_allow_html=True)
|
| 238 |
+
with col_next:
|
| 239 |
+
if st.session_state.view_page < total_pages - 1:
|
| 240 |
+
if st.button("次へ →", use_container_width=True):
|
| 241 |
+
st.session_state.view_page += 1
|
| 242 |
+
st.rerun()
|
|
|
|
| 243 |
|
| 244 |
with tab_manage:
|
| 245 |
+
st.title("秘密の管理画面")
|
| 246 |
password = st.text_input("合言葉", type="password")
|
| 247 |
|
| 248 |
if password == ADMIN_PASSWORD:
|
|
|
|
| 255 |
if "eyecatch_url" not in st.session_state: st.session_state.eyecatch_url = ""
|
| 256 |
if "inline_image_urls" not in st.session_state: st.session_state.inline_image_urls = []
|
| 257 |
if "delete_confirm_id" not in st.session_state: st.session_state.delete_confirm_id = None
|
|
|
|
|
|
|
|
|
|
| 258 |
if "quill_key" not in st.session_state: st.session_state.quill_key = 0
|
|
|
|
|
|
|
| 259 |
|
| 260 |
st.divider()
|
| 261 |
mode_label = f"📝 記事の編集 (ID: {st.session_state.edit_id})" if st.session_state.edit_id else "✍️ 新規記事の投稿"
|
| 262 |
st.subheader(mode_label)
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
col_t, col_a = st.columns([2, 1])
|
| 265 |
with col_t:
|
| 266 |
title_input = st.text_input("記事タイトル", value=st.session_state.edit_data["title"])
|
|
|
|
| 284 |
st.warning("テンプレートなしが選択されています")
|
| 285 |
|
| 286 |
st.divider()
|
|
|
|
| 287 |
st.subheader("🖼️ キャッチ画像(サムネイル)")
|
| 288 |
if st.session_state.eyecatch_url:
|
| 289 |
st.image(st.session_state.eyecatch_url, width=300)
|
|
|
|
| 291 |
st.session_state.eyecatch_url = ""
|
| 292 |
st.rerun()
|
| 293 |
else:
|
| 294 |
+
eyecatch_file = st.file_uploader("キャッチ画像をアップロード(JPG/PNG)", type=["jpg", "jpeg", "png"], key="eyecatch_uploader")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
if eyecatch_file:
|
| 296 |
+
col_ec1, col_ec2 = st.columns(2)
|
| 297 |
with col_ec1:
|
| 298 |
ec_max_w = st.number_input("最大横幅(px)", min_value=200, max_value=2000, value=1200, step=100, key="ec_maxw")
|
| 299 |
with col_ec2:
|
| 300 |
st.write("")
|
| 301 |
if st.button("☁️ アップロード", key="ec_upload_btn"):
|
| 302 |
+
with st.spinner("アップロード中..."):
|
| 303 |
url = upload_image_to_microcms(eyecatch_file, max_width=int(ec_max_w))
|
| 304 |
if url:
|
| 305 |
st.session_state.eyecatch_url = url
|
|
|
|
| 307 |
st.rerun()
|
| 308 |
|
| 309 |
st.divider()
|
|
|
|
| 310 |
st.subheader("📷 本文内差し込み画像")
|
| 311 |
st.caption("アップロード後、ボタンでエディタに挿入またはHTMLタグをコピーしてください")
|
| 312 |
|
| 313 |
+
inline_files = st.file_uploader("差し込み画像(複数選択可)", type=["jpg", "jpeg", "png"], accept_multiple_files=True, key="inline_uploader")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
|
| 315 |
+
col_in1, col_in2 = st.columns(2)
|
| 316 |
with col_in1:
|
| 317 |
inline_max_w = st.number_input("最大横幅(px)", min_value=200, max_value=2000, value=900, step=100, key="inline_maxw")
|
| 318 |
with col_in2:
|
|
|
|
| 337 |
for i, img_info in enumerate(st.session_state.inline_image_urls):
|
| 338 |
max_w = img_info.get("max_w", 900)
|
| 339 |
img_tag = f'<img src="{img_info["url"]}" alt="{img_info["name"]}" style="width:{max_w}px;max-width:100%;height:auto;display:block;" />'
|
|
|
|
| 340 |
with st.container():
|
| 341 |
col_il1, col_il2 = st.columns([1, 2])
|
| 342 |
with col_il1:
|
| 343 |
st.caption(img_info["name"])
|
| 344 |
st.image(img_info["url"], width=100)
|
|
|
|
| 345 |
with col_il2:
|
|
|
|
| 346 |
st.code(img_tag, language=None)
|
| 347 |
col_btn1, col_btn2 = st.columns(2)
|
| 348 |
with col_btn1:
|
|
|
|
| 358 |
st.divider()
|
| 359 |
|
| 360 |
st.divider()
|
|
|
|
| 361 |
st.subheader("📝 本文エディタ")
|
| 362 |
col_mode1, col_mode2 = st.columns(2)
|
| 363 |
with col_mode1:
|
|
|
|
| 392 |
st.session_state.working_html = rich_html
|
| 393 |
except ImportError:
|
| 394 |
st.error("streamlit-quill が未インストールです。")
|
|
|
|
| 395 |
else:
|
| 396 |
+
edited_html = st.text_area("HTMLコード(直接編集)", value=st.session_state.working_html, height=300, key="html_editor")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
st.session_state.working_html = edited_html
|
| 398 |
|
| 399 |
st.markdown("---")
|
| 400 |
+
raw_content = st.text_area("✨ AI装飾用メモ", height=120, key="ai_raw")
|
| 401 |
+
if st.button("✨ AIモードで装飾"):
|
| 402 |
+
if raw_content:
|
| 403 |
+
try:
|
| 404 |
+
model = genai.GenerativeModel(model_name=model_choice)
|
| 405 |
+
prompt = f"以下の文章をブログ記事用に装飾してください。本文の内容は全く変更をしないで装飾のみに務めること。装飾はモダンでオシャレに。HTMLのみ出力すること。本文: {raw_content}"
|
| 406 |
+
with st.spinner("AIが執筆中..."):
|
| 407 |
+
ai_response = model.generate_content(prompt)
|
| 408 |
+
st.session_state.working_html = clean_ai_html(ai_response.text)
|
| 409 |
+
st.rerun()
|
| 410 |
+
except Exception as e:
|
| 411 |
+
st.error(f"AIエラー: {e}")
|
| 412 |
+
else:
|
| 413 |
+
st.warning("AI装飾用メモを入力してください")
|
|
|
|
|
|
|
| 414 |
|
| 415 |
with st.expander("🪄 AIにデザインの修正を指示する"):
|
| 416 |
feedback_input = st.text_input("修正内容", key="ai_feedback")
|
|
|
|
| 418 |
if feedback_input and st.session_state.working_html:
|
| 419 |
try:
|
| 420 |
model = genai.GenerativeModel(model_name=model_choice)
|
| 421 |
+
feedback_prompt = f"""現在のHTMLコードを、以下の指示に基づいて修正してください。
|
| 422 |
+
本文の内容は絶対に変更せず、HTMLタグやスタイルのみを変更すること。
|
| 423 |
+
出力は純粋なHTMLコードのみを返してください。
|
| 424 |
+
【現在のHTML】{st.session_state.working_html}
|
| 425 |
+
【修正指示】{feedback_input}"""
|
| 426 |
+
with st.spinner("再修正中..."):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
ai_response = model.generate_content(feedback_prompt)
|
| 428 |
st.session_state.working_html = clean_ai_html(ai_response.text)
|
| 429 |
st.rerun()
|
| 430 |
except Exception as e:
|
| 431 |
st.error(f"AIエラー: {e}")
|
| 432 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
if st.session_state.working_html:
|
| 434 |
st.divider()
|
| 435 |
st.subheader("👀 プレビュー")
|
| 436 |
+
render_html(clean_quill_html(st.session_state.working_html), height=400)
|
|
|
|
| 437 |
|
| 438 |
st.divider()
|
| 439 |
col_save1, col_save2 = st.columns(2)
|
|
|
|
| 462 |
headers = {"X-MICROCMS-API-KEY": CMS_API_KEY, "Content-Type": "application/json"}
|
| 463 |
clean_title = title_input.replace("【下書き】", "").strip()
|
| 464 |
save_title = f"【下書き】{clean_title}" if status_save == "下書き" else clean_title
|
|
|
|
| 465 |
cleaned_content = clean_quill_html(st.session_state.working_html)
|
| 466 |
+
post_data = {"title": save_title, "content": cleaned_content, "author": author_select}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
if st.session_state.eyecatch_url:
|
| 468 |
post_data["eyecatch"] = st.session_state.eyecatch_url
|
| 469 |
|
|
|
|
| 475 |
|
| 476 |
if res.status_code in [200, 201]:
|
| 477 |
st.success("保存完了!")
|
|
|
|
|
|
|
|
|
|
| 478 |
st.session_state.edit_id = None
|
| 479 |
st.session_state.edit_data = {"title": "", "author": AUTHOR_LIST[0], "content": ""}
|
| 480 |
st.session_state.working_html = ""
|
|
|
|
| 501 |
with col_m2:
|
| 502 |
if st.button("📝 編集", key=f"edit_btn_{ma_id}"):
|
| 503 |
st.session_state.edit_id = ma_id
|
| 504 |
+
st.session_state.edit_data = {"title": ma.get("title"), "author": ma.get("author"), "content": ma.get("content", "")}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
st.session_state.working_html = ma.get("content", "")
|
| 506 |
ec = ma.get("eyecatch")
|
| 507 |
st.session_state.eyecatch_url = ec.get("url", "") if isinstance(ec, dict) else (ec or "")
|