.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.github/workflows/sync_to_hf.yml DELETED
@@ -1,23 +0,0 @@
1
- name: Sync to Hugging Face Space
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- workflow_dispatch:
7
-
8
- jobs:
9
- sync-to-hub:
10
- runs-on: ubuntu-latest
11
- steps:
12
- - uses: actions/checkout@v4
13
- with:
14
- fetch-depth: 0
15
- persist-credentials: false
16
-
17
- - name: Push to Hugging Face Space
18
- env:
19
- HF_TOKEN: ${{ secrets.HF_TOKEN }}
20
- run: |
21
- git config user.email "action@github.com"
22
- git config user.name "GitHub Action"
23
- git push --force https://Nagomi-SugarHeart:$HF_TOKEN@huggingface.co/spaces/Wasemas/9thblogs main
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore DELETED
@@ -1,9 +0,0 @@
1
- __pycache__/
2
- *.py[cod]
3
- *.pyc
4
- .env
5
- .env.*
6
- *.egg-info/
7
- dist/
8
- build/
9
- .DS_Store
 
 
 
 
 
 
 
 
 
 
CLAUDE.md DELETED
@@ -1,224 +0,0 @@
1
- # ブログシステム 機能仕様書
2
-
3
- ## 1. システム概要
4
- **FastAPI + Jinja2** で構築された、ブログ記事の公開および管理システムです。バックエンドのデータストアとして「microCMS」を採用し、記事データや画像の管理を行っています。AI(Gemini API)を利用した記事装飾のアシスト機能、バックアップ・バージョン管理機能、ブロック型エディタを備えています。Hugging Face Docker Space(無料)でホスティングしています。
5
-
6
- ### 技術スタック
7
- | 項目 | 内容 |
8
- | :--- | :--- |
9
- | バックエンド | FastAPI + Uvicorn |
10
- | テンプレート | Jinja2 |
11
- | フロントエンド | Tailwind CSS CDN・Font Awesome 6・Quill 1.3.6 |
12
- | ホスティング | Hugging Face Docker Space(port 7860) |
13
- | データストア | microCMS |
14
- | 画像処理 | Pillow(EXIF自動回転・リサイズ) |
15
- | AI | Google Gemini API |
16
-
17
- ### ファイル構成
18
- ```
19
- /
20
- ├── main.py # FastAPI アプリ本体
21
- ├── requirements.txt
22
- ├── Dockerfile # HF Docker Space 用
23
- └── templates/
24
- ├── base.html # 共通レイアウト(Tailwind・Font Awesome CDN)
25
- ├── blog_list.html # ブログ一覧(公開)
26
- ├── blog_detail.html # 記事詳細・コメント・シェア(公開)
27
- ├── admin_login.html # 管理者ログイン
28
- ├── admin_list.html # 記事管理一覧(管理者)
29
- └── admin_editor.html # ブロックエディタ(管理者・Tailwind/Quill/Font Awesome CDN)
30
- ```
31
-
32
- ---
33
-
34
- ## 2. ユーザーロール(権限)
35
- | ロール | 対象者 | 利用可能な機能 |
36
- | :--- | :--- | :--- |
37
- | **一般ユーザー** | ブログの読者 | 記事の閲覧、検索、コメントの投稿、シェア |
38
- | **管理者・投稿者** | ブログの運営メンバー | 「合言葉」によるログイン、記事の作成・編集・削除、画像の管理 |
39
-
40
- ### 認証方式
41
- - `ADMIN_PASSWORD` 環境変数と `SECRET_KEY` を SHA-256 で署名した Cookie(有効期限30日)
42
- - 管理者ルートは FastAPI の `Depends` で認証チェック
43
-
44
- ---
45
-
46
- ## 3. 非機能要件
47
- * ランニングに費用を一切発生させない(HF Docker Space 無料枠)。
48
- * 投稿者はプログラム言語に明るくないユーザーを含め4人以上。著者リスト: `["にき", "かん", "なごみ", "蜜柑餅"]`
49
- * スマートフォン・PCの両方で簡単に操作できること(モバイルファースト)。
50
- * HTMLに不慣れなユーザーにはリッチテキスト編集、HTML編集もしたいユーザーにはHTMLモードを提供。
51
- * APIキーや環境のリンクなどは環境変数としてコード外に記載。
52
-
53
- ---
54
-
55
- ## 4. 機能要件
56
-
57
- ### 4.1 閲覧側(フロントエンド)機能
58
-
59
- * **記事一覧・検索機能**
60
- * 記事をカード形式(アイキャッチ画像・タイトル・著者・投稿日・タグ・本文の要約)で表示。
61
- * ページネーション: 1ページあたり10件、前後ページへ遷移可能。
62
- * 検索・絞り込み: テキスト検索(タイトル・本文)、著者、タグ(複数選択可)。
63
- * 「公開」ステータスの記事のみ表示(下書きは非表示)。
64
- * タグは `#タグ名` 形式で表示。
65
-
66
- * **記事詳細表示**
67
- * HTML で作成された記事本文・タグ・アイキャッチ画像をレンダリング。
68
- * `.blog-content` スタイルにてライトモード・ダークモード両対応。
69
- * YouTube(レスポンシブ iframe)・X(Twitter)(`blockquote.twitter-tweet` + widget.js)埋め込みに対応。
70
- * 画像にキャプション(`<p>` タグ、中央寄せ小文字)が付く場合は画像下に表示。
71
-
72
- * **シェア機能**
73
- * OGP メタタグ(`og:title`・`og:image`・`og:url`)と Twitter Card(`summary_large_image`)でキャッチ画像をサムネイルとして表示。
74
- * 「Xで共有」ボタン: `twitter.com/intent/tweet` で X アプリへ遷移。
75
- * 「テキストをコピー」ボタン: `{title} | わせます9期ブログ\n{link}` をクリップボードにコピー。
76
- * `SPACE_URL` 環境変数未設定時は `request.base_url` をフォールバックとして使用し、常にシェアボタンを表示。
77
-
78
- * **コメント機能**
79
- * 記事ごとのコメント一覧表示。
80
- * 任意の名前と本文による新規コメント投稿(fetch POST → `/api/comment`)、投稿後即時リロード。
81
-
82
- ### 4.2 管理側(バックエンド)機能
83
-
84
- * **記事管理一覧**(`/admin/articles`)
85
- * 全記事(下書き含む)のリスト表示。公開/下書バッジ(同サイズ・折り返し禁止)。
86
- * タグは `#タグ名` 形式で表示。
87
- * 「編集」ボタン → `/admin/edit/{id}` へ遷移。
88
- * 「削除」ボタン → 確認ポップアップ付き(`data-id`・`data-title` 属性方式)。
89
-
90
- * **ブロックエディタ**(`/admin/edit/{id}` / `/admin/new`)
91
-
92
- **基本情報入力**
93
- * タイトル: `<textarea>` 自動高さ調整(折り返し・縦拡張可、iOS 自動ズーム防止)。
94
- * 著者: `<select>`(font-size 16px で iOS 自動ズーム防止)。
95
- * タグ: `<textarea>` 自動高さ調整・Enter 無効(カンマ区切り)。スマホでは縦1列レイアウト。
96
- * アイキャッチ画像: アップロード→microCMS media API、EXIF自動回転・1200px リサイズ。
97
-
98
- **ブロック本体**
99
- * **テキストブロック**と**画像ブロック**の配列として本文を管理。
100
- * ブロック間・先頭・末尾に常に「+ テキスト / + 画像」ボタンを表示。
101
- * ↑↓ ボタンでブロック順序を変更。🗑 ボタンで削除。
102
- * **画像ブロック**: アップロード→microCMS media API・最大幅指定・キャプション(画像下に小文字で表示)。
103
-
104
- **テキストブロック編集モーダル**
105
- * スマホ: フルスクリーン、PC: センターモーダル(最大幅 2xl)。
106
- * iOS キーボード対応: `visualViewport` API でキーボード開閉時にモーダルの top/height をリアルタイム追従。
107
- * **リッチテキストモード(Quill 1.3.6 Snow)**
108
- * ツールバー: H1/H2/H3、文字サイズ(small/normal/large/huge)、太字・斜体・下線・打消し線、文字色、箇条書き・番号付きリスト、引用、リンク、YouTube動画埋め込み、Xボタン、クリア。
109
- * ツールバー: `position: sticky; top: 0` で入力中も上部に固定。
110
- * iOS ツールバータップ時: `mousedown` で blur 抑制 + `touchstart`/`touchend` でカーソル位置を保存・復元(キーボードを閉じずに装飾可能)。
111
- * `scrollingContainer: '#quill-container'` 指定で改行時にカーソルが自動追従。
112
- * **HTML 直接編集モード**: `<textarea>`(font-size 16px)+ HTML ツールバー(太字/H2/H3/リスト/段落/リンク/YouTube/X埋め込み/AI装飾)。
113
- * **埋め込み機能**: URL 入力ダイアログ → YouTube は `<div class="embed-yt"><iframe ...></iframe></div>`(16:9 レスポンシブ)、X は `<blockquote class="twitter-tweet"><a href="..."></a></blockquote>` で挿入。
114
- * **AI 装飾(Gemini 連携)**: HTML モードのみ。指示テキスト入力 → `/api/ai-decorate` → HTML を自動修正。
115
- * モーダル内プレビュー: トグルボタンで表示/非表示。
116
-
117
- **バックアップ・バージョン管理**
118
- * 保存時に `blog-backup` へ自動バックアップ(バージョン番号はインクリメント)。
119
- * 選択リスト形式(`v{version} - {YYYY-MM-DD HH:MM}` JST 表示)でバックアップを選択。
120
- * 「復元」ボタン押下 → 確認ポップアップ → 現在の編集内容をバックアップで上書き。
121
- * `contentBlocks`(JSON)・`title`・`author`・`tags` を復元。
122
-
123
- **保存**
124
- * 「下書き保存」: `/api/save` POST → 成功後ページリロード(バックアップ一覧を更新)、上部に薄緑バナーで「✅ 下書き保存しました」を表示(sessionStorage 経由)。
125
- * 「公開する」: `/api/save` POST → 下書きからの公開時は新 ID で再作成し、コメント・バックアップの `originalId` を一括書き換え(マイグレーション)。
126
-
127
- **全体プレビュー**
128
- * 右下フローティングボタン「👁 プレビュー」→ フルスクリーンモーダルで全ブロックを結合表示。
129
- * タイトル・著者・タグ・本文を `.blog-preview` スタイルでレンダリング(ダークモード対応)。
130
-
131
- ---
132
-
133
- ## 5. 外部API・連携サービス
134
- | サービス名 | 用途 |
135
- | :--- | :--- |
136
- | **microCMS** | ブログ記事データ・コメント・バックアップの保存、アップロード画像のホスティング |
137
- | **Google Gemini API** | HTML モードでの記事本文自動装飾(モデル: `gemini-2.5-flash`) |
138
- | **Twitter widget.js** | 閲覧ページで `twitter-tweet` クラスを検出した場合のみ読み込み |
139
-
140
- ---
141
-
142
- ## 6. 環境変数
143
- | 変数名 | 用途 | デフォルト |
144
- | :--- | :--- | :--- |
145
- | `MICROCMS_SERVICE_ID` | microCMS サービス ID | (必須) |
146
- | `MICROCMS_API_KEY` | microCMS API キー | (必須) |
147
- | `ADMIN_PASSWORD` | 管理者ログイン用合言葉 | `1234` |
148
- | `SECRET_KEY` | Cookie 署名キー | `wasemas-secret-key` |
149
- | `GEMINI_API_KEY` | Gemini API キー | (任意) |
150
- | `SPACE_URL` | シェア用ベース URL | `request.base_url` でフォールバック |
151
-
152
- ---
153
-
154
- ## 7. microCMS API 情報
155
-
156
- ### 7.1 blog(リスト型・ブログコンテンツ)
157
- | フィールドID | 表示名 | データ型 | 用途 |
158
- | :--- | :--- | :--- | :--- |
159
- | **title** | タイトル | テキストフィールド | ブログタイトル |
160
- | **author** | 著者 | テキストフィールド | 著者名 |
161
- | **eyecatch** | キャッチ画像 | 画像 | サムネイル画像 |
162
- | **tags** | タグ | テキストフィールド | カンマ区切りタグ |
163
- | **content** | 本文 | テキストエリア | HTML タグ付き本文(表示・保存用) |
164
- | **contentRich** | 本文(リッチ) | リッチテキストエリア | microCMS 管理画面直接編集用 |
165
- | **contentBlocks** | ブロックデータ | テキストエリア | ブロック配列 JSON(編集用) |
166
-
167
- ### 7.2 comments(リスト型・コメント)
168
- | フィールドID | 表示名 | データ型 | 用途 |
169
- | :--- | :--- | :--- | :--- |
170
- | **articleId** | 記事ID | テキストフィールド | 対象記事の ID |
171
- | **name** | 名前 | テキストフィールド | 投稿者名 |
172
- | **body** | コメント本文 | テキストエリア | コメント内容 |
173
-
174
- ### 7.3 blog-backup(リスト型・バックアップ)
175
- | フィールドID | 表示名 | データ型 | 用途 |
176
- | :--- | :--- | :--- | :--- |
177
- | **originalId** | 記事ID | テキストフィールド | 元記事の ID |
178
- | **version** | バージョン | 数値 | バックアップバージョン(originalId + version でユニーク) |
179
- | **title** | タイトル | テキストフィールド | タイトル |
180
- | **author** | 著者 | テキストフィールド | 著者名 |
181
- | **tags** | タグ | テキストフィールド | カンマ区切りタグ |
182
- | **content** | 本文 | テキストエリア | HTML タグ付き本文 |
183
- | **contentBlocks** | ブロックデータ | テキストエリア | ブロック配列 JSON |
184
-
185
- ### ブロックデータ構造(contentBlocks の JSON)
186
- ```json
187
- [
188
- { "type": "text", "id": "block-abc", "html": "<h2>見出し</h2><p>本文</p>" },
189
- { "type": "image", "id": "block-def", "url": "https://...", "alt": "キャプション", "maxWidth": 900 }
190
- ]
191
- ```
192
- 保存時は `contentBlocks`(JSON 文字列)・`content`(結合 HTML)・`contentRich`(同上)の3フィールドを同時更新。旧記事(`contentBlocks` なし)は単一テキストブロックとして後方互換ロード。
193
-
194
- ---
195
-
196
- ## 8. APIルート一覧
197
-
198
- ### ページルート(HTML 返却)
199
- | メソッド | パス | 役割 |
200
- | :--- | :--- | :--- |
201
- | GET | `/` | ブログ一覧(`?id=xxx` で記事詳細) |
202
- | GET | `/admin` | 管理者ログイン画面(認証済みなら `/admin/articles` へリダイレクト) |
203
- | POST | `/admin/login` | パスワード検証 → Cookie セット |
204
- | POST | `/admin/logout` | Cookie 削除 |
205
- | GET | `/admin/articles` | 記事管理一覧(要認証) |
206
- | GET | `/admin/new` | 新規記事エディタ(要認証) |
207
- | GET | `/admin/edit/{id}` | 既存記事エディタ(要認証) |
208
-
209
- ### API ルート(JSON 返却)
210
- | メソッド | パス | 役割 |
211
- | :--- | :--- | :--- |
212
- | POST | `/api/save` | 記事保存(microCMS PATCH/POST)・バックアップ自動作成 |
213
- | DELETE | `/api/article/{id}` | 記事削除 |
214
- | POST | `/api/upload-image` | 画像アップロード → microCMS media API |
215
- | GET | `/api/backups/{article_id}` | バックアップ一覧取得 |
216
- | POST | `/api/ai-decorate` | Gemini で HTML 装飾 |
217
- | POST | `/api/comment` | コメント投稿 |
218
-
219
- ---
220
-
221
- ## 9. 今後実装したい内容
222
- * **記事削除後もバックアップを保持**: 削除しても `blog-backup` は残し、後から復活できるようにする。
223
- * **microCMS リッチテキストエディタとの双方向同期**: `contentRich` のみ持つ旧記事をブロックエディタで編集可能にし、保存時に `contentRich` にも書き戻す。
224
- * **タグのオートコンプリート**: 既存タグを候補として表示し、入力を補助する。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -1,10 +1,20 @@
1
- FROM python:3.11-slim
 
2
  WORKDIR /app
3
- RUN apt-get update && apt-get install -y --no-install-recommends \
4
- libssl-dev libffi-dev build-essential && \
5
- rm -rf /var/lib/apt/lists/*
6
- COPY requirements.txt .
7
- RUN pip install --no-cache-dir -r requirements.txt
8
- COPY . .
9
- EXPOSE 7860
10
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.13.5-slim
2
+
3
  WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ build-essential \
7
+ curl \
8
+ git \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY requirements.txt ./
12
+ COPY src/ ./src/
13
+
14
+ RUN pip3 install -r requirements.txt
15
+
16
+ EXPOSE 8501
17
+
18
+ HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
19
+
20
+ ENTRYPOINT ["streamlit", "run", "src/streamlit_app.py", "--server.port=8501", "--server.address=0.0.0.0"]
README.md CHANGED
@@ -3,7 +3,9 @@ title: Wasemas9thblogs
3
  emoji: 🚀
4
  colorFrom: red
5
  colorTo: red
6
- sdk: docker
 
7
  pinned: false
8
- short_description: わせます9期ブログ
9
- ---
 
 
3
  emoji: 🚀
4
  colorFrom: red
5
  colorTo: red
6
+ sdk: streamlit
7
+ app_file: app.py
8
  pinned: false
9
+ short_description: Streamlit blog space
10
+ sdk_version: 1.57.0
11
+ ---
app.py ADDED
@@ -0,0 +1,729 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import google.generativeai as genai
3
+ import requests
4
+ import os
5
+ import re
6
+ import io
7
+ import time
8
+ from PIL import Image
9
+
10
+ GEMINI_KEY = os.environ.get("GEMINI_API_KEY")
11
+ CMS_SERVICE_ID = os.environ.get("MICROCMS_SERVICE_ID")
12
+ CMS_API_KEY = os.environ.get("MICROCMS_API_KEY")
13
+ ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "1234")
14
+ AUTHOR_LIST = ["にき", "かん", "なごみ", "蜜柑餅"]
15
+ SPACE_URL = "https://nagomi-sugarheart-wasemas9thblogs.hf.space"
16
+ ARTICLES_PER_PAGE = 10
17
+
18
+ TEMPLATES = {
19
+ "テンプレートなし": "",
20
+ "📅 イベントレポート": "<h2>イベント概要</h2><p>イベント名:<br>日時:<br>場所:</p><h2>内容</h2><p>ここに内容を書いてください。</p><h2>感想</h2><p>ここに感想を書いてください。</p>",
21
+ "📝 日常記事": "<h2>はじめに</h2><p>ここに導入を書いてください。</p><h2>本文</h2><p>ここに本文を書いてください。</p><h2>おわりに</h2><p>ここにまとめを書いてください。</p>",
22
+ "🎵 ライブ・コンサートレポート": "<h2>公演情報</h2><p>公演名:<br>日時:<br>会場:<br>出演:</p><h2>セットリスト</h2><p>1. <br>2. <br>3. </p><h2>見どころ・感想</h2><p>ここに感想を書いてください。</p>",
23
+ "📣 お知らせ": "<h2>お知らせ</h2><p>ここにお知らせ内容を書いてください。</p><h2>詳細</h2><p>日時:<br>場所:<br>参加方法:</p>",
24
+ }
25
+
26
+ if GEMINI_KEY:
27
+ genai.configure(api_key=GEMINI_KEY)
28
+
29
+ st.markdown("""
30
+ <style>
31
+ .ql-toolbar {
32
+ position: sticky !important;
33
+ top: 0 !important;
34
+ z-index: 999 !important;
35
+ background: white !important;
36
+ }
37
+ input, textarea, select {
38
+ font-size: 16px !important;
39
+ }
40
+ /* 戻るボタンや続きを読むのリンク装飾 */
41
+ .nav-link {
42
+ display: inline-block;
43
+ padding: 8px 16px;
44
+ background-color: #f0f2f6;
45
+ color: #31333F;
46
+ text-decoration: none;
47
+ border-radius: 8px;
48
+ font-size: 14px;
49
+ margin-bottom: 16px;
50
+ border: 1px solid #e0e0e0;
51
+ }
52
+ .nav-link:hover {
53
+ background-color: #e0e4eb;
54
+ color: #31333F;
55
+ }
56
+ .read-more {
57
+ display: inline-block;
58
+ margin-top: 8px;
59
+ font-size: 14px;
60
+ color: #0068c9;
61
+ text-decoration: none;
62
+ font-weight: bold;
63
+ }
64
+ .read-more:hover {
65
+ text-decoration: underline;
66
+ }
67
+ </style>
68
+ """, unsafe_allow_html=True)
69
+
70
+
71
+ def clean_ai_html(text):
72
+ text = re.sub(r'```html?', '', text, flags=re.IGNORECASE)
73
+ text = text.replace("```", "")
74
+ return text.strip()
75
+
76
+
77
+ def clean_quill_html(html):
78
+ html = re.sub(r'<figure>', '<div style="margin:8px 0;">', html)
79
+ html = re.sub(r'</figure>', '</div>', html)
80
+ html = re.sub(r'(<p>\s*<br\s*/?>\s*</p>)(\s*<p>\s*<img)', r'\2', html)
81
+ html = re.sub(r'(<p>\s*<img[^>]*/?>\s*</p>)(\s*<p>\s*<br\s*/?>\s*</p>)', r'\1', html)
82
+ html = re.sub(r'(<p>\s*<br\s*/?>\s*</p>)(\s*<img)', r'\2', html)
83
+ html = re.sub(r'(<img[^>]*/?>)(\s*<p>\s*<br\s*/?>\s*</p>)', r'\1', html)
84
+ html = re.sub(r'(<p>\s*<br\s*/?>\s*</p>)(\s*<div)', r'\2', html)
85
+ html = re.sub(r'(</div>)(\s*<p>\s*<br\s*/?>\s*</p>)', r'\1', html)
86
+ return html.strip()
87
+
88
+
89
+ def strip_html(html):
90
+ return re.sub(r'<[^>]*?>', '', html)
91
+
92
+
93
+ def make_snippet(content_html):
94
+ paragraphs = re.split(r'</p>|</h[1-6]>|<br\s*/?>', content_html)
95
+ for p in paragraphs:
96
+ text = strip_html(p).strip()
97
+ if text:
98
+ return text[:60] + "..." if len(text) > 60 else text
99
+ return ""
100
+
101
+
102
+ # スクロールバーのネストを解消し、自然なページ拡張を行うHTMLレンダリング関数
103
+ def render_html_native(html):
104
+ wrapped = f"""
105
+ <div class="blog-content">
106
+ <style>
107
+ .blog-content {{ font-family: sans-serif; font-size: 16px; color: #111; word-break: break-word; line-height: 1.6; }}
108
+ .blog-content img {{ max-width: 100%; height: auto; display: block; border-radius: 8px; margin: 16px 0; }}
109
+ .blog-content p {{ margin: 0.8em 0; }}
110
+ .blog-content h1, .blog-content h2, .blog-content h3 {{ margin: 1.2em 0 0.6em; border-bottom: 1px solid #eee; padding-bottom: 4px; }}
111
+ .blog-content a {{ color: #0068c9; text-decoration: none; }}
112
+ .blog-content a:hover {{ text-decoration: underline; }}
113
+ .blog-content blockquote {{ border-left: 4px solid #ddd; margin: 0; padding-left: 16px; color: #555; }}
114
+ </style>
115
+ {html}
116
+ </div>
117
+ """
118
+ st.markdown(wrapped, unsafe_allow_html=True)
119
+
120
+
121
+ def get_tags(article):
122
+ tags_str = article.get("tags", "")
123
+ return [t.strip() for t in tags_str.split(',') if t.strip()] if tags_str else []
124
+
125
+
126
+ def render_tag_badges(tags):
127
+ if not tags:
128
+ return
129
+ badges = ' '.join([
130
+ f'<span style="background:#e8f4f8;border-radius:12px;padding:2px 10px;'
131
+ f'font-size:12px;margin:2px;display:inline-block;color:#2c7bb6;">#{t}</span>'
132
+ for t in tags
133
+ ])
134
+ st.markdown(badges, unsafe_allow_html=True)
135
+
136
+
137
+ def upload_image_to_microcms(image_file, max_width=1200):
138
+ try:
139
+ img = Image.open(image_file)
140
+ try:
141
+ import PIL.ExifTags
142
+ exif = img._getexif()
143
+ if exif:
144
+ for tag, val in exif.items():
145
+ if PIL.ExifTags.TAGS.get(tag) == 'Orientation':
146
+ if val == 3: img = img.rotate(180, expand=True)
147
+ elif val == 6: img = img.rotate(270, expand=True)
148
+ elif val == 8: img = img.rotate(90, expand=True)
149
+ except:
150
+ pass
151
+
152
+ if img.width > max_width:
153
+ ratio = max_width / img.width
154
+ img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
155
+
156
+ buf = io.BytesIO()
157
+ if img.mode in ("RGBA", "P"):
158
+ img = img.convert("RGB")
159
+ img.save(buf, format="JPEG", quality=85)
160
+ buf.seek(0)
161
+
162
+ url = f"https://{CMS_SERVICE_ID}.microcms-management.io/api/v1/media"
163
+ headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
164
+ files = {"file": ("upload.jpg", buf, "image/jpeg")}
165
+ res = requests.post(url, headers=headers, files=files)
166
+
167
+ if res.status_code not in [200, 201]:
168
+ st.error(f"アップロードエラー詳細: {res.status_code} / {res.text}")
169
+ return None
170
+ return res.json().get("url")
171
+ except Exception as e:
172
+ st.error(f"画像アップロードエラー: {e}")
173
+ return None
174
+
175
+
176
+ @st.cache_data(ttl=60)
177
+ def get_articles(limit=ARTICLES_PER_PAGE, offset=0):
178
+ if not CMS_SERVICE_ID or not CMS_API_KEY:
179
+ return [], 0
180
+ url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
181
+ headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
182
+ try:
183
+ response = requests.get(url, headers=headers, params={"limit": limit, "offset": offset})
184
+ response.raise_for_status()
185
+ data = response.json()
186
+ return data.get("contents", []), data.get("totalCount", 0)
187
+ except:
188
+ return [], 0
189
+
190
+
191
+ @st.cache_data(ttl=60)
192
+ def get_all_articles():
193
+ if not CMS_SERVICE_ID or not CMS_API_KEY:
194
+ return []
195
+ url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
196
+ headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
197
+ try:
198
+ response = requests.get(url, headers=headers, params={"limit": 100})
199
+ response.raise_for_status()
200
+ return response.json().get("contents", [])
201
+ except:
202
+ return []
203
+
204
+
205
+ def get_comments(article_id):
206
+ if not CMS_SERVICE_ID or not CMS_API_KEY:
207
+ return []
208
+ url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/comments"
209
+ headers = {"X-MICROCMS-API-KEY": CMS_API_KEY}
210
+ try:
211
+ response = requests.get(url, headers=headers, params={
212
+ "filters": f"articleId[equals]{article_id}",
213
+ "limit": 100,
214
+ "orders": "createdAt",
215
+ })
216
+ response.raise_for_status()
217
+ return response.json().get("contents", [])
218
+ except:
219
+ return []
220
+
221
+
222
+ def post_comment(article_id, name, body):
223
+ if not CMS_SERVICE_ID or not CMS_API_KEY:
224
+ return False
225
+ url = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/comments"
226
+ headers = {"X-MICROCMS-API-KEY": CMS_API_KEY, "Content-Type": "application/json"}
227
+ try:
228
+ res = requests.post(url, headers=headers, json={"articleId": article_id, "name": name, "body": body})
229
+ return res.status_code in [200, 201]
230
+ except:
231
+ return False
232
+
233
+
234
+ params = st.query_params
235
+
236
+ if "id" in params:
237
+ article_id = params["id"]
238
+ all_articles = get_all_articles()
239
+ article = next((a for a in all_articles if a["id"] == article_id), None)
240
+
241
+ if article:
242
+ # ブラウザの「戻る」が機能するリンク式の戻るボタン
243
+ st.markdown('<a href="?" target="_self" class="nav-link">← 一覧に戻る</a>', unsafe_allow_html=True)
244
+
245
+ eyecatch = article.get("eyecatch")
246
+ if eyecatch and isinstance(eyecatch, dict):
247
+ st.image(eyecatch.get("url", ""), use_container_width=True)
248
+
249
+ title_text = article.get("title", "無題")
250
+ st.title(title_text)
251
+ st.caption(f"👤 {article.get('author')} | 📅 {article.get('publishedAt')[:10]}")
252
+
253
+ article_tags = get_tags(article)
254
+ render_tag_badges(article_tags)
255
+
256
+ st.divider()
257
+ content = clean_quill_html(article.get("content", ""))
258
+ # スクロールバーなしでネイティブ描画
259
+ render_html_native(content)
260
+ st.divider()
261
+
262
+ # 共有リンクの改善 (コピーしやすい形式へ)
263
+ share_url = f"{SPACE_URL}?id={article_id}"
264
+ share_text = f"【記事】{title_text}\n{share_url}"
265
+ st.write("🔗 **この記事をシェアする** (右上のコピーボタンを押してください)")
266
+ st.code(share_text, language="")
267
+
268
+ st.divider()
269
+ st.subheader("💬 コメント")
270
+ comments = get_comments(article_id)
271
+ if comments:
272
+ for c in comments:
273
+ c_name = c.get("name", "名無し")
274
+ c_body = c.get("body", "")
275
+ c_date = c.get("createdAt", "")[:10]
276
+ st.markdown(f"**{c_name}** · {c_date}")
277
+ st.write(c_body)
278
+ st.divider()
279
+ else:
280
+ st.caption("まだコメントはありません。")
281
+
282
+ st.subheader("コメントを投稿")
283
+ with st.form("comment_form", clear_on_submit=True):
284
+ comment_name = st.text_input("お名前", max_chars=50)
285
+ comment_body = st.text_area("コメント", max_chars=500, height=120)
286
+ submitted = st.form_submit_button("投稿する", use_container_width=True)
287
+ if submitted:
288
+ if not comment_name.strip():
289
+ st.error("お名前を入力してください")
290
+ elif not comment_body.strip():
291
+ st.error("コメントを入力してください")
292
+ else:
293
+ if post_comment(article_id, comment_name.strip(), comment_body.strip()):
294
+ st.success("投稿しました!")
295
+ st.rerun()
296
+ else:
297
+ st.error("投稿に失敗しました。時間をおいて再試行してください。")
298
+ else:
299
+ st.error("記事が見つかりませんでした。")
300
+ st.markdown('<a href="?" target="_self" class="nav-link">← 一覧に戻る</a>', unsafe_allow_html=True)
301
+
302
+ else:
303
+ with st.sidebar:
304
+ st.title("⚙️ 設定")
305
+ model_choice = st.selectbox("使用するAIモデル", ["gemini-2.5-flash", "gemini-2.5-pro"], index=0)
306
+ show_drafts_in_view = st.checkbox("閲覧画面で下書きも表示する", value=False)
307
+
308
+ tab_view, tab_manage = st.tabs(["📖 ブログを読む", "🛠️ 記事の管理・投稿"])
309
+
310
+ with tab_view:
311
+ st.title("サークル活動ブログ")
312
+
313
+ all_articles = get_all_articles()
314
+ all_tags = sorted(set(tag for a in all_articles for tag in get_tags(a)))
315
+
316
+ search_col1, search_col2, search_col3 = st.columns(3)
317
+ with search_col1:
318
+ search_query = st.text_input("🔍 本文・タイトル検索", "", key="search_text")
319
+ with search_col2:
320
+ author_filter = st.selectbox("👤 著者で絞り込み", ["全員"] + AUTHOR_LIST, key="search_author")
321
+ with search_col3:
322
+ tag_filter = st.selectbox("🏷️ タグで絞り込み", ["全タグ"] + all_tags, key="tag_filter")
323
+
324
+ if "view_page" not in st.session_state: st.session_state.view_page = 0
325
+ if "last_search" not in st.session_state: st.session_state.last_search = ""
326
+ if search_query != st.session_state.last_search:
327
+ st.session_state.view_page = 0
328
+ st.session_state.last_search = search_query
329
+
330
+ filtered_articles = []
331
+ for a in all_articles:
332
+ title = a.get("title", "無題")
333
+ content_html = a.get("content", "")
334
+ author = a.get("author", "不明")
335
+
336
+ if title.startswith("【下書き】") and not show_drafts_in_view:
337
+ continue
338
+
339
+ article_tags = get_tags(a)
340
+ clean_text = strip_html(content_html)
341
+
342
+ author_match = (author_filter == "全員") or (author_filter == author)
343
+ text_match = (not search_query) or (
344
+ search_query.lower() in title.lower() or search_query.lower() in clean_text.lower()
345
+ )
346
+ tag_match = (tag_filter == "全タグ") or (tag_filter in article_tags)
347
+
348
+ if author_match and text_match and tag_match:
349
+ filtered_articles.append(a)
350
+
351
+ total_count = len(filtered_articles)
352
+ total_pages = max(1, -(-total_count // ARTICLES_PER_PAGE))
353
+ if st.session_state.view_page >= total_pages:
354
+ st.session_state.view_page = 0
355
+
356
+ offset = st.session_state.view_page * ARTICLES_PER_PAGE
357
+ page_articles = filtered_articles[offset:offset + ARTICLES_PER_PAGE]
358
+
359
+ for a in page_articles:
360
+ title = a.get("title", "無題")
361
+ content_html = a.get("content", "")
362
+ author = a.get("author", "不明")
363
+ pub_date = a.get("publishedAt", "-----")[:10]
364
+ eyecatch = a.get("eyecatch")
365
+ snippet = make_snippet(content_html)
366
+ article_tags = get_tags(a)
367
+ article_url_params = f"?id={a['id']}"
368
+
369
+ with st.container():
370
+ # サムネイル画像のレイアウト(1:3の比率で小さく表示)
371
+ if eyecatch and isinstance(eyecatch, dict):
372
+ col_img, col_txt = st.columns([1, 3])
373
+ with col_img:
374
+ st.image(eyecatch.get("url", ""), use_container_width=True)
375
+ with col_txt:
376
+ st.subheader(title)
377
+ st.caption(f"👤 {author} | 📅 {pub_date}")
378
+ render_tag_badges(article_tags)
379
+ st.write(f"<div style='color:#555; font-size:14px; margin: 8px 0;'>{snippet}</div>", unsafe_allow_html=True)
380
+ st.markdown(f'<a href="{article_url_params}" target="_self" class="read-more">続きを読む →</a>', unsafe_allow_html=True)
381
+ else:
382
+ st.subheader(title)
383
+ st.caption(f"👤 {author} | 📅 {pub_date}")
384
+ render_tag_badges(article_tags)
385
+ st.write(f"<div style='color:#555; font-size:14px; margin: 8px 0;'>{snippet}</div>", unsafe_allow_html=True)
386
+ st.markdown(f'<a href="{article_url_params}" target="_self" class="read-more">続きを読む →</a>', unsafe_allow_html=True)
387
+
388
+ st.divider()
389
+
390
+ if total_count > ARTICLES_PER_PAGE:
391
+ st.caption(f"{total_count}件中 {offset+1}〜{min(offset+ARTICLES_PER_PAGE, total_count)}件を表示")
392
+ col_prev, col_page, col_next = st.columns([1, 2, 1])
393
+ with col_prev:
394
+ if st.session_state.view_page > 0:
395
+ if st.button("← 前へ", use_container_width=True):
396
+ st.session_state.view_page -= 1
397
+ st.rerun()
398
+ with col_page:
399
+ st.markdown(
400
+ f"<p style='text-align:center;'>{st.session_state.view_page + 1} / {total_pages} ページ</p>",
401
+ unsafe_allow_html=True
402
+ )
403
+ with col_next:
404
+ if st.session_state.view_page < total_pages - 1:
405
+ if st.button("次へ →", use_container_width=True):
406
+ st.session_state.view_page += 1
407
+ st.rerun()
408
+
409
+ with tab_manage:
410
+ st.title("管理画面")
411
+ password = st.text_input("合言葉", type="password")
412
+
413
+ if password == ADMIN_PASSWORD:
414
+ st.success("ログイン成功!")
415
+
416
+ if "edit_id" not in st.session_state: st.session_state.edit_id = None
417
+ if "edit_data" not in st.session_state: st.session_state.edit_data = {"title": "", "author": AUTHOR_LIST[0], "content": ""}
418
+ if "edit_tags" not in st.session_state: st.session_state.edit_tags = []
419
+ if "editor_mode" not in st.session_state: st.session_state.editor_mode = "rich"
420
+ if "working_html" not in st.session_state: st.session_state.working_html = ""
421
+ if "eyecatch_url" not in st.session_state: st.session_state.eyecatch_url = ""
422
+ if "inline_image_urls" not in st.session_state: st.session_state.inline_image_urls = []
423
+ if "delete_confirm_id" not in st.session_state: st.session_state.delete_confirm_id = None
424
+ if "quill_key" not in st.session_state: st.session_state.quill_key = 0
425
+
426
+ # タグ入力欄のステート初期化
427
+ if "tags_input_field" not in st.session_state: st.session_state.tags_input_field = ""
428
+
429
+ st.divider()
430
+ mode_label = f"📝 記事の編集 (ID: {st.session_state.edit_id})" if st.session_state.edit_id else "✍️ 新規記事の投稿"
431
+ st.subheader(mode_label)
432
+
433
+ col_t, col_a = st.columns([2, 1])
434
+ with col_t:
435
+ title_input = st.text_input("記事タイトル", value=st.session_state.edit_data["title"])
436
+ with col_a:
437
+ try:
438
+ def_auth_idx = AUTHOR_LIST.index(st.session_state.edit_data["author"])
439
+ except:
440
+ def_auth_idx = 0
441
+ author_select = st.selectbox("著者", AUTHOR_LIST, index=def_auth_idx)
442
+
443
+ tags_input = st.text_input(
444
+ "🏷️ タグ(カンマ区切り)",
445
+ placeholder="例: イベント, 日常, ライブ",
446
+ key="tags_input_field"
447
+ )
448
+
449
+ st.divider()
450
+ st.subheader("📋 テンプレート")
451
+ template_choice = st.selectbox("テンプレートを選択", list(TEMPLATES.keys()), key="template_select")
452
+ if st.button("✅ このテンプレートを適用"):
453
+ selected_html = TEMPLATES[template_choice]
454
+ if selected_html:
455
+ st.session_state.working_html = selected_html
456
+ st.session_state.quill_key += 1
457
+ st.rerun()
458
+ else:
459
+ st.warning("テンプレートなしが選択されています")
460
+
461
+ st.divider()
462
+ st.subheader("🖼️ キャッチ画像(サムネイル)")
463
+ if st.session_state.eyecatch_url:
464
+ st.image(st.session_state.eyecatch_url, width=300)
465
+ if st.button("🗑️ キャッチ画像を削除"):
466
+ st.session_state.eyecatch_url = ""
467
+ st.rerun()
468
+ else:
469
+ eyecatch_file = st.file_uploader("キャッチ画像をアップロード(JPG/PNG)", type=["jpg", "jpeg", "png"], key="eyecatch_uploader")
470
+ if eyecatch_file:
471
+ col_ec1, col_ec2 = st.columns(2)
472
+ with col_ec1:
473
+ ec_max_w = st.number_input("最大横幅(px)", min_value=200, max_value=2000, value=1200, step=100, key="ec_maxw")
474
+ with col_ec2:
475
+ st.write("")
476
+ if st.button("☁️ アップロード", key="ec_upload_btn"):
477
+ with st.spinner("アップロード中..."):
478
+ url = upload_image_to_microcms(eyecatch_file, max_width=int(ec_max_w))
479
+ if url:
480
+ st.session_state.eyecatch_url = url
481
+ st.success("アップロード成功!")
482
+ st.rerun()
483
+
484
+ st.divider()
485
+ st.subheader("📷 本文内差し込み画像")
486
+ st.caption("アップロード後、ボタンでエディタに挿入またはHTMLタグをコピーしてください")
487
+
488
+ inline_files = st.file_uploader("差し込み画像(複数選択可)", type=["jpg", "jpeg", "png"], accept_multiple_files=True, key="inline_uploader")
489
+
490
+ col_in1, col_in2 = st.columns(2)
491
+ with col_in1:
492
+ inline_max_w = st.number_input("最大横幅(px)", min_value=200, max_value=2000, value=900, step=100, key="inline_maxw")
493
+ with col_in2:
494
+ st.write("")
495
+ if st.button("☁️ 選択画像を一括アップロード", key="inline_upload_btn"):
496
+ if inline_files:
497
+ new_urls = []
498
+ for f in inline_files:
499
+ with st.spinner(f"{f.name} をアップロード中..."):
500
+ url = upload_image_to_microcms(f, max_width=int(inline_max_w))
501
+ if url:
502
+ new_urls.append({"name": f.name, "url": url, "max_w": int(inline_max_w)})
503
+ st.session_state.inline_image_urls.extend(new_urls)
504
+ if new_urls:
505
+ st.success(f"{len(new_urls)}枚アップロード完了!")
506
+ st.rerun()
507
+ else:
508
+ st.warning("画像を選択してください")
509
+
510
+ if st.session_state.inline_image_urls:
511
+ st.markdown("**アップロード済み差し込み画像**")
512
+ for i, img_info in enumerate(st.session_state.inline_image_urls):
513
+ max_w = img_info.get("max_w", 900)
514
+ img_tag = f'<img src="{img_info["url"]}" alt="{img_info["name"]}" style="width:{max_w}px;max-width:100%;height:auto;display:block;" />'
515
+ with st.container():
516
+ col_il1, col_il2 = st.columns([1, 2])
517
+ with col_il1:
518
+ st.caption(img_info["name"])
519
+ st.image(img_info["url"], width=100)
520
+ with col_il2:
521
+ st.code(img_tag, language=None)
522
+ col_btn1, col_btn2 = st.columns(2)
523
+ with col_btn1:
524
+ if st.session_state.editor_mode == "rich":
525
+ if st.button("📌 エディタに挿入", key=f"insert_btn_{i}", use_container_width=True):
526
+ st.session_state.working_html += img_tag
527
+ st.session_state.quill_key += 1
528
+ st.rerun()
529
+ with col_btn2:
530
+ if st.button("🗑️ 削除", key=f"del_inline_{i}", use_container_width=True):
531
+ st.session_state.inline_image_urls.pop(i)
532
+ st.rerun()
533
+ st.divider()
534
+
535
+ st.divider()
536
+ st.subheader("📝 本文エディタ")
537
+ col_mode1, col_mode2 = st.columns(2)
538
+ with col_mode1:
539
+ if st.button("🖊️ リッチテキストモード", use_container_width=True,
540
+ type="primary" if st.session_state.editor_mode == "rich" else "secondary"):
541
+ st.session_state.editor_mode = "rich"
542
+ st.rerun()
543
+ with col_mode2:
544
+ if st.button("💻 HTMLモード", use_container_width=True,
545
+ type="primary" if st.session_state.editor_mode == "html" else "secondary"):
546
+ st.session_state.editor_mode = "html"
547
+ st.rerun()
548
+
549
+ if st.session_state.editor_mode == "rich":
550
+ try:
551
+ from streamlit_quill import st_quill
552
+ rich_html = st_quill(
553
+ value=st.session_state.working_html,
554
+ html=True,
555
+ toolbar=[
556
+ ["bold", "italic", "underline", "strike"],
557
+ [{"header": [1, 2, 3, False]}],
558
+ [{"color": []}, {"background": []}],
559
+ [{"list": "ordered"}, {"list": "bullet"}],
560
+ ["blockquote", "code-block"],
561
+ ["link"],
562
+ ["clean"]
563
+ ],
564
+ key=f"quill_editor_{st.session_state.quill_key}"
565
+ )
566
+ if rich_html is not None:
567
+ st.session_state.working_html = rich_html
568
+ except ImportError:
569
+ st.error("streamlit-quill が未インストールです。")
570
+ else:
571
+ edited_html = st.text_area("HTMLコード(直接編集)", value=st.session_state.working_html, height=300, key="html_editor")
572
+ st.session_state.working_html = edited_html
573
+
574
+ st.markdown("---")
575
+ raw_content = st.text_area("✨ AI装飾用メモ", height=120, key="ai_raw")
576
+ if st.button("✨ AIモードで装飾"):
577
+ if raw_content:
578
+ try:
579
+ model = genai.GenerativeModel(model_name=model_choice)
580
+ prompt = f"以下の文章をブログ記事用に装飾してください。本文の内容は全く変更をしないで装飾のみに務めること。装飾はモダンでオシャレに。HTMLのみ出力すること。本文: {raw_content}"
581
+ with st.spinner("AIが執筆中..."):
582
+ ai_response = model.generate_content(prompt)
583
+ st.session_state.working_html = clean_ai_html(ai_response.text)
584
+ st.rerun()
585
+ except Exception as e:
586
+ st.error(f"AIエラー: {e}")
587
+ else:
588
+ st.warning("AI装飾用メモを入力してください")
589
+
590
+ with st.expander("🪄 AIにデザインの修正を指示する"):
591
+ feedback_input = st.text_input("修正内容", key="ai_feedback")
592
+ if st.button("再修正を実行"):
593
+ if feedback_input and st.session_state.working_html:
594
+ try:
595
+ model = genai.GenerativeModel(model_name=model_choice)
596
+ feedback_prompt = f"""現在のHTMLコードを、以下の指示に基づいて修正してください。
597
+ 本文の内容は絶対に変更せず、HTMLタグやスタイルのみを変更すること。
598
+ 出力は純粋なHTMLコードのみを返してください。
599
+ 【現在のHTML】{st.session_state.working_html}
600
+ 【修正指示】{feedback_input}"""
601
+ with st.spinner("再修正中..."):
602
+ ai_response = model.generate_content(feedback_prompt)
603
+ st.session_state.working_html = clean_ai_html(ai_response.text)
604
+ st.rerun()
605
+ except Exception as e:
606
+ st.error(f"AIエラー: {e}")
607
+
608
+ if st.session_state.working_html:
609
+ st.divider()
610
+ st.subheader("👀 プレビュー")
611
+ render_html_native(clean_quill_html(st.session_state.working_html))
612
+
613
+ st.divider()
614
+ col_save1, col_save2 = st.columns(2)
615
+ with col_save1:
616
+ default_is_draft = title_input.startswith("【下書き】")
617
+ status_save = st.radio("保存ステータス", ["公開", "下書き"], index=1 if default_is_draft else 0, horizontal=True)
618
+ with col_save2:
619
+ if st.session_state.edit_id:
620
+ if st.button("✖ 編集をキャンセル"):
621
+ st.session_state.edit_id = None
622
+ st.session_state.edit_data = {"title": "", "author": AUTHOR_LIST[0], "content": ""}
623
+ st.session_state.edit_tags = []
624
+ st.session_state.tags_input_field = ""
625
+ st.session_state.working_html = ""
626
+ st.session_state.eyecatch_url = ""
627
+ st.session_state.inline_image_urls = []
628
+ st.session_state.editor_mode = "rich"
629
+ st.session_state.quill_key += 1
630
+ st.rerun()
631
+
632
+ if st.button("💾 この内容で保存する", use_container_width=True):
633
+ if not title_input:
634
+ st.error("タイトルを入力してください")
635
+ elif not st.session_state.working_html:
636
+ st.error("本文を入力してください")
637
+ else:
638
+ url_base = f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog"
639
+ headers = {"X-MICROCMS-API-KEY": CMS_API_KEY, "Content-Type": "application/json"}
640
+ clean_title = title_input.replace("【下書き】", "").strip()
641
+ save_title = f"【下書き】{clean_title}" if status_save == "下書き" else clean_title
642
+ tag_list = [t.strip() for t in tags_input.split(',') if t.strip()]
643
+ cleaned_content = clean_quill_html(st.session_state.working_html)
644
+ post_data = {"title": save_title, "content": cleaned_content, "author": author_select, "tags": ','.join(tag_list)}
645
+ if st.session_state.eyecatch_url:
646
+ post_data["eyecatch"] = st.session_state.eyecatch_url
647
+
648
+ try:
649
+ if st.session_state.edit_id:
650
+ res = requests.patch(f"{url_base}/{st.session_state.edit_id}", headers=headers, json=post_data)
651
+ else:
652
+ res = requests.post(url_base, headers=headers, json=post_data)
653
+
654
+ if res.status_code in [200, 201]:
655
+ st.success("保存完了!")
656
+ st.cache_data.clear()
657
+ st.session_state.edit_id = None
658
+ st.session_state.edit_data = {"title": "", "author": AUTHOR_LIST[0], "content": ""}
659
+ st.session_state.edit_tags = []
660
+ st.session_state.tags_input_field = ""
661
+ st.session_state.working_html = ""
662
+ st.session_state.eyecatch_url = ""
663
+ st.session_state.inline_image_urls = []
664
+ st.session_state.editor_mode = "rich"
665
+ st.session_state.quill_key += 1
666
+ st.rerun()
667
+ else:
668
+ st.error(f"エラー: {res.text}")
669
+ except Exception as e:
670
+ st.error(f"通信エラー: {e}")
671
+
672
+ st.divider()
673
+ st.subheader("📋 既存記事の管理")
674
+ manage_articles = get_all_articles()
675
+ for ma in manage_articles:
676
+ ma_id = ma["id"]
677
+ ma_title = ma.get("title", "無題")
678
+ ma_tags = get_tags(ma)
679
+ col_m1, col_m2, col_m3 = st.columns([3, 1, 1])
680
+ with col_m1:
681
+ st.write(f"**{ma_title}**")
682
+ st.caption(f"ID: {ma_id} | {ma.get('author')}")
683
+ render_tag_badges(ma_tags)
684
+ with col_m2:
685
+ if st.button("📝 編集", key=f"edit_btn_{ma_id}"):
686
+ st.session_state.edit_id = ma_id
687
+ st.session_state.edit_data = {"title": ma.get("title"), "author": ma.get("author"), "content": ma.get("content", "")}
688
+
689
+ # タグのセッションステートを確実に入力フィールドへ連携
690
+ extracted_tags = get_tags(ma)
691
+ st.session_state.edit_tags = extracted_tags
692
+ st.session_state.tags_input_field = ', '.join(extracted_tags)
693
+
694
+ st.session_state.working_html = ma.get("content", "")
695
+ ec = ma.get("eyecatch")
696
+ st.session_state.eyecatch_url = ec.get("url", "") if isinstance(ec, dict) else (ec or "")
697
+ st.session_state.inline_image_urls = []
698
+ st.session_state.editor_mode = "rich"
699
+ st.session_state.quill_key += 1
700
+ st.rerun()
701
+ with col_m3:
702
+ if st.session_state.delete_confirm_id == ma_id:
703
+ st.warning("本当に削除しますか?")
704
+ col_yes, col_no = st.columns(2)
705
+ with col_yes:
706
+ if st.button("✅ はい", key=f"del_yes_{ma_id}"):
707
+ res = requests.delete(
708
+ f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/blog/{ma_id}",
709
+ headers={"X-MICROCMS-API-KEY": CMS_API_KEY}
710
+ )
711
+ st.cache_data.clear()
712
+ st.session_state.delete_confirm_id = None
713
+ if res.status_code in [200, 202, 204]:
714
+ st.success("削除完了")
715
+ else:
716
+ st.error(f"削除失敗: {res.status_code}")
717
+ st.rerun()
718
+ with col_no:
719
+ if st.button("❌ いいえ", key=f"del_no_{ma_id}"):
720
+ st.session_state.delete_confirm_id = None
721
+ st.rerun()
722
+ else:
723
+ if st.button("🗑️ ��除", key=f"del_btn_{ma_id}"):
724
+ st.session_state.delete_confirm_id = ma_id
725
+ st.rerun()
726
+ st.divider()
727
+
728
+ elif password != "":
729
+ st.error("合言葉が違います。")
main.py DELETED
@@ -1,687 +0,0 @@
1
- from fastapi import FastAPI, Request, Response, UploadFile, File, Form, HTTPException
2
- from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
3
- from fastapi.templating import Jinja2Templates
4
- import os, re, io, json, hashlib
5
- from datetime import datetime, timezone, timedelta
6
- JST = timezone(timedelta(hours=9))
7
- import requests as http_requests
8
- from PIL import Image
9
- try:
10
- from pillow_heif import register_heif_opener
11
- register_heif_opener()
12
- except ImportError:
13
- pass
14
- try:
15
- import google.generativeai as genai
16
- GENAI_AVAILABLE = True
17
- except Exception:
18
- genai = None
19
- GENAI_AVAILABLE = False
20
-
21
- app = FastAPI()
22
- templates = Jinja2Templates(directory="templates")
23
-
24
- GEMINI_KEY = os.environ.get("GEMINI_API_KEY")
25
- CMS_SERVICE_ID = os.environ.get("MICROCMS_SERVICE_ID")
26
- CMS_API_KEY = os.environ.get("MICROCMS_API_KEY")
27
- ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD", "1234").strip()
28
- SECRET_KEY = os.environ.get("SECRET_KEY", "wasemas-secret-key")
29
- AUTHOR_LIST = ["にき", "かん", "なごみ", "蜜柑餅","メロン"]
30
- ARTICLES_PER_PAGE = 10
31
- SPACE_URL = os.environ.get("SPACE_URL", "")
32
- GEMINI_MODEL = "gemini-2.5-flash"
33
-
34
- TEMPLATES = {
35
- "テンプレートなし": "",
36
- "📅 イベントレポート": "<h2>イベント概要</h2><p>イベント名:<br>日時:<br>場所:</p><h2>内容</h2><p>ここに内容を書いてください。</p><h2>感想</h2><p>ここに感想を書いてください。</p>",
37
- "📝 日常記事": "<h2>はじめに</h2><p>ここに導入を書いてください。</p><h2>本文</h2><p>ここに本文を書いてください。</p><h2>おわりに</h2><p>ここにまとめを書いてください。</p>",
38
- "🎵 ライブ・コンサートレポート": "<h2>公演情報</h2><p>公演名:<br>日時:<br>会場:<br>出演:</p><h2>セットリスト</h2><p>1. <br>2. <br>3. </p><h2>見どころ・感想</h2><p>ここに感想を書いてください。</p>",
39
- "📣 お知らせ": "<h2>お知らせ</h2><p>ここにお知らせ内容を書いてください。</p><h2>詳細</h2><p>日時:<br>場所:<br>参加方法:</p>",
40
- }
41
-
42
- if GEMINI_KEY and GENAI_AVAILABLE:
43
- genai.configure(api_key=GEMINI_KEY)
44
-
45
-
46
- # ─── 認証 ───────────────────────────────────────────────
47
-
48
- def _auth_token() -> str:
49
- return hashlib.sha256(f"{ADMIN_PASSWORD}:{SECRET_KEY}".encode()).hexdigest()
50
-
51
- def set_auth_cookie(response: Response):
52
- response.set_cookie("auth", _auth_token(), httponly=True,
53
- max_age=86400 * 30, samesite="lax", path="/")
54
-
55
- def get_auth(request: Request) -> bool:
56
- return request.cookies.get("auth") == _auth_token()
57
-
58
-
59
- # ─── ユーティリティ ──────────────────────────────────────
60
-
61
- def strip_html(html: str) -> str:
62
- return re.sub(r"<[^>]*?>", "", html)
63
-
64
- def get_tags(article: dict) -> list:
65
- s = article.get("tags", "")
66
- return [t.strip() for t in s.split(",") if t.strip()] if s else []
67
-
68
- def make_snippet(html: str) -> str:
69
- parts = re.split(r"</p>|</h[1-6]>|<br\s*/?>", html)
70
- for p in parts:
71
- t = strip_html(p).strip()
72
- if t:
73
- return t[:60] + "..." if len(t) > 60 else t
74
- return ""
75
-
76
- def get_content(article: dict) -> str:
77
- rich = article.get("contentRich", "")
78
- return rich if rich else article.get("content", "")
79
-
80
- def clean_html(html: str) -> str:
81
- if not html:
82
- return ""
83
- html = re.sub(r"<figure>", '<div style="margin:8px 0;">', html)
84
- html = re.sub(r"</figure>", "</div>", html)
85
- html = re.sub(r"(<p>\s*<br\s*/?>\s*</p>)(\s*<p>\s*<img)", r"\2", html)
86
- html = re.sub(r"(<p>\s*<img[^>]*/?>\\s*</p>)(\s*<p>\s*<br\s*/?>\s*</p>)", r"\1", html)
87
- return html.strip()
88
-
89
- def render_blocks_to_html(blocks: list) -> str:
90
- parts = []
91
- for b in blocks:
92
- if b.get("type") == "text":
93
- parts.append(b.get("html", ""))
94
- elif b.get("type") == "image":
95
- url = b.get("url", "")
96
- alt = b.get("alt", "")
97
- max_w = b.get("maxWidth", 900)
98
- if url:
99
- caption = (f'<p style="text-align:center;font-size:0.8em;color:#9ca3af;margin-top:4px;">{alt}</p>'
100
- if alt else "")
101
- parts.append(
102
- f'<figure style="margin:8px 0;">'
103
- f'<img src="{url}" alt="{alt}" '
104
- f'style="width:{max_w}px;max-width:100%;height:auto;display:block;margin:0 auto;" />'
105
- f'{caption}</figure>'
106
- )
107
- return "\n".join(parts)
108
-
109
- def blocks_from_article(article: dict) -> list:
110
- cb = article.get("contentBlocks", "")
111
- if cb:
112
- try:
113
- return json.loads(cb)
114
- except Exception:
115
- pass
116
- html = article.get("contentRich", "") or article.get("content", "")
117
- if html:
118
- return [{"type": "text", "id": "block-0", "html": html}]
119
- return [{"type": "text", "id": "block-0", "html": ""}]
120
-
121
- def filter_articles(articles: list, q: str, author: str, tag: str) -> list:
122
- result = []
123
- for a in articles:
124
- title = a.get("title", "")
125
- if title.startswith("【下書き】"):
126
- continue
127
- if q and q.lower() not in title.lower() and q.lower() not in strip_html(get_content(a)).lower():
128
- continue
129
- if author and author != "全員" and a.get("author") != author:
130
- continue
131
- if tag and tag != "全タグ" and tag not in get_tags(a):
132
- continue
133
- result.append(a)
134
- return result
135
-
136
-
137
- # ─── microCMS API ─────────────────────────────────────────
138
-
139
- CMS_HEADERS_GET = lambda: {"X-MICROCMS-API-KEY": CMS_API_KEY}
140
- CMS_HEADERS_POST = lambda: {"X-MICROCMS-API-KEY": CMS_API_KEY, "Content-Type": "application/json"}
141
-
142
- def cms_url(path: str) -> str:
143
- return f"https://{CMS_SERVICE_ID}.microcms.io/api/v1/{path}"
144
-
145
- def get_all_articles() -> list:
146
- try:
147
- r = http_requests.get(cms_url("blog"), headers=CMS_HEADERS_GET(), params={"limit": 100})
148
- return r.json().get("contents", [])
149
- except Exception:
150
- return []
151
-
152
- def get_article(article_id: str) -> dict | None:
153
- try:
154
- r = http_requests.get(cms_url(f"blog/{article_id}"), headers=CMS_HEADERS_GET())
155
- return r.json() if r.status_code == 200 else None
156
- except Exception:
157
- return None
158
-
159
- def get_comments(article_id: str) -> list:
160
- all_comments = []
161
- offset = 0
162
- limit = 100
163
- try:
164
- while True:
165
- r = http_requests.get(cms_url("comments"), headers=CMS_HEADERS_GET(), params={
166
- "filters": f"articleId[equals]{article_id}",
167
- "limit": limit, "offset": offset, "orders": "createdAt",
168
- })
169
- data = r.json()
170
- all_comments.extend(data.get("contents", []))
171
- if offset + limit >= data.get("totalCount", 0):
172
- break
173
- offset += limit
174
- return all_comments
175
- except Exception:
176
- return []
177
-
178
- def post_comment_api(article_id: str, name: str, body: str, parent_id: str = "") -> bool:
179
- try:
180
- payload = {"articleId": article_id, "name": name, "body": body}
181
- if parent_id:
182
- payload["parentId"] = parent_id
183
- r = http_requests.post(cms_url("comments"), headers=CMS_HEADERS_POST(), json=payload)
184
- return r.status_code in [200, 201]
185
- except Exception:
186
- return False
187
-
188
- def build_comment_tree(comments: list) -> list:
189
- by_id = {c["id"]: {**c, "replies": []} for c in comments}
190
- roots = []
191
- for c in comments:
192
- node = by_id[c["id"]]
193
- pid = c.get("parentId", "")
194
- if pid and pid in by_id:
195
- by_id[pid]["replies"].append(node)
196
- else:
197
- roots.append(node)
198
- return roots
199
-
200
- def migrate_comments(old_id: str, new_id: str) -> int:
201
- try:
202
- r = http_requests.get(cms_url("comments"), headers=CMS_HEADERS_GET(),
203
- params={"filters": f"articleId[equals]{old_id}", "limit": 100})
204
- comments = r.json().get("contents", [])
205
- for c in comments:
206
- http_requests.patch(cms_url(f"comments/{c['id']}"), headers=CMS_HEADERS_POST(),
207
- json={"articleId": new_id})
208
- return len(comments)
209
- except Exception:
210
- return 0
211
-
212
- def _to_jst_str(iso: str) -> str:
213
- try:
214
- dt = datetime.fromisoformat(iso.replace("Z", "+00:00")).astimezone(JST)
215
- return dt.strftime("%Y-%m-%d %H:%M")
216
- except Exception:
217
- return iso[:16].replace("T", " ")
218
-
219
- templates.env.filters["to_jst"] = _to_jst_str
220
-
221
- def get_all_comment_counts() -> dict:
222
- try:
223
- r = http_requests.get(cms_url("comments"), headers=CMS_HEADERS_GET(), params={
224
- "limit": 100,
225
- "fields": "articleId",
226
- })
227
- counts: dict = {}
228
- for c in r.json().get("contents", []):
229
- aid = c.get("articleId", "")
230
- if aid:
231
- counts[aid] = counts.get(aid, 0) + 1
232
- return counts
233
- except Exception:
234
- return {}
235
-
236
- def get_backups(original_id: str) -> list:
237
- try:
238
- r = http_requests.get(cms_url("blog-backup"), headers=CMS_HEADERS_GET(), params={
239
- "filters": f"originalId[equals]{original_id}",
240
- "limit": 100, "orders": "-version",
241
- })
242
- items = r.json().get("contents", [])
243
- for bk in items:
244
- bk["createdAt_jst"] = _to_jst_str(bk.get("createdAt", ""))
245
- return items
246
- except Exception:
247
- return []
248
-
249
- def create_backup(original_id: str, article_data: dict) -> tuple[bool, str]:
250
- try:
251
- r = http_requests.get(cms_url("blog-backup"), headers=CMS_HEADERS_GET(), params={
252
- "filters": f"originalId[equals]{original_id}", "limit": 1, "orders": "-version",
253
- })
254
- if r.status_code not in [200, 201]:
255
- return False, f"backup list fetch failed: {r.status_code}"
256
- contents = r.json().get("contents", [])
257
- next_ver = (contents[0].get("version", 0) if contents else 0) + 1
258
- body = {
259
- "originalId": original_id,
260
- "version": next_ver,
261
- "title": article_data.get("title", ""),
262
- "author": article_data.get("author", ""),
263
- "tags": article_data.get("tags", ""),
264
- "content": article_data.get("content", ""),
265
- "editorMode": "blocks",
266
- }
267
- if article_data.get("contentBlocks"):
268
- body["contentBlocks"] = article_data["contentBlocks"]
269
- r2 = http_requests.post(cms_url("blog-backup"), headers=CMS_HEADERS_POST(), json=body)
270
- if r2.status_code in [200, 201]:
271
- return True, ""
272
- return False, f"backup post failed: {r2.status_code} {r2.text[:200]}"
273
- except Exception as e:
274
- return False, str(e)
275
-
276
- def migrate_backup_ids(old_id: str, new_id: str):
277
- try:
278
- r = http_requests.get(cms_url("blog-backup"), headers=CMS_HEADERS_GET(),
279
- params={"filters": f"originalId[equals]{old_id}", "limit": 100})
280
- for bk in r.json().get("contents", []):
281
- http_requests.patch(cms_url(f"blog-backup/{bk['id']}"), headers=CMS_HEADERS_POST(),
282
- json={"originalId": new_id})
283
- except Exception:
284
- pass
285
-
286
- def upload_image_cms(image_bytes: bytes, filename: str = "upload.jpg") -> str | None:
287
- try:
288
- url = f"https://{CMS_SERVICE_ID}.microcms-management.io/api/v1/media"
289
- r = http_requests.post(url, headers={"X-MICROCMS-API-KEY": CMS_API_KEY},
290
- files={"file": (filename, image_bytes, "image/jpeg")})
291
- if r.status_code not in [200, 201]:
292
- return None
293
- return r.json().get("url")
294
- except Exception:
295
- return None
296
-
297
- def process_image(data: bytes, max_width: int = 1200) -> bytes:
298
- img = Image.open(io.BytesIO(data))
299
- try:
300
- import PIL.ExifTags
301
- exif = img._getexif()
302
- if exif:
303
- for tag, val in exif.items():
304
- if PIL.ExifTags.TAGS.get(tag) == "Orientation":
305
- if val == 3: img = img.rotate(180, expand=True)
306
- elif val == 6: img = img.rotate(270, expand=True)
307
- elif val == 8: img = img.rotate(90, expand=True)
308
- except Exception:
309
- pass
310
- if img.width > max_width:
311
- ratio = max_width / img.width
312
- img = img.resize((max_width, int(img.height * ratio)), Image.LANCZOS)
313
- if img.mode in ("RGBA", "P"):
314
- img = img.convert("RGB")
315
- buf = io.BytesIO()
316
- img.save(buf, format="JPEG", quality=85)
317
- return buf.getvalue()
318
-
319
-
320
- # ─── 公開ページ ───────────────────────────────────────────
321
-
322
- @app.get("/", response_class=HTMLResponse)
323
- async def index(request: Request, id: str = None, page: int = 0,
324
- q: str = "", author: str = "", tag: str = ""):
325
- if id:
326
- article = get_article(id)
327
- if not article:
328
- return templates.TemplateResponse(request, "blog_detail.html",
329
- {"article": None, "comments": []})
330
- flat_comments = get_comments(id)
331
- comment_tree = build_comment_tree(flat_comments)
332
- base_url = SPACE_URL or str(request.base_url).rstrip("/")
333
- if article.get("contentBlocks"):
334
- rendered_content = render_blocks_to_html(blocks_from_article(article))
335
- else:
336
- rendered_content = clean_html(get_content(article))
337
- return templates.TemplateResponse(request, "blog_detail.html", {
338
- "article": article,
339
- "comments": comment_tree,
340
- "comment_count": len(flat_comments),
341
- "content": rendered_content,
342
- "tags": get_tags(article),
343
- "space_url": base_url,
344
- })
345
-
346
- all_articles = get_all_articles()
347
- all_tags = sorted(set(t for a in all_articles for t in get_tags(a)
348
- if not a.get("title", "").startswith("【下書き】")))
349
- filtered = filter_articles(all_articles, q, author, tag)
350
- total = len(filtered)
351
- total_pages = max(1, -(-total // ARTICLES_PER_PAGE))
352
- page = max(0, min(page, total_pages - 1))
353
- page_articles = filtered[page * ARTICLES_PER_PAGE:(page + 1) * ARTICLES_PER_PAGE]
354
- comment_counts = get_all_comment_counts()
355
-
356
- return templates.TemplateResponse(request, "blog_list.html", {
357
- "articles": page_articles,
358
- "total": total,
359
- "page": page,
360
- "total_pages": total_pages,
361
- "q": q,
362
- "author": author,
363
- "tag": tag,
364
- "all_tags": all_tags,
365
- "author_list": AUTHOR_LIST,
366
- "articles_per_page": ARTICLES_PER_PAGE,
367
- "get_tags": get_tags,
368
- "make_snippet": make_snippet,
369
- "get_content": get_content,
370
- "comment_counts": comment_counts,
371
- })
372
-
373
-
374
- # ─── 管理者ページ ─────────────────────────────────────────
375
-
376
- @app.get("/admin", response_class=HTMLResponse)
377
- async def admin_login_page(request: Request):
378
- if get_auth(request):
379
- return RedirectResponse("/admin/articles", status_code=302)
380
- return templates.TemplateResponse(request, "admin_login.html", {"error": None})
381
-
382
- @app.post("/admin/login")
383
- async def admin_login(request: Request, password: str = Form(default="")):
384
- if not password.strip():
385
- return templates.TemplateResponse(request, "admin_login.html",
386
- {"error": "合言葉を入力してください"})
387
- if password.strip() == ADMIN_PASSWORD:
388
- response = RedirectResponse("/admin/articles", status_code=302)
389
- set_auth_cookie(response)
390
- return response
391
- return templates.TemplateResponse(request, "admin_login.html",
392
- {"error": "合言葉が違います"})
393
-
394
- @app.post("/admin/logout")
395
- async def admin_logout():
396
- response = RedirectResponse("/admin", status_code=302)
397
- response.delete_cookie("auth")
398
- return response
399
-
400
- @app.get("/admin/articles", response_class=HTMLResponse)
401
- async def admin_articles(request: Request):
402
- if not get_auth(request):
403
- return RedirectResponse("/admin", status_code=302)
404
- articles = get_all_articles()
405
- return templates.TemplateResponse(request, "admin_list.html",
406
- {"articles": articles, "get_tags": get_tags})
407
-
408
- @app.get("/admin/new", response_class=HTMLResponse)
409
- async def admin_new(request: Request):
410
- if not get_auth(request):
411
- return RedirectResponse("/admin", status_code=302)
412
- return templates.TemplateResponse(request, "admin_editor.html", {
413
- "article": None,
414
- "blocks_json": json.dumps([{"type": "text", "id": "block-0", "html": ""}]),
415
- "author_list": AUTHOR_LIST,
416
- "templates_map": TEMPLATES,
417
- "backups": [],
418
- })
419
-
420
- @app.get("/admin/edit/{article_id}", response_class=HTMLResponse)
421
- async def admin_edit(request: Request, article_id: str):
422
- if not get_auth(request):
423
- return RedirectResponse("/admin", status_code=302)
424
- article = get_article(article_id)
425
- if not article:
426
- raise HTTPException(status_code=404, detail="記事が見つかりません")
427
- blocks = blocks_from_article(article)
428
- ec = article.get("eyecatch")
429
- ec_url = ec.get("url", "") if isinstance(ec, dict) else (ec or "")
430
- backups = get_backups(article_id)
431
- return templates.TemplateResponse(request, "admin_editor.html", {
432
- "article": article,
433
- "blocks_json": json.dumps(blocks, ensure_ascii=False),
434
- "eyecatch_url": ec_url,
435
- "author_list": AUTHOR_LIST,
436
- "templates_map": TEMPLATES,
437
- "backups": backups,
438
- "get_tags": get_tags,
439
- })
440
-
441
-
442
- # ─── JSON API ─────────────────────────────────────────────
443
-
444
- @app.post("/api/save")
445
- async def api_save(request: Request):
446
- if not get_auth(request):
447
- raise HTTPException(status_code=401)
448
- data = await request.json()
449
-
450
- article_id = data.get("article_id") or None
451
- title_raw = data.get("title", "").strip()
452
- author = data.get("author", AUTHOR_LIST[0])
453
- tags = data.get("tags", "")
454
- eyecatch = data.get("eyecatch", "")
455
- blocks = data.get("blocks", [])
456
- status = data.get("status", "draft")
457
-
458
- clean_title = title_raw.replace("【下書き】", "").strip()
459
- save_title = f"【下書き】{clean_title}" if status == "draft" else clean_title
460
- tag_list = [t.strip() for t in tags.split(",") if t.strip()]
461
- rendered = clean_html(render_blocks_to_html(blocks))
462
- blocks_json = json.dumps(blocks, ensure_ascii=False)
463
-
464
- post_data = {
465
- "title": save_title,
466
- "content": rendered,
467
- "contentRich": rendered,
468
- "contentBlocks": blocks_json,
469
- "author": author,
470
- "tags": ",".join(tag_list),
471
- "editorMode": "blocks",
472
- }
473
- if eyecatch:
474
- post_data["eyecatch"] = eyecatch
475
-
476
- base_url = cms_url("blog")
477
- was_draft = False
478
- becomes_public = (status == "public")
479
- new_id = None
480
-
481
- try:
482
- if article_id:
483
- existing = get_article(article_id)
484
- if existing:
485
- was_draft = existing.get("title", "").startswith("【下書き】")
486
-
487
- if was_draft and becomes_public:
488
- likes_count = int(existing.get("likes", 0) or 0)
489
- if likes_count:
490
- post_data["likes"] = likes_count
491
- http_requests.delete(f"{base_url}/{article_id}", headers=CMS_HEADERS_GET())
492
- r = http_requests.post(base_url, headers=CMS_HEADERS_POST(), json=post_data)
493
- if r.status_code in [200, 201]:
494
- new_id = r.json().get("id")
495
- if new_id:
496
- migrate_comments(article_id, new_id)
497
- migrate_backup_ids(article_id, new_id)
498
- else:
499
- r = http_requests.patch(f"{base_url}/{article_id}", headers=CMS_HEADERS_POST(), json=post_data)
500
- else:
501
- r = http_requests.post(base_url, headers=CMS_HEADERS_POST(), json=post_data)
502
-
503
- if r.status_code not in [200, 201]:
504
- return JSONResponse({"success": False, "error": r.text})
505
-
506
- saved_id = new_id or (r.json().get("id") if not article_id else article_id)
507
- if was_draft and becomes_public and new_id:
508
- saved_id = new_id
509
-
510
- backup_data = {
511
- "title": save_title,
512
- "author": author,
513
- "tags": ",".join(tag_list),
514
- "content": rendered,
515
- "contentBlocks": blocks_json,
516
- }
517
- backup_ok, backup_err = False, "saved_id missing"
518
- if saved_id:
519
- backup_ok, backup_err = create_backup(saved_id, backup_data)
520
-
521
- return JSONResponse({"success": True, "id": saved_id,
522
- "backup_ok": backup_ok, "backup_err": backup_err})
523
- except Exception as e:
524
- return JSONResponse({"success": False, "error": str(e)})
525
-
526
- @app.delete("/api/article/{article_id}")
527
- async def api_delete_article(request: Request, article_id: str):
528
- if not get_auth(request):
529
- raise HTTPException(status_code=401)
530
- try:
531
- r = http_requests.delete(cms_url(f"blog/{article_id}"), headers=CMS_HEADERS_GET())
532
- return JSONResponse({"success": r.status_code in [200, 202, 204]})
533
- except Exception as e:
534
- return JSONResponse({"success": False, "error": str(e)})
535
-
536
- @app.post("/api/upload-image")
537
- async def api_upload_image(request: Request, file: UploadFile = File(...),
538
- max_width: int = Form(default=1200)):
539
- if not get_auth(request):
540
- raise HTTPException(status_code=401)
541
- try:
542
- raw = await file.read()
543
- processed = process_image(raw, max_width=max_width)
544
- url = upload_image_cms(processed, filename=file.filename or "upload.jpg")
545
- if url:
546
- return JSONResponse({"success": True, "url": url})
547
- return JSONResponse({"success": False, "error": "アップロード失敗"})
548
- except Exception as e:
549
- return JSONResponse({"success": False, "error": str(e)})
550
-
551
- @app.get("/api/backups/{article_id}")
552
- async def api_get_backups(request: Request, article_id: str):
553
- if not get_auth(request):
554
- raise HTTPException(status_code=401)
555
- return JSONResponse({"backups": get_backups(article_id)})
556
-
557
- @app.post("/api/ai-decorate")
558
- async def api_ai_decorate(request: Request):
559
- if not get_auth(request):
560
- raise HTTPException(status_code=401)
561
- if not GEMINI_KEY or not GENAI_AVAILABLE:
562
- return JSONResponse({"success": False, "error": "AI機能が利用できません(APIキー未設定または初期化エラー)"})
563
- data = await request.json()
564
- html_content = data.get("html", "")
565
- instruction = data.get("instruction", "")
566
- if not html_content or not instruction:
567
- return JSONResponse({"success": False, "error": "htmlとinstructionが必要です"})
568
- try:
569
- model = genai.GenerativeModel(model_name=GEMINI_MODEL)
570
- prompt = (
571
- f"あなたはブログ記事のHTMLコーダーです。\n"
572
- f"以下のルールを守りながら、与えられた指示に基づいてHTMLを修正してください。\n\n"
573
- f"【文章の書き換え厳禁】\n"
574
- f"本文の内容は絶対に変更しないこと。推敲・削除・要約等は全て不要。HTMLタグとスタイルのみ変更すること。\n\n"
575
- f"【スコープルール】\n"
576
- f"・記事全体を <div class=\"article-body\"> で囲む\n"
577
- f"・CSSセレクタは必ず .article-body h2 {{}} のようにラッパークラスを先頭につけてスコープを閉じる\n"
578
- f"・body や html への直接スタイル指定は禁止\n"
579
- f"・CSS変数は :root ではなく .article-body に定義する(例: .article-body {{ --accent-color: #e07297; }})\n\n"
580
- f"【ダークモード対応】\n"
581
- f"・テキストの文字色は black/white などの固定色を使わず color: inherit を使う\n"
582
- f"・グレー系の背景は rgba(128,128,128,0.1) などの半透過色を使う\n"
583
- f"・ダークモード切替は @media (prefers-color-scheme: dark) 内で .article-body の変数を上書きする\n\n"
584
- f"【Twitter埋め込み(厳守)】\n"
585
- f"・<blockquote class=\"twitter-tweet\"> タグおよびその内部は一切変更・装飾・スタイル追加しないこと\n"
586
- f"・blockquote 要素や twitter-tweet クラスへの CSS 指定は全て禁止\n"
587
- f"・widget.jsの読み込みは��要(ページ側で処理する)\n\n"
588
- f"【出力形式】\n"
589
- f"・HTMLコードのみを返すこと(説明文・コードブロック記号は不要)\n"
590
- f"・CSSはできるだけ <style> タグにまとめる(インラインstyleは最小限に)\n"
591
- f"・可読性のため、本文の改行に合わせて適切にコードも改行すること\n\n"
592
- f"【現在のHTML】\n{html_content}\n\n"
593
- f"【修正指示】\n{instruction}"
594
- )
595
- resp = model.generate_content(prompt)
596
- cleaned = re.sub(r"```html?", "", resp.text, flags=re.IGNORECASE).replace("```", "").strip()
597
- return JSONResponse({"success": True, "html": cleaned})
598
- except Exception as e:
599
- err = str(e)
600
- if "429" in err:
601
- m = re.search(r"retry in (\d+(?:\.\d+)?)s", err)
602
- wait = int(float(m.group(1))) + 1 if m else 30
603
- return JSONResponse({"success": False, "error": f"使用制限中。{wait}秒後に再試行してください"})
604
- return JSONResponse({"success": False, "error": err})
605
-
606
- @app.post("/api/ai-decorate-all")
607
- async def api_ai_decorate_all(request: Request):
608
- if not get_auth(request):
609
- raise HTTPException(status_code=401)
610
- if not GEMINI_KEY or not GENAI_AVAILABLE:
611
- return JSONResponse({"success": False, "error": "AI機能が利用できません(APIキー未設定または初期化エラー)"})
612
- data = await request.json()
613
- blocks_input = data.get("blocks", []) # [{id, html}, ...]
614
- instruction = data.get("instruction", "")
615
- if not blocks_input or not instruction:
616
- return JSONResponse({"success": False, "error": "blocksとinstructionが必要です"})
617
- try:
618
- blocks_json_str = json.dumps(blocks_input, ensure_ascii=False)
619
- model = genai.GenerativeModel(model_name=GEMINI_MODEL)
620
- prompt = (
621
- f"あなたはブログ記事のHTMLコーダーです。\n"
622
- f"以下は記事のテキストブロック(HTML)のJSON配列です。\n"
623
- f"全ブロックを通じて統一されたデザイン・スタイルになるよう、以下のルールと指示に基づいてHTMLを修正してください。\n\n"
624
- f"【文章の書き換え厳禁】\n"
625
- f"本文の内容は絶対に変更しないこと。推敲・削除・要約等は全て不要。HTMLタグとスタイルのみ変更すること。\n\n"
626
- f"【スコープルール】\n"
627
- f"・各ブロックのコンテンツを <div class=\"article-body\"> で囲む\n"
628
- f"・CSSセレクタは必ず .article-body h2 {{}} のようにラッパークラスを先頭につけてスコープを閉じる\n"
629
- f"・<style> タグは最初のブロック(配列の先頭)の先頭にのみ記載し、全ブロック共通スタイルをまとめる\n"
630
- f"・body や html への直接スタイル指定は禁止\n"
631
- f"・CSS変数は :root ではなく .article-body に定義する(例: .article-body {{ --accent-color: #e07297; }})\n\n"
632
- f"【ダークモード対応】\n"
633
- f"・テキストの文字色は black/white などの固定色を使わず color: inherit を使う\n"
634
- f"・グレー系の背景は rgba(128,128,128,0.1) などの半透過色を使う\n"
635
- f"・ダークモード切替は @media (prefers-color-scheme: dark) 内で .article-body の変数を上書きする\n\n"
636
- f"【Twitter埋め込み(厳守)】\n"
637
- f"・<blockquote class=\"twitter-tweet\"> タグおよびその内部は一切変更・装飾・スタイル追加しないこと\n"
638
- f"・blockquote 要素や twitter-tweet クラスへの CSS 指定は全て禁止\n"
639
- f"・widget.jsの読み込みは不要(ページ側で処理する)\n\n"
640
- f"【出力形式】\n"
641
- f"・同じJSON形式(idとhtmlのペアの配列)のみを返すこと。余分なテキストや説明は不要。\n"
642
- f"・CSSはできるだけ <style> タグにまとめる(最初のブロックに集約、インラインstyleは最小限に)\n"
643
- f"・可読性のため、本文の改行に合わせて適切にコードも改行すること\n\n"
644
- f"【ブロックデータ(JSON)】\n{blocks_json_str}\n"
645
- f"【装飾指示】{instruction}\n"
646
- f"【出力形式】[{{\"id\": \"block-xxx\", \"html\": \"装飾されたHTML\"}},...] のJSON配列のみ"
647
- )
648
- resp = model.generate_content(prompt)
649
- cleaned = re.sub(r"```json?", "", resp.text, flags=re.IGNORECASE).replace("```", "").strip()
650
- decorated_list = json.loads(cleaned)
651
- result = {item["id"]: item["html"] for item in decorated_list}
652
- return JSONResponse({"success": True, "decorated": result})
653
- except Exception as e:
654
- err = str(e)
655
- if "429" in err:
656
- m = re.search(r"retry in (\d+(?:\.\d+)?)s", err)
657
- wait = int(float(m.group(1))) + 1 if m else 30
658
- return JSONResponse({"success": False, "error": f"使用制限中。{wait}秒後に再試行してください"})
659
- return JSONResponse({"success": False, "error": err})
660
-
661
- @app.post("/api/comment")
662
- async def api_comment(request: Request):
663
- data = await request.json()
664
- article_id = data.get("article_id", "")
665
- name = data.get("name", "わせます太郎").strip() or "わせます太郎"
666
- body = data.get("body", "").strip()
667
- parent_id = data.get("parent_id", "")
668
- if not body:
669
- return JSONResponse({"success": False, "error": "コメントを入力してください"})
670
- ok = post_comment_api(article_id, name, body, parent_id)
671
- return JSONResponse({"success": ok})
672
-
673
- @app.post("/api/like/{article_id}")
674
- async def api_like(article_id: str, request: Request):
675
- try:
676
- body = await request.json()
677
- action = body.get("action", "add")
678
- except Exception:
679
- action = "add"
680
- article = get_article(article_id)
681
- if not article:
682
- return JSONResponse({"success": False, "likes": 0})
683
- current = int(article.get("likes", 0) or 0)
684
- new_count = max(0, current + (1 if action == "add" else -1))
685
- r = http_requests.patch(cms_url(f"blog/{article_id}"), headers=CMS_HEADERS_POST(),
686
- json={"likes": new_count})
687
- return JSONResponse({"success": r.status_code in [200, 201], "likes": new_count})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,9 +1,5 @@
1
- fastapi
2
- uvicorn[standard]
3
- jinja2
4
- python-multipart
5
  requests
6
- pillow
7
- pillow-heif
8
- google-generativeai
9
- itsdangerous
 
1
+ streamlit
2
+ google-generativeai>=0.5.2
 
 
3
  requests
4
+ streamlit-quill
5
+ Pillow
 
 
templates/admin_editor.html DELETED
@@ -1,894 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ja">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>{% if article %}記事の編集{% else %}新規記事{% endif %} - わせます9期ブログ</title>
7
- <script>tailwind.config = { darkMode: 'media' }</script>
8
- <script src="https://cdn.tailwindcss.com"></script>
9
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
10
- <link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
11
- <style>
12
- .block-card { transition: all 0.2s ease; border-left: 4px solid transparent; }
13
- .block-card:hover { border-left-color: #bf283a; }
14
- .editor-overlay { display: none; }
15
- .editor-overlay.active { display: flex; }
16
- .blog-preview { font-size: 16px; line-height: 1.8; word-break: break-word; }
17
- .blog-preview h1,.blog-preview h2,.blog-preview h3 { font-weight: bold; margin: 0.8em 0 0.4em; }
18
- .blog-preview h1 { font-size: 1.6rem; }
19
- .blog-preview h2 { border-left: 4px solid #bf283a; padding-left: 10px; font-size: 1.25rem; }
20
- .blog-preview h3 { font-size: 1.1rem; }
21
- .blog-preview p { line-height: 1.8; margin-bottom: 0.5rem; }
22
- .blog-preview strong { font-weight: bold; }
23
- .blog-preview em { font-style: italic; }
24
- .blog-preview ul { list-style: disc; padding-left: 1.5em; margin-bottom: 0.5rem; }
25
- .blog-preview ol { list-style: decimal; padding-left: 1.5em; margin-bottom: 0.5rem; }
26
- .blog-preview img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; }
27
- #modal-textarea { font-size: 16px; }
28
- .tag-badge { display:inline-block; background:#e0f2fe; color:#0369a1; border-radius:9999px; padding:2px 10px; font-size:12px; margin:2px; }
29
- .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
30
- background: #1f2937; color: white; padding: 10px 24px; border-radius: 9999px;
31
- font-size: 14px; z-index: 100; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
32
- .toast.show { opacity: 1; }
33
- .ql-toolbar.ql-snow { border: none; border-bottom: 1px solid #e5e7eb; padding: 6px 8px; flex-wrap: wrap;
34
- position: sticky; top: 0; z-index: 2; background: white; }
35
- .ql-container.ql-snow { border: none; font-size: 16px; height: auto !important; overflow: visible !important; }
36
- .ql-editor { min-height: 280px; padding: 12px 16px; line-height: 1.8; overflow-y: visible !important; }
37
- .ql-editor p, .ql-editor h1, .ql-editor h2, .ql-editor h3 { margin-bottom: 0.5em; }
38
- #quill-container { overflow-y: auto; }
39
- .mode-btn { padding: 3px 12px; border-radius: 9999px; font-size: 12px; font-weight: bold;
40
- cursor: pointer; transition: all 0.15s; border: 1px solid #d1d5db; color: #6b7280; }
41
- .mode-btn.active { background: #bf283a; color: white; border-color: #bf283a; }
42
- @media (prefers-color-scheme: dark) {
43
- .block-card { background-color: #1f2937; border-color: #374151; }
44
- .block-card:hover { border-left-color: #f08090; }
45
- .block-card input, .block-card textarea, .block-card select {
46
- background-color: #374151; color: #e5e7eb; border-color: #4b5563;
47
- }
48
- .blog-preview { color: #e5e7eb; }
49
- .blog-preview h2 { border-left-color: #f08090; }
50
- .block-card .text-gray-700 { color: #d1d5db; }
51
- .block-card .text-gray-400 { color: #9ca3af; }
52
- .block-card .hover\:bg-gray-50:hover { background-color: #374151; }
53
- .ql-toolbar.ql-snow { border-bottom-color: #374151; background: #1f2937; }
54
- .ql-toolbar.ql-snow .ql-stroke { stroke: #9ca3af; }
55
- .ql-toolbar.ql-snow .ql-fill { fill: #9ca3af; }
56
- .ql-toolbar.ql-snow .ql-picker { color: #9ca3af; }
57
- .ql-toolbar.ql-snow .ql-picker-options { background: #1f2937; border-color: #374151; }
58
- .ql-container.ql-snow { background: #1f2937; color: #e5e7eb; }
59
- .mode-btn { border-color: #4b5563; color: #9ca3af; background: transparent; }
60
- .mode-btn.active { background: #bf283a; color: white; border-color: #bf283a; }
61
- }
62
- </style>
63
- </head>
64
- <body class="bg-gray-50 dark:bg-gray-900 pb-24">
65
-
66
- <!-- 保存完了バナー(リロード後に表示) -->
67
- <div id="save-banner" class="hidden fixed top-0 inset-x-0 z-50 bg-green-100 text-green-800 border-b border-green-200 text-center py-3 text-sm font-bold shadow-sm">
68
- ✅ 下書き保存しました
69
- </div>
70
-
71
- <!-- スティッキーヘッダー -->
72
- <header class="bg-white dark:bg-gray-800 border-b dark:border-gray-700 sticky top-0 z-30 px-4 py-3 flex justify-between items-center shadow-sm">
73
- <a href="/admin/articles" class="text-gray-500 dark:text-gray-400 text-sm hover:text-[#bf283a] dark:hover:text-[#f08090] transition">
74
- <i class="fas fa-chevron-left mr-1"></i>一覧に戻る
75
- </a>
76
- <h1 class="font-bold text-gray-800 dark:text-gray-100 text-sm">
77
- {% if article %}📝 記事の編集{% else %}✍️ 新規記事{% endif %}
78
- </h1>
79
- <div class="flex gap-2">
80
- <button onclick="save('draft')"
81
- class="border dark:border-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1.5 rounded-full text-xs font-bold hover:bg-gray-50 dark:hover:bg-gray-700 transition">
82
- 下書き保存
83
- </button>
84
- <button onclick="save('public')"
85
- class="bg-[#bf283a] text-white px-4 py-1.5 rounded-full text-sm font-bold hover:bg-[#a01f2f] transition">
86
- 公開する
87
- </button>
88
- </div>
89
- </header>
90
-
91
- <main class="max-w-2xl mx-auto p-4 space-y-4">
92
-
93
- <!-- タイトル -->
94
- <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700">
95
- <label class="text-xs text-gray-400 dark:text-gray-500 font-bold uppercase block mb-1">記事タイトル</label>
96
- <textarea id="title-input" rows="1"
97
- oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'"
98
- placeholder="タイトルを入力..."
99
- style="font-size:18px"
100
- class="w-full font-bold border-none focus:ring-0 focus:outline-none p-0 bg-transparent text-gray-800 dark:text-gray-100 placeholder-gray-300 dark:placeholder-gray-600 resize-none overflow-hidden leading-snug">{{ article.get('title','') | replace('【下書き】','') if article else '' }}</textarea>
101
- </div>
102
-
103
- <!-- 著者・タグ -->
104
- <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700 grid grid-cols-1 sm:grid-cols-2 gap-3">
105
- <div>
106
- <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-1">著者</label>
107
- <select id="author-select" style="font-size:16px" class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#e09099]">
108
- {% for a in author_list %}
109
- <option value="{{ a }}" {% if article and article.get('author') == a %}selected{% endif %}>{{ a }}</option>
110
- {% endfor %}
111
- </select>
112
- </div>
113
- <div>
114
- <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-1">タグ(カンマ区切り)</label>
115
- <textarea id="tags-input" rows="1"
116
- oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'"
117
- onkeydown="if(event.key==='Enter')event.preventDefault()"
118
- placeholder="イベント, 日常"
119
- style="font-size:16px"
120
- class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099] resize-none overflow-hidden leading-snug">{{ get_tags(article)|join(', ') if article else '' }}</textarea>
121
- </div>
122
- </div>
123
-
124
- <!-- バックアップから復元 -->
125
- {% if backups %}
126
- <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700">
127
- <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-2">📦 バックアップから復元</label>
128
- <div class="flex gap-2">
129
- <select id="backup-select" class="flex-1 border dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#e09099]">
130
- {% for bk in backups %}
131
- <option data-backup='{{ bk | tojson }}'>v{{ bk.get('version','?') }} - {{ bk.get('createdAt_jst', bk.get('createdAt','')[:16] | replace('T',' ')) }}</option>
132
- {% endfor %}
133
- </select>
134
- <button onclick="applySelectedBackup()"
135
- class="bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-200 px-4 py-2 rounded-lg text-sm font-bold transition">
136
- 復元
137
- </button>
138
- </div>
139
- </div>
140
- {% endif %}
141
-
142
- <!-- アイキャッチ画像 -->
143
- <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700">
144
- <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-2">🖼️ キャッチ画像</label>
145
- <input type="hidden" id="eyecatch-url" value="{{ eyecatch_url if eyecatch_url else '' }}">
146
- <div id="eyecatch-preview" class="{% if not eyecatch_url %}hidden{% endif %} mb-3">
147
- <div class="relative inline-block">
148
- <img id="eyecatch-img" src="{{ eyecatch_url if eyecatch_url else '' }}" alt="" class="h-32 rounded-lg object-cover">
149
- <button onclick="removeEyecatch()"
150
- class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 text-xs flex items-center justify-center hover:bg-red-600">✕</button>
151
- </div>
152
- </div>
153
- <div id="eyecatch-upload" class="{% if eyecatch_url %}hidden{% endif %}">
154
- <label class="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-6 cursor-pointer hover:border-[#bf283a] dark:hover:border-[#f08090] transition">
155
- <i class="fas fa-cloud-upload-alt text-2xl text-gray-400 dark:text-gray-500 mb-2"></i>
156
- <span class="text-sm text-gray-500 dark:text-gray-400">クリックして画像を選択</span>
157
- <span class="text-xs text-gray-400 dark:text-gray-500 mt-1">JPG / PNG / HEIC</span>
158
- <input type="file" accept="image/*" class="hidden" onchange="uploadEyecatch(this)">
159
- </label>
160
- </div>
161
- </div>
162
-
163
- <!-- ブロックリスト -->
164
- <div>
165
- <div class="flex justify-between items-center mb-2">
166
- <span class="text-xs text-gray-400 dark:text-gray-500 font-bold uppercase">本文ブロック</span>
167
- <span class="text-xs text-gray-400 dark:text-gray-500">↑↓ で順番を変更</span>
168
- </div>
169
- <div id="block-list" class="space-y-1"></div>
170
- </div>
171
-
172
- <!-- 全体AI装飾 -->
173
- <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700">
174
- <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-2">✨ AIで全ブロックを一括装飾</label>
175
- <div class="flex gap-2">
176
- <input id="global-ai-instruction" type="text"
177
- placeholder="例: 見出しをつけてスタイルを統一して"
178
- style="font-size:16px"
179
- class="flex-1 border dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-300">
180
- <button id="global-ai-btn" onclick="runGlobalAI()"
181
- class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-blue-600 transition whitespace-nowrap">
182
- <i class="fas fa-magic mr-1"></i>実行
183
- </button>
184
- </div>
185
- <p id="global-ai-msg" class="text-xs mt-2 hidden"></p>
186
- </div>
187
-
188
- </main>
189
-
190
- <!-- テキスト編集モーダル(スマホ: フルスクリーン / PC: センター) -->
191
- <div id="editor-modal" class="editor-overlay fixed inset-x-0 top-0 z-50 flex-col bg-black/50 backdrop-blur-sm sm:items-center sm:justify-center sm:p-4" style="height:100%">
192
- <div id="modal-inner" class="bg-white dark:bg-gray-800 w-full sm:max-w-2xl sm:rounded-2xl flex flex-col shadow-2xl overflow-hidden h-full sm:h-auto sm:max-h-[90vh]">
193
- <!-- モーダルヘッダー -->
194
- <div class="px-4 py-3 border-b dark:border-gray-700 flex justify-between items-center bg-gray-50 dark:bg-gray-700 flex-shrink-0">
195
- <button onclick="closeEditor(false)" class="text-gray-500 dark:text-gray-400 font-bold text-sm">キャンセル</button>
196
- <div class="flex gap-1">
197
- <button id="btn-mode-rich" class="mode-btn active" onclick="setEditorMode('rich')">リッチ</button>
198
- <button id="btn-mode-html" class="mode-btn" onclick="setEditorMode('html')">HTML</button>
199
- </div>
200
- <button onclick="closeEditor(true)" class="text-[#bf283a] dark:text-[#f08090] font-bold text-sm">完了</button>
201
- </div>
202
-
203
- <!-- HTMLモード用ツールバー -->
204
- <div id="html-toolbar" class="hidden px-3 py-2 border-b dark:border-gray-700 flex gap-2 overflow-x-auto bg-white dark:bg-gray-800 flex-shrink-0">
205
- <button onclick="tb('bold')" title="太字" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"><i class="fas fa-bold text-sm"></i></button>
206
- <button onclick="tb('h2')" title="見出し2" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-xs font-bold text-gray-700 dark:text-gray-200">H2</button>
207
- <button onclick="tb('h3')" title="見出し3" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-xs font-bold text-gray-700 dark:text-gray-200">H3</button>
208
- <button onclick="tb('ul')" title="箇条書き" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"><i class="fas fa-list-ul text-sm"></i></button>
209
- <button onclick="tb('p')" title="段落" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"><i class="fas fa-paragraph text-sm"></i></button>
210
- <button onclick="tb('link')" title="リンク" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"><i class="fas fa-link text-sm"></i></button>
211
- <button onclick="tb('blockquote')" title="引用" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"><i class="fas fa-quote-left text-sm"></i></button>
212
- <button onclick="insertEmbed('youtube')" title="YouTube埋め込み" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200"><i class="fab fa-youtube text-sm"></i></button>
213
- <button onclick="insertEmbed('twitter')" title="X(Twitter)埋め込み" class="w-8 h-8 flex items-center justify-center border dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 text-xs font-bold text-gray-700 dark:text-gray-200">𝕏</button>
214
- <div class="h-8 w-px bg-gray-200 dark:bg-gray-600 mx-1"></div>
215
- <button onclick="toggleAiPanel()" class="bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-3 py-0.5 rounded-full text-xs font-bold whitespace-nowrap hover:bg-blue-100 dark:hover:bg-blue-900/50 transition">
216
- <i class="fas fa-magic"></i> AIで装飾
217
- </button>
218
- </div>
219
-
220
- <!-- AI パネル -->
221
- <div id="ai-panel" class="hidden border-b dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 px-3 py-2 flex-shrink-0">
222
- <div class="flex gap-2">
223
- <input id="ai-instruction" type="text" placeholder="指示を入力(例:見出しをつけて)"
224
- style="font-size:16px"
225
- class="flex-1 border dark:border-gray-600 rounded-lg px-3 py-1.5 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-300">
226
- <button id="ai-btn" onclick="runAI()"
227
- class="bg-blue-500 text-white px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-blue-600 transition whitespace-nowrap">
228
- 実行
229
- </button>
230
- </div>
231
- </div>
232
-
233
- <!-- リッチテキストエディタ(Quill) -->
234
- <div id="quill-container" class="flex-1 overflow-y-auto bg-white dark:bg-gray-800 min-h-0">
235
- <div id="quill-editor"></div>
236
- </div>
237
-
238
- <!-- HTMLテキストエリア(HTMLモード) -->
239
- <textarea id="modal-textarea"
240
- class="hidden w-full p-4 focus:ring-0 border-none focus:outline-none leading-relaxed flex-1 resize-none bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100"
241
- style="min-height:300px"
242
- placeholder="ここにHTMLを入力...&#10;&#10;例:&#10;&lt;h2&gt;見出し&lt;/h2&gt;&#10;&lt;p&gt;本文テキスト&lt;/p&gt;"></textarea>
243
-
244
- <div class="border-t dark:border-gray-700 flex-shrink-0">
245
- <button onclick="togglePreview()" class="w-full py-2 text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition">
246
- <i class="fas fa-eye mr-1"></i>プレビューを表示/非表示
247
- </button>
248
- <div id="modal-preview" class="hidden border-t dark:border-gray-700 p-4 blog-preview overflow-y-auto text-sm text-gray-700 dark:text-gray-300" style="max-height:200px"></div>
249
- </div>
250
- </div>
251
- </div>
252
-
253
- <!-- バックアップ復元確認モーダル -->
254
- <div id="restore-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
255
- <div class="bg-white dark:bg-gray-800 rounded-2xl p-6 w-80 shadow-2xl border dark:border-gray-700">
256
- <h2 class="font-bold text-gray-800 dark:text-gray-100 mb-2">バックアップを復元</h2>
257
- <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">現在の編集内容が失われます。続けますか?</p>
258
- <div class="flex gap-3">
259
- <button onclick="closeRestoreModal()" class="flex-1 border dark:border-gray-600 rounded-xl py-2 text-sm text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">キャンセル</button>
260
- <button onclick="executeRestore()" class="flex-1 bg-blue-500 text-white rounded-xl py-2 text-sm font-bold hover:bg-blue-600">復元する</button>
261
- </div>
262
- </div>
263
- </div>
264
-
265
- <!-- 全体プレビューモーダル -->
266
- <div id="preview-modal" class="hidden fixed inset-0 z-50 bg-white dark:bg-gray-900 overflow-y-auto">
267
- <div class="sticky top-0 bg-white dark:bg-gray-800 border-b dark:border-gray-700 px-4 py-3 flex justify-between items-center shadow-sm z-10">
268
- <span class="font-bold text-sm text-gray-700 dark:text-gray-200">📄 記事プレビュー</span>
269
- <button onclick="closeFullPreview()" class="text-gray-500 dark:text-gray-400 font-bold text-sm hover:text-gray-700 dark:hover:text-gray-200">✕ 閉じる</button>
270
- </div>
271
- <div class="max-w-2xl mx-auto px-4 py-6 pb-20">
272
- <h1 id="fp-title" class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-3 leading-snug"></h1>
273
- <p id="fp-meta" class="text-sm text-gray-400 dark:text-gray-500 mb-4"></p>
274
- <div id="fp-tags" class="mb-6"></div>
275
- <div id="fp-body" class="blog-preview"></div>
276
- </div>
277
- </div>
278
-
279
- <!-- プレビューフローティングボタン -->
280
- <button onclick="openFullPreview()"
281
- class="fixed bottom-6 right-4 z-30 bg-gray-700 dark:bg-gray-600 text-white px-5 py-3 rounded-full shadow-lg text-sm font-bold hover:bg-gray-600 dark:hover:bg-gray-500 transition flex items-center gap-2">
282
- <i class="fas fa-eye"></i> プレビュー
283
- </button>
284
-
285
- <div id="toast" class="toast">保存しました</div>
286
-
287
- <script>
288
- let blocks = {{ blocks_json | safe }};
289
- let articleId = "{{ article.get('id','') if article else '' }}";
290
- let currentEditIdx = null;
291
- let tempContent = "";
292
- let restoreTarget = null;
293
- let editorMode = 'rich';
294
- let quill = null;
295
-
296
- function initQuill() {
297
- if (quill) return;
298
- quill = new Quill('#quill-editor', {
299
- theme: 'snow',
300
- scrollingContainer: '#quill-container',
301
- modules: {
302
- toolbar: [
303
- [{ header: [1, 2, 3, false] }],
304
- [{ size: ['small', false, 'large', 'huge'] }],
305
- ['bold', 'italic', 'underline', 'strike'],
306
- [{ color: [] }],
307
- [{ list: 'bullet' }, { list: 'ordered' }],
308
- ['blockquote', 'link', 'video'],
309
- ['clean']
310
- ]
311
- }
312
- });
313
- quill.on('text-change', updatePreview);
314
-
315
- // Quill ツールバーにカスタム X 埋め込みボタンを追加
316
- const qlToolbar = document.querySelector('#quill-container .ql-toolbar');
317
- if (qlToolbar) {
318
- const xGroup = document.createElement('span');
319
- xGroup.className = 'ql-formats';
320
- const xBtn = document.createElement('button');
321
- xBtn.innerHTML = '𝕏';
322
- xBtn.title = 'X(Twitter)埋め込み';
323
- xBtn.style.cssText = 'font-size:12px;font-weight:bold;width:auto;padding:0 6px;';
324
- xBtn.addEventListener('click', () => insertEmbed('twitter'));
325
- xGroup.appendChild(xBtn);
326
- qlToolbar.appendChild(xGroup);
327
- }
328
-
329
- // キーボードを閉じずにツールバーボタンを使えるようにする
330
- if (qlToolbar) {
331
- // デスクトップ: mousedown で blur を抑制
332
- qlToolbar.addEventListener('mousedown', e => e.preventDefault());
333
- // iOS: タッチ開始時にカーソル位置を保存し、タッチ終了後に復元
334
- let _savedRange = null;
335
- let _toolbarTouched = false;
336
- qlToolbar.addEventListener('touchstart', () => {
337
- _savedRange = quill.getSelection();
338
- _toolbarTouched = true;
339
- }, { passive: true });
340
- qlToolbar.addEventListener('touchend', () => {
341
- if (_savedRange !== null) {
342
- setTimeout(() => {
343
- quill.root.focus({ preventScroll: true });
344
- quill.setSelection(_savedRange.index, _savedRange.length, 'silent');
345
- _savedRange = null;
346
- _toolbarTouched = false;
347
- }, 20);
348
- }
349
- });
350
- }
351
- }
352
-
353
- function setEditorMode(mode) {
354
- editorMode = mode;
355
- const quillCont = document.getElementById('quill-container');
356
- const ta = document.getElementById('modal-textarea');
357
- const htmlTb = document.getElementById('html-toolbar');
358
- const btnRich = document.getElementById('btn-mode-rich');
359
- const btnHtml = document.getElementById('btn-mode-html');
360
-
361
- if (mode === 'rich') {
362
- initQuill();
363
- quill.root.innerHTML = ta.value;
364
- quillCont.classList.remove('hidden');
365
- ta.classList.add('hidden');
366
- htmlTb.classList.add('hidden');
367
- btnRich.classList.add('active');
368
- btnHtml.classList.remove('active');
369
- } else {
370
- if (quill) ta.value = quill.root.innerHTML;
371
- quillCont.classList.add('hidden');
372
- ta.classList.remove('hidden');
373
- htmlTb.classList.remove('hidden');
374
- btnHtml.classList.add('active');
375
- btnRich.classList.remove('active');
376
- }
377
- updatePreview();
378
- }
379
-
380
- function getEditorContent() {
381
- if (editorMode === 'rich' && quill) return quill.root.innerHTML;
382
- return document.getElementById('modal-textarea').value;
383
- }
384
-
385
- function setEditorContent(html) {
386
- document.getElementById('modal-textarea').value = html;
387
- if (quill) quill.root.innerHTML = html;
388
- }
389
-
390
- function buildAddRow(insertIdx) {
391
- const div = document.createElement('div');
392
- div.className = 'flex justify-center gap-2 py-2';
393
- div.innerHTML = `
394
- <button onclick="addBlock(${insertIdx},'text')" class="bg-gray-100 dark:bg-gray-700 hover:bg-[#fdf0f2] dark:hover:bg-[#3d1a20] hover:text-[#bf283a] text-gray-500 dark:text-gray-400 text-xs py-1.5 px-4 rounded-full transition">+ テキスト</button>
395
- <button onclick="addBlock(${insertIdx},'image')" class="bg-gray-100 dark:bg-gray-700 hover:bg-blue-50 dark:hover:bg-blue-900/30 hover:text-blue-500 dark:hover:text-blue-400 text-gray-500 dark:text-gray-400 text-xs py-1.5 px-4 rounded-full transition">+ 画像</button>
396
- `;
397
- return div;
398
- }
399
-
400
- function renderBlocks() {
401
- const list = document.getElementById('block-list');
402
- list.innerHTML = '';
403
-
404
- list.appendChild(buildAddRow(0));
405
-
406
- if (blocks.length === 0) return;
407
-
408
- blocks.forEach((block, i) => {
409
- const card = document.createElement('div');
410
- card.className = 'block-card bg-white dark:bg-gray-800 rounded-xl border dark:border-gray-700 shadow-sm';
411
- card.id = `card-${block.id}`;
412
- card.innerHTML = buildCardHTML(block, i);
413
- list.appendChild(card);
414
- list.appendChild(buildAddRow(i + 1));
415
- });
416
- }
417
-
418
- function buildCardHTML(block, i) {
419
- const isFirst = i === 0;
420
- const isLast = i === blocks.length - 1;
421
- const badge = block.type === 'text'
422
- ? '<span class="text-xs font-bold text-[#bf283a] bg-[#fdf0f2] px-2 py-0.5 rounded">テキスト</span>'
423
- : '<span class="text-xs font-bold text-blue-500 bg-blue-50 dark:bg-blue-900/30 dark:text-blue-400 px-2 py-0.5 rounded">画像</span>';
424
-
425
- const controls = `
426
- <div class="flex gap-1 text-gray-400">
427
- <button onclick="moveBlock(${i},-1)" ${isFirst ? 'disabled' : ''} class="w-7 h-7 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 flex items-center justify-center">
428
- <i class="fas fa-arrow-up text-xs"></i>
429
- </button>
430
- <button onclick="moveBlock(${i},1)" ${isLast ? 'disabled' : ''} class="w-7 h-7 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-30 flex items-center justify-center">
431
- <i class="fas fa-arrow-down text-xs"></i>
432
- </button>
433
- <button onclick="deleteBlock(${i})" class="w-7 h-7 rounded hover:bg-red-100 dark:hover:bg-red-900/30 hover:text-red-500 flex items-center justify-center">
434
- <i class="fas fa-trash text-xs"></i>
435
- </button>
436
- </div>`;
437
-
438
- let content = '';
439
- if (block.type === 'text') {
440
- const preview = block.html
441
- ? `<div class="blog-preview text-sm text-gray-700 dark:text-gray-300 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded p-2 transition" onclick="openEditor(${i})">${block.html}</div>`
442
- : `<div class="text-gray-400 text-sm text-center py-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition" onclick="openEditor(${i})"><i class="fas fa-plus-circle mr-1"></i>ここをタップして文章を書く</div>`;
443
- content = `<div class="mt-2">${preview}</div>`;
444
- } else {
445
- content = buildImageContent(block, i);
446
- }
447
-
448
- return `<div class="p-4"><div class="flex justify-between items-center mb-1">${badge}${controls}</div>${content}</div>`;
449
- }
450
-
451
- function buildImageContent(block, i) {
452
- if (block.url) {
453
- return `
454
- <div class="mt-2">
455
- <img src="${block.url}" alt="${escHtml(block.alt||'')}" class="w-full max-h-64 object-contain rounded-lg bg-gray-50 dark:bg-gray-700">
456
- ${block.alt ? `<p class="text-xs text-center text-gray-400 dark:text-gray-500 mt-1 italic">${escHtml(block.alt)}</p>` : ''}
457
- <div class="flex gap-2 mt-2">
458
- <input type="text" value="${escHtml(block.alt||'')}" placeholder="キャプション"
459
- onchange="updateBlockField(${i},'alt',this.value)"
460
- class="flex-1 border dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100">
461
- <input type="number" value="${block.maxWidth||900}" min="200" max="2000" step="100"
462
- onchange="updateBlockField(${i},'maxWidth',parseInt(this.value))"
463
- class="w-24 border dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100">
464
- <button onclick="clearImage(${i})" class="text-red-400 hover:text-red-600 text-xs border border-red-200 dark:border-red-700 px-2 py-1.5 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/30 transition">削除</button>
465
- </div>
466
- <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">最大幅: ${block.maxWidth||900}px</p>
467
- </div>`;
468
- }
469
- return `
470
- <div class="mt-2">
471
- <label class="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-6 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 transition">
472
- <i class="fas fa-cloud-upload-alt text-2xl text-gray-400 dark:text-gray-500 mb-2"></i>
473
- <span class="text-sm text-gray-500 dark:text-gray-400">クリックして画像をアップロード</span>
474
- <input type="file" accept="image/*" class="hidden" onchange="uploadBlockImage(${i},this)">
475
- </label>
476
- </div>`;
477
- }
478
-
479
- function moveBlock(idx, dir) {
480
- const ni = idx + dir;
481
- if (ni < 0 || ni >= blocks.length) return;
482
- [blocks[idx], blocks[ni]] = [blocks[ni], blocks[idx]];
483
- renderBlocks();
484
- }
485
-
486
- function deleteBlock(idx) {
487
- blocks.splice(idx, 1);
488
- renderBlocks();
489
- }
490
-
491
- function addBlock(idx, type) {
492
- const id = 'block-' + Math.random().toString(36).substr(2, 8);
493
- const nb = type === 'text'
494
- ? {type:'text', id, html:'', editorMode:'rich'}
495
- : {type:'image', id, url:'', alt:'', maxWidth:900};
496
- blocks.splice(idx, 0, nb);
497
- renderBlocks();
498
- if (type === 'text') openEditor(idx);
499
- }
500
-
501
- function updateBlockField(idx, field, value) { blocks[idx][field] = value; }
502
- function clearImage(idx) { blocks[idx].url = ''; blocks[idx].alt = ''; renderBlocks(); }
503
-
504
- function syncModalToViewport() {
505
- const modal = document.getElementById('editor-modal');
506
- if (!modal.classList.contains('active')) return;
507
- if (!window.visualViewport) return;
508
- const vv = window.visualViewport;
509
- modal.style.top = vv.offsetTop + 'px';
510
- modal.style.height = vv.height + 'px';
511
- }
512
-
513
- if (window.visualViewport) {
514
- window.visualViewport.addEventListener('resize', syncModalToViewport);
515
- window.visualViewport.addEventListener('scroll', syncModalToViewport);
516
- }
517
-
518
- function openEditor(idx) {
519
- currentEditIdx = idx;
520
- const block = blocks[idx];
521
- const html = block.html || '';
522
- const blockMode = block.editorMode || 'rich';
523
- // 両エディタに内容をセットしてからモードを切り替���る
524
- document.getElementById('modal-textarea').value = html;
525
- if (quill) quill.root.innerHTML = html;
526
- tempContent = html;
527
- document.getElementById('editor-modal').classList.add('active');
528
- document.body.style.overflow = 'hidden';
529
- syncModalToViewport();
530
- setEditorMode(blockMode);
531
- }
532
-
533
- function closeEditor(save) {
534
- if (save && currentEditIdx !== null) {
535
- blocks[currentEditIdx].html = getEditorContent();
536
- blocks[currentEditIdx].editorMode = editorMode;
537
- }
538
- currentEditIdx = null;
539
- const modal = document.getElementById('editor-modal');
540
- modal.classList.remove('active');
541
- modal.style.top = '';
542
- modal.style.height = '';
543
- document.body.style.overflow = '';
544
- document.getElementById('ai-panel').classList.add('hidden');
545
- document.getElementById('modal-preview').classList.add('hidden');
546
- renderBlocks();
547
- }
548
-
549
- function updatePreview() {
550
- const prev = document.getElementById('modal-preview');
551
- if (!prev.classList.contains('hidden')) prev.innerHTML = getEditorContent();
552
- }
553
-
554
- function togglePreview() {
555
- const prev = document.getElementById('modal-preview');
556
- prev.classList.toggle('hidden');
557
- if (!prev.classList.contains('hidden')) prev.innerHTML = getEditorContent();
558
- }
559
-
560
- document.getElementById('modal-textarea').addEventListener('input', updatePreview);
561
-
562
- function tb(type) {
563
- const ta = document.getElementById('modal-textarea');
564
- const s = ta.selectionStart, e = ta.selectionEnd;
565
- const sel = ta.value.substring(s, e);
566
- let open = '', close = '', full = null;
567
- if (type === 'bold') { open = '<strong>'; close = '</strong>'; }
568
- else if (type === 'h2') { full = `<h2>${sel || '見出し'}</h2>`; }
569
- else if (type === 'h3') { full = `<h3>${sel || '見出し'}</h3>`; }
570
- else if (type === 'p') { full = `<p>${sel || 'テキスト'}</p>`; }
571
- else if (type === 'ul') { full = `<ul>\n <li>${sel || '項目'}</li>\n</ul>`; }
572
- else if (type === 'blockquote') {
573
- full = `<blockquote style="background-color:rgba(128,128,128,0.1);border-left:5px solid #e07297;padding:15px 20px;margin:25px 0;border-radius:5px;color:inherit;">${sel || '引用テキスト'}</blockquote>`;
574
- }
575
- else if (type === 'link') {
576
- const href = prompt('URLを入力:');
577
- if (href) full = `<a href="${href}">${sel || href}</a>`;
578
- }
579
- if (full !== null) {
580
- ta.value = ta.value.substring(0, s) + full + ta.value.substring(e);
581
- ta.setSelectionRange(s + full.length, s + full.length);
582
- } else if (open) {
583
- const replaced = open + sel + close;
584
- ta.value = ta.value.substring(0, s) + replaced + ta.value.substring(e);
585
- ta.setSelectionRange(s + open.length, s + open.length + sel.length);
586
- }
587
- updatePreview(); ta.focus();
588
- }
589
-
590
- function toggleAiPanel() { document.getElementById('ai-panel').classList.toggle('hidden'); }
591
-
592
- async function runAI() {
593
- const ins = document.getElementById('ai-instruction').value.trim();
594
- const currentHtml = getEditorContent();
595
- if (!ins || !currentHtml) { alert('本文と指示を入力してください'); return; }
596
- const btn = document.getElementById('ai-btn');
597
- btn.disabled = true; btn.textContent = '処理中...';
598
- try {
599
- const res = await fetch('/api/ai-decorate', {
600
- method: 'POST', headers: {'Content-Type':'application/json'},
601
- body: JSON.stringify({html: currentHtml, instruction: ins})
602
- });
603
- const data = await res.json();
604
- if (data.success) { setEditorContent(data.html); setEditorMode('html'); }
605
- else { alert('AIエラー: ' + data.error); }
606
- } catch(err) { alert('通信エラー: ' + err.message); }
607
- finally { btn.disabled = false; btn.textContent = '実行'; }
608
- }
609
-
610
- async function uploadBlockImage(idx, input) {
611
- const file = input.files[0];
612
- if (!file) return;
613
- const fd = new FormData();
614
- fd.append('file', file);
615
- fd.append('max_width', blocks[idx].maxWidth || 900);
616
- try {
617
- const res = await fetch('/api/upload-image', {method:'POST', body: fd});
618
- const data = await res.json();
619
- if (data.success && data.url) { blocks[idx].url = data.url; renderBlocks(); }
620
- else { alert('アップロード失敗: ' + (data.error||'')); }
621
- } catch(err) { alert('通信エラー: ' + err.message); }
622
- }
623
-
624
- async function uploadEyecatch(input) {
625
- const file = input.files[0];
626
- if (!file) return;
627
- const fd = new FormData();
628
- fd.append('file', file);
629
- fd.append('max_width', '1200');
630
- try {
631
- const res = await fetch('/api/upload-image', {method:'POST', body: fd});
632
- const data = await res.json();
633
- if (data.success && data.url) {
634
- document.getElementById('eyecatch-url').value = data.url;
635
- document.getElementById('eyecatch-img').src = data.url;
636
- document.getElementById('eyecatch-preview').classList.remove('hidden');
637
- document.getElementById('eyecatch-upload').classList.add('hidden');
638
- } else { alert('アップロード失敗: ' + (data.error||'')); }
639
- } catch(err) { alert('通信エラー: ' + err.message); }
640
- }
641
-
642
- function removeEyecatch() {
643
- document.getElementById('eyecatch-url').value = '';
644
- document.getElementById('eyecatch-preview').classList.add('hidden');
645
- document.getElementById('eyecatch-upload').classList.remove('hidden');
646
- }
647
-
648
- function applySelectedBackup() {
649
- const sel = document.getElementById('backup-select');
650
- if (!sel) return;
651
- const opt = sel.options[sel.selectedIndex];
652
- if (!opt) return;
653
- try {
654
- restoreTarget = JSON.parse(opt.getAttribute('data-backup'));
655
- document.getElementById('restore-modal').classList.remove('hidden');
656
- } catch(e) { alert('バックアップデータの読み込みに失敗しました'); }
657
- }
658
-
659
- function closeRestoreModal() {
660
- document.getElementById('restore-modal').classList.add('hidden');
661
- restoreTarget = null;
662
- }
663
-
664
- function executeRestore() {
665
- if (!restoreTarget) return;
666
- const bk = restoreTarget;
667
- if (bk.contentBlocks) {
668
- try { blocks = JSON.parse(bk.contentBlocks); }
669
- catch { blocks = [{type:'text', id:'block-0', html: bk.contentRich || bk.content || ''}]; }
670
- } else {
671
- blocks = [{type:'text', id:'block-0', html: bk.contentRich || bk.content || ''}];
672
- }
673
- if (bk.title) document.getElementById('title-input').value = bk.title.replace(/^【下書き】/, '');
674
- if (bk.tags) {
675
- const tagsEl = document.getElementById('tags-input');
676
- tagsEl.value = bk.tags;
677
- tagsEl.style.height = 'auto';
678
- tagsEl.style.height = tagsEl.scrollHeight + 'px';
679
- }
680
- const ec = bk.eyecatch;
681
- if (ec) {
682
- const ecUrl = typeof ec === 'object' ? ec.url : ec;
683
- if (ecUrl) {
684
- document.getElementById('eyecatch-url').value = ecUrl;
685
- document.getElementById('eyecatch-img').src = ecUrl;
686
- document.getElementById('eyecatch-preview').classList.remove('hidden');
687
- document.getElementById('eyecatch-upload').classList.add('hidden');
688
- }
689
- }
690
- closeRestoreModal();
691
- renderBlocks();
692
- showToast('バックアップを復元しました');
693
- }
694
-
695
- async function save(status) {
696
- const title = document.getElementById('title-input').value.trim();
697
- if (!title) { alert('タイトルを入力してください'); return; }
698
- const hasContent = blocks.some(b => (b.html && b.html.trim()) || b.url);
699
- if (!hasContent) { alert('本文を入力してください'); return; }
700
- const payload = {
701
- article_id: articleId || null, title,
702
- author: document.getElementById('author-select').value,
703
- tags: document.getElementById('tags-input').value,
704
- eyecatch: document.getElementById('eyecatch-url').value,
705
- blocks, status,
706
- };
707
- const headerBtns = document.querySelectorAll('header button');
708
- headerBtns.forEach(b => b.disabled = true);
709
- try {
710
- const res = await fetch('/api/save', {
711
- method:'POST', headers:{'Content-Type':'application/json'},
712
- body: JSON.stringify(payload)
713
- });
714
- const data = await res.json();
715
- if (data.success) {
716
- const backupMsg = data.backup_ok ? '' : '(バックアップ失敗)';
717
- if (!data.backup_ok && data.backup_err) console.warn('backup error:', data.backup_err);
718
- if (status === 'draft') {
719
- // 下書き保存 → リロードしてバックアップ一覧を更新
720
- const newId = data.id || articleId;
721
- sessionStorage.setItem('draft_saved_msg', '✅ 下書き保存しました' + backupMsg);
722
- window.location.href = `/admin/edit/${newId}`;
723
- return;
724
- }
725
- // 公開保存
726
- if (!articleId && data.id) {
727
- articleId = data.id;
728
- history.replaceState({}, '', `/admin/edit/${data.id}`);
729
- document.querySelector('header h1').textContent = '📝 記事の編集';
730
- }
731
- if (status === 'public' && data.id !== articleId) {
732
- articleId = data.id;
733
- history.replaceState({}, '', `/admin/edit/${data.id}`);
734
- }
735
- showToast('公開しました!' + backupMsg);
736
- } else { alert('保存エラー: ' + (data.error || '不明なエラー')); }
737
- } catch(err) { alert('通信エラー: ' + err.message); }
738
- finally { headerBtns.forEach(b => b.disabled = false); }
739
- }
740
-
741
- function _pasteHtml(html) {
742
- if (editorMode === 'rich' && quill) {
743
- const range = quill.getSelection(true) || { index: quill.getLength() - 1 };
744
- quill.clipboard.dangerouslyPasteHTML(range.index, html);
745
- } else {
746
- const ta = document.getElementById('modal-textarea');
747
- const s = ta.selectionStart, e = ta.selectionEnd;
748
- ta.value = ta.value.substring(0, s) + html + ta.value.substring(e);
749
- ta.setSelectionRange(s + html.length, s + html.length);
750
- updatePreview();
751
- }
752
- }
753
-
754
- function insertEmbed(type) {
755
- if (type === 'youtube') {
756
- const url = prompt('YouTube URLを入力してください\n例: https://www.youtube.com/watch?v=XXXXX');
757
- if (!url) return;
758
- const m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/);
759
- if (!m) { alert('YouTube URLが認識できませんでした'); return; }
760
- const html = `<div class="embed-yt"><iframe src="https://www.youtube.com/embed/${m[1]}" allowfullscreen></iframe></div>`;
761
- _pasteHtml(html);
762
- } else if (type === 'twitter') {
763
- const url = prompt('X(Twitter) のポストURLを入力してください\n例: https://x.com/user/status/123...');
764
- if (!url) return;
765
- if (!/(?:twitter\.com|x\.com)\/\w+\/status\/\d+/.test(url)) {
766
- alert('X(Twitter)のURLが認識できませんでした'); return;
767
- }
768
- const html = `<blockquote class="twitter-tweet"><a href="${url}"></a></blockquote>`;
769
- _pasteHtml(html);
770
- }
771
- }
772
-
773
- function blocksToHtml() {
774
- return blocks.map(b => {
775
- if (b.type === 'text') return b.html || '';
776
- if (b.type === 'image' && b.url) {
777
- const caption = b.alt
778
- ? `<p style="text-align:center;font-size:0.8em;color:#9ca3af;margin-top:4px;">${escHtml(b.alt)}</p>`
779
- : '';
780
- return `<figure style="margin:8px 0;"><img src="${escHtml(b.url)}" alt="${escHtml(b.alt||'')}" style="width:${b.maxWidth||900}px;max-width:100%;height:auto;display:block;margin:0 auto;">${caption}</figure>`;
781
- }
782
- return '';
783
- }).join('\n');
784
- }
785
-
786
- function openFullPreview() {
787
- const title = document.getElementById('title-input').value.trim() || '(タイトルなし)';
788
- const author = document.getElementById('author-select').value;
789
- const tags = document.getElementById('tags-input').value
790
- .split(',').map(t => t.trim()).filter(Boolean);
791
-
792
- document.getElementById('fp-title').textContent = title;
793
- document.getElementById('fp-meta').textContent = '👤 ' + author;
794
- document.getElementById('fp-tags').innerHTML =
795
- tags.map(t => `<span class="tag-badge">#${escHtml(t)}</span>`).join('');
796
- document.getElementById('fp-body').innerHTML = blocksToHtml();
797
- document.getElementById('preview-modal').classList.remove('hidden');
798
- document.body.style.overflow = 'hidden';
799
- }
800
-
801
- function closeFullPreview() {
802
- document.getElementById('preview-modal').classList.add('hidden');
803
- document.body.style.overflow = '';
804
- }
805
-
806
- function showToast(msg) {
807
- const t = document.getElementById('toast');
808
- t.textContent = msg; t.classList.add('show');
809
- setTimeout(() => t.classList.remove('show'), 2500);
810
- }
811
-
812
- function escHtml(s) {
813
- return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
814
- }
815
-
816
- async function runGlobalAI() {
817
- const ins = document.getElementById('global-ai-instruction').value.trim();
818
- if (!ins) { alert('装飾の指示を入力してください'); return; }
819
- const textBlocks = blocks.filter(b => b.type === 'text' && b.html && b.html.trim());
820
- if (textBlocks.length === 0) { alert('装飾するテキストブロックがありません'); return; }
821
- const btn = document.getElementById('global-ai-btn');
822
- const msg = document.getElementById('global-ai-msg');
823
- btn.disabled = true;
824
- btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>処理中...';
825
- msg.textContent = ''; msg.classList.add('hidden');
826
- try {
827
- const payload = textBlocks.map(b => ({ id: b.id, html: b.html }));
828
- const res = await fetch('/api/ai-decorate-all', {
829
- method: 'POST', headers: { 'Content-Type': 'application/json' },
830
- body: JSON.stringify({ blocks: payload, instruction: ins })
831
- });
832
- const data = await res.json();
833
- if (data.success && data.decorated) {
834
- blocks.forEach(b => {
835
- if (b.type === 'text' && data.decorated[b.id] !== undefined) {
836
- b.html = data.decorated[b.id];
837
- b.editorMode = 'html';
838
- }
839
- });
840
- renderBlocks();
841
- msg.textContent = '✅ 全ブロックを装飾しました';
842
- msg.className = 'text-xs text-green-600 dark:text-green-400 mt-2';
843
- msg.classList.remove('hidden');
844
- setTimeout(() => msg.classList.add('hidden'), 3000);
845
- } else {
846
- msg.textContent = 'AIエラー: ' + (data.error || '不明なエラー');
847
- msg.className = 'text-xs text-red-500 mt-2';
848
- msg.classList.remove('hidden');
849
- }
850
- } catch(err) {
851
- msg.textContent = '通信エラー: ' + err.message;
852
- msg.className = 'text-xs text-red-500 mt-2';
853
- msg.classList.remove('hidden');
854
- } finally {
855
- btn.disabled = false;
856
- btn.innerHTML = '<i class="fas fa-magic mr-1"></i>実行';
857
- }
858
- }
859
-
860
- renderBlocks();
861
-
862
- // リロード後の保存完了バナー表示
863
- (function() {
864
- const msg = sessionStorage.getItem('draft_saved_msg');
865
- if (msg) {
866
- sessionStorage.removeItem('draft_saved_msg');
867
- const banner = document.getElementById('save-banner');
868
- banner.textContent = msg;
869
- banner.classList.remove('hidden');
870
- setTimeout(() => banner.classList.add('hidden'), 4000);
871
- }
872
- })();
873
-
874
- document.addEventListener('keydown', e => {
875
- if (e.key === 'Escape' && document.getElementById('editor-modal').classList.contains('active')) closeEditor(false);
876
- });
877
- </script>
878
- <script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
879
- <script>
880
- window.addEventListener('load', () => {
881
- initQuill();
882
- document.getElementById('html-toolbar').classList.add('hidden');
883
- document.getElementById('modal-textarea').classList.add('hidden');
884
- document.getElementById('quill-container').classList.remove('hidden');
885
- const titleEl = document.getElementById('title-input');
886
- titleEl.style.height = 'auto';
887
- titleEl.style.height = titleEl.scrollHeight + 'px';
888
- const tagsEl = document.getElementById('tags-input');
889
- tagsEl.style.height = 'auto';
890
- tagsEl.style.height = tagsEl.scrollHeight + 'px';
891
- });
892
- </script>
893
- </body>
894
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/admin_list.html DELETED
@@ -1,92 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}記事管理 - わせます9期ブログ{% endblock %}
3
- {% block body %}
4
- <div class="max-w-2xl mx-auto px-4 py-6">
5
- <div class="flex justify-between items-center mb-6">
6
- <div>
7
- <h1 class="text-xl font-bold text-gray-800 dark:text-gray-100">📋 記事管理</h1>
8
- <a href="/" class="text-xs text-gray-400 dark:text-gray-500 hover:text-[#bf283a] dark:hover:text-[#f08090] transition">← ブログに戻る</a>
9
- </div>
10
- <div class="flex gap-2">
11
- <a href="/admin/new"
12
- class="bg-[#bf283a] text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-[#a01f2f] transition">
13
- ✏️ 新規記事
14
- </a>
15
- <form method="post" action="/admin/logout">
16
- <button type="submit" class="border dark:border-gray-600 px-4 py-2 rounded-xl text-sm text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition">
17
- ログアウト
18
- </button>
19
- </form>
20
- </div>
21
- </div>
22
-
23
- <div class="space-y-3">
24
- {% for a in articles %}
25
- {% set tags = get_tags(a) %}
26
- {% set is_draft = a.get("title","").startswith("【下書き】") %}
27
- <div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl p-4">
28
- <div class="flex justify-between items-start gap-2">
29
- <div class="flex-1 min-w-0">
30
- <div class="flex items-center gap-2 mb-1">
31
- {% if is_draft %}
32
- <span class="text-[10px] whitespace-nowrap flex-shrink-0 bg-yellow-100 dark:bg-yellow-900/40 text-yellow-700 dark:text-yellow-400 px-2 py-0.5 rounded-full font-bold">下書</span>
33
- {% else %}
34
- <span class="text-[10px] whitespace-nowrap flex-shrink-0 bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400 px-2 py-0.5 rounded-full font-bold">公開</span>
35
- {% endif %}
36
- <span class="font-bold text-gray-800 dark:text-gray-100 text-sm truncate">{{ a.get("title","無題") }}</span>
37
- </div>
38
- <p class="text-xs text-gray-400 dark:text-gray-500 mb-1">{{ a.get("author","") }} | ID: {{ a.get("id","") }}</p>
39
- <div>{% for t in tags %}<span class="tag-badge">#{{ t }}</span>{% endfor %}</div>
40
- </div>
41
- <div class="flex gap-2 flex-shrink-0">
42
- <a href="/admin/edit/{{ a.get('id','') }}"
43
- class="bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-blue-100 dark:hover:bg-blue-900/50 transition">
44
- 📝 編集
45
- </a>
46
- <button onclick="confirmDelete(this.dataset.id, this.dataset.title)"
47
- data-id="{{ a.get('id','') }}"
48
- data-title="{{ a.get('title','') | e }}"
49
- class="bg-red-50 dark:bg-red-900/30 text-red-500 dark:text-red-400 px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-red-100 dark:hover:bg-red-900/50 transition">
50
- 🗑️
51
- </button>
52
- </div>
53
- </div>
54
- </div>
55
- {% else %}
56
- <div class="text-center text-gray-400 dark:text-gray-500 py-16">記事がありません</div>
57
- {% endfor %}
58
- </div>
59
- </div>
60
-
61
- <!-- 削除確認モーダル -->
62
- <div id="delete-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
63
- <div class="bg-white dark:bg-gray-800 rounded-2xl p-6 w-80 shadow-2xl border dark:border-gray-700">
64
- <h2 class="font-bold text-gray-800 dark:text-gray-100 mb-2">削除の確認</h2>
65
- <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">「<span id="delete-title" class="font-bold text-gray-700 dark:text-gray-200"></span>」を削除しますか?この操作は取り消せません。</p>
66
- <div class="flex gap-3">
67
- <button onclick="closeDeleteModal()" class="flex-1 border dark:border-gray-600 rounded-xl py-2 text-sm text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 transition">キャンセル</button>
68
- <button onclick="executeDelete()" class="flex-1 bg-red-500 text-white rounded-xl py-2 text-sm font-bold hover:bg-red-600 transition">削除する</button>
69
- </div>
70
- </div>
71
- </div>
72
-
73
- <script>
74
- let deleteTargetId = null;
75
- function confirmDelete(id, title) {
76
- deleteTargetId = id;
77
- document.getElementById('delete-title').textContent = title;
78
- document.getElementById('delete-modal').classList.remove('hidden');
79
- }
80
- function closeDeleteModal() {
81
- document.getElementById('delete-modal').classList.add('hidden');
82
- deleteTargetId = null;
83
- }
84
- async function executeDelete() {
85
- if (!deleteTargetId) return;
86
- const res = await fetch(`/api/article/${deleteTargetId}`, {method: 'DELETE'});
87
- const data = await res.json();
88
- if (data.success) { location.reload(); }
89
- else { alert('削除に失敗しました: ' + (data.error || '')); closeDeleteModal(); }
90
- }
91
- </script>
92
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/admin_login.html DELETED
@@ -1,30 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}管理者ログイン - わせます9期ブログ{% endblock %}
3
- {% block body %}
4
- <div class="min-h-screen flex items-center justify-center px-4">
5
- <div class="w-full max-w-sm">
6
- <div class="text-center mb-8">
7
- <h1 class="text-2xl font-bold text-[#bf283a] dark:text-[#f08090] mb-1">わせます9期ブログ</h1>
8
- <p class="text-sm text-gray-400 dark:text-gray-500">管理者ログイン</p>
9
- </div>
10
- <form method="post" action="/admin/login" class="bg-white dark:bg-gray-800 rounded-2xl shadow-md p-6 space-y-4 border dark:border-gray-700">
11
- {% if error %}
12
- <div class="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 text-red-600 dark:text-red-400 rounded-lg px-4 py-2 text-sm">{{ error }}</div>
13
- {% endif %}
14
- <div>
15
- <label class="block text-xs font-bold text-gray-500 dark:text-gray-400 mb-1">合言葉</label>
16
- <input type="text" name="password" autofocus
17
- class="w-full border dark:border-gray-600 rounded-xl px-4 py-3 text-base bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-[#e09099]"
18
- placeholder="合言葉を入力">
19
- </div>
20
- <button type="submit"
21
- class="w-full bg-[#bf283a] text-white py-3 rounded-xl font-bold text-base hover:bg-[#a01f2f] transition">
22
- ログイン
23
- </button>
24
- </form>
25
- <div class="text-center mt-4">
26
- <a href="/" class="text-sm text-gray-400 dark:text-gray-500 hover:text-[#bf283a] dark:hover:text-[#f08090] transition">← ブログに戻る</a>
27
- </div>
28
- </div>
29
- </div>
30
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/base.html DELETED
@@ -1,37 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ja">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>{% block title %}わせます9期ブログ{% endblock %}</title>
7
- <script>tailwind.config = { darkMode: 'media' }</script>
8
- <script src="https://cdn.tailwindcss.com"></script>
9
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
10
- <style>
11
- .blog-content { font-family: sans-serif; font-size: 16px; line-height: 1.8; word-break: break-word; }
12
- .blog-content h1,.blog-content h2,.blog-content h3 { margin: 1.2em 0 0.6em; font-weight: bold; }
13
- .blog-content h1 { font-size: 1.6rem; }
14
- .blog-content h2 { border-left: 5px solid #bf283a; padding-left: 12px; font-size: 1.25rem; }
15
- .blog-content h3 { font-size: 1.1rem; }
16
- .blog-content p { margin-bottom: 0.75em; }
17
- .blog-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 6px 0; display: block; }
18
- .blog-content iframe { max-width: 100%; display: block; margin: 8px auto; border-radius: 8px; border: none; }
19
- .blog-content .embed-yt { position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; margin: 12px 0; border-radius: 8px; }
20
- .blog-content .embed-yt iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border-radius: 8px; }
21
- .blog-content blockquote.twitter-tweet { margin: 12px auto; }
22
- .blog-content ul,.blog-content ol { padding-left: 1.5em; margin-bottom: 0.75em; }
23
- .blog-content li { margin-bottom: 0.25em; }
24
- .tag-badge { display: inline-block; background: #e0f2fe; color: #0369a1; border-radius: 9999px; padding: 2px 10px; font-size: 12px; margin: 2px; }
25
- @media (prefers-color-scheme: dark) {
26
- .blog-content { color: #e5e7eb; }
27
- .blog-content h2 { border-left-color: #f08090; }
28
- .blog-content a { color: #f08090; }
29
- .tag-badge { background: #1e3a4a; color: #7dd3fc; }
30
- }
31
- </style>
32
- {% block head %}{% endblock %}
33
- </head>
34
- <body class="bg-gray-50 dark:bg-gray-900 min-h-screen">
35
- {% block body %}{% endblock %}
36
- </body>
37
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/blog_detail.html DELETED
@@ -1,209 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}{{ article.get("title","記事") if article else "記事が見つかりません" }} - わせます9期ブログ{% endblock %}
3
- {% block head %}
4
- {% if article %}
5
- {% set ec = article.get("eyecatch") %}
6
- {% set ec_url = ec.get("url","") if ec is mapping else (ec or "") %}
7
- {% set share_url = space_url ~ "?id=" ~ article.get("id","") %}
8
- {% set og_title = article.get("title","") ~ " | わせます9期ブログ" %}
9
- <meta property="og:type" content="article">
10
- <meta property="og:title" content="{{ og_title }}">
11
- <meta property="og:url" content="{{ share_url }}">
12
- <meta property="og:site_name" content="わせます9期ブログ">
13
- {% if ec_url %}
14
- <meta property="og:image" content="{{ ec_url }}">
15
- <meta name="twitter:card" content="summary_large_image">
16
- <meta name="twitter:image" content="{{ ec_url }}">
17
- {% else %}
18
- <meta name="twitter:card" content="summary">
19
- {% endif %}
20
- <meta name="twitter:title" content="{{ og_title }}">
21
- {% endif %}
22
- {% endblock %}
23
-
24
- {% macro render_comment(c, article_id, depth) %}
25
- <div class="{% if depth > 0 %}ml-5 pl-3 border-l-2 border-pink-100 dark:border-gray-600{% endif %} mb-3">
26
- <div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl p-4">
27
- <div class="flex justify-between items-center mb-1">
28
- <span class="font-bold text-sm text-gray-700 dark:text-gray-200">{{ c.get("name","名無し") }}</span>
29
- <span class="text-xs text-gray-400 dark:text-gray-500">{{ c.get("createdAt","") | to_jst }}</span>
30
- </div>
31
- <p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed whitespace-pre-wrap">{{ c.get("body","") }}</p>
32
- <button onclick="toggleReplyForm('{{ c.id }}')"
33
- class="text-xs text-gray-400 dark:text-gray-500 mt-2 hover:text-[#bf283a] dark:hover:text-[#f08090] transition">
34
- ↩ 返信する
35
- </button>
36
- <div id="reply-{{ c.id }}" class="hidden mt-3 space-y-2">
37
- <input id="reply-name-{{ c.id }}" type="text" placeholder="名前(省略可)" maxlength="50"
38
- class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099]">
39
- <textarea id="reply-body-{{ c.id }}" placeholder="返信を入力..." maxlength="500" rows="2"
40
- class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099] resize-none"></textarea>
41
- <p id="reply-msg-{{ c.id }}" class="text-sm hidden"></p>
42
- <div class="flex gap-2">
43
- <button onclick="submitComment('{{ article_id }}', '{{ c.id }}')"
44
- class="flex-1 bg-[#bf283a] text-white py-1.5 rounded-lg text-sm font-bold hover:bg-[#a01f2f] transition">
45
- 返信する
46
- </button>
47
- <button onclick="toggleReplyForm('{{ c.id }}')"
48
- class="px-3 border dark:border-gray-600 text-gray-500 dark:text-gray-400 py-1.5 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition">
49
- キャンセル
50
- </button>
51
- </div>
52
- </div>
53
- </div>
54
- {% for reply in c.replies %}
55
- {{ render_comment(reply, article_id, depth + 1) }}
56
- {% endfor %}
57
- </div>
58
- {% endmacro %}
59
-
60
- {% block body %}
61
- <div class="max-w-2xl mx-auto px-4 py-6">
62
- <a href="/" class="inline-block mb-4 text-sm text-gray-500 dark:text-gray-400 hover:text-[#bf283a] dark:hover:text-[#f08090] transition">← 一覧に戻る</a>
63
-
64
- {% if not article %}
65
- <div class="text-center text-gray-400 dark:text-gray-500 py-16">記事が見つかりませんでした</div>
66
- {% else %}
67
- {% set ec = article.get("eyecatch") %}
68
- {% set ec_url = ec.get("url","") if ec is mapping else (ec or "") %}
69
- {% set article_id = article.get("id","") %}
70
- {% if ec_url %}
71
- <img src="{{ ec_url }}" alt="" class="w-full h-64 object-cover rounded-xl mb-6">
72
- {% endif %}
73
-
74
- <h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-2">{{ article.get("title","無題") }}</h1>
75
- <p class="text-sm text-gray-400 dark:text-gray-500 mb-3">👤 {{ article.get("author","") }} | 📅 {{ article.get("publishedAt","")[:10] }} | <a href="#comments" class="hover:text-[#bf283a] dark:hover:text-[#f08090] transition">💬 {{ comment_count }}件</a></p>
76
- <div class="mb-4">
77
- {% for t in tags %}<span class="tag-badge">#{{ t }}</span>{% endfor %}
78
- </div>
79
-
80
- <hr class="my-4 border-gray-200 dark:border-gray-700">
81
- <div class="blog-content text-gray-800 dark:text-gray-200">{{ content | safe }}</div>
82
- <hr class="my-6 border-gray-200 dark:border-gray-700">
83
-
84
- <!-- いいね -->
85
- <div class="flex justify-center mb-8">
86
- <button id="like-btn" onclick="likeArticle('{{ article_id }}')"
87
- class="w-20 h-20 rounded-full border-2 border-[#e09099] dark:border-[#c07080] flex flex-col items-center justify-center gap-0.5 hover:bg-[#fdf0f2] dark:hover:bg-[#3d1a20] transition group">
88
- <span id="like-icon" class="text-2xl transition-transform group-hover:scale-110 leading-none text-[#bf283a] dark:text-[#f08090]">♡</span>
89
- <span id="like-count" class="text-xs font-bold text-[#bf283a] dark:text-[#f08090] leading-none">{{ article.get("likes", 0) or 0 }}</span>
90
- </button>
91
- </div>
92
-
93
- <!-- シェア -->
94
- {% if space_url %}
95
- {% set share_link = space_url ~ "?id=" ~ article_id %}
96
- {% set share_text = article.get("title","") ~ " | わせます9期ブログ\n" ~ share_link %}
97
- <div class="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 mb-6 border dark:border-gray-700">
98
- <p class="text-sm font-bold text-gray-600 dark:text-gray-300 mb-3">🔗 この記事をシェアする</p>
99
- <div class="flex gap-2 mb-3">
100
- <a href="https://twitter.com/intent/tweet?text={{ share_text | urlencode }}"
101
- target="_blank" rel="noopener"
102
- class="flex items-center gap-2 bg-black text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-gray-800 transition">
103
- <svg viewBox="0 0 24 24" class="w-4 h-4 fill-current"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-4.714-6.231-5.401 6.231H2.744l7.73-8.835L1.254 2.25H8.08l4.259 5.63zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
104
- Xで共有
105
- </a>
106
- <button id="copy-btn"
107
- onclick="navigator.clipboard.writeText(document.getElementById('share-text').value).then(()=>{this.textContent='✅ コピー済み';setTimeout(()=>{this.textContent='テキストをコピー'},2000)})"
108
- class="flex-1 border dark:border-gray-600 text-gray-600 dark:text-gray-300 px-3 py-2 rounded-lg text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition">
109
- テキストをコピー
110
- </button>
111
- </div>
112
- <textarea id="share-text" readonly rows="2"
113
- class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-xs bg-white dark:bg-gray-700 text-gray-500 dark:text-gray-400 focus:outline-none resize-none leading-relaxed">{{ article.get('title','') }} | わせます9期ブログ
114
- {{ share_link }}</textarea>
115
- </div>
116
- {% endif %}
117
-
118
- <!-- コメント -->
119
- <section id="comments">
120
- <h2 class="text-lg font-bold text-gray-700 dark:text-gray-200 mb-4">💬 コメント</h2>
121
- {% if comments %}
122
- {% for c in comments %}
123
- {{ render_comment(c, article_id, 0) }}
124
- {% endfor %}
125
- {% else %}
126
- <p class="text-sm text-gray-400 dark:text-gray-500 mb-4">まだコメントはありません。</p>
127
- {% endif %}
128
-
129
- <div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl p-4 mt-4">
130
- <h3 class="font-bold text-sm text-gray-700 dark:text-gray-200 mb-3">コメントを投稿</h3>
131
- <input id="comment-name" type="text" placeholder="名前(省略可)" maxlength="50"
132
- class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm mb-2 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099]">
133
- <textarea id="comment-body" placeholder="コメントを入力..." maxlength="500" rows="3"
134
- class="w-full border dark:border-gray-600 rounded-lg px-3 py-2 text-sm mb-3 bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099] resize-none"></textarea>
135
- <button onclick="submitComment('{{ article_id }}')"
136
- class="w-full bg-[#bf283a] text-white py-2 rounded-lg text-sm font-bold hover:bg-[#a01f2f] transition">投稿する</button>
137
- <p id="comment-msg" class="text-sm text-center mt-2 hidden"></p>
138
- </div>
139
- </section>
140
- {% endif %}
141
- </div>
142
- <script>
143
- async function submitComment(articleId, parentId = '') {
144
- const nameEl = parentId ? document.getElementById(`reply-name-${parentId}`) : document.getElementById('comment-name');
145
- const bodyEl = parentId ? document.getElementById(`reply-body-${parentId}`) : document.getElementById('comment-body');
146
- const msgEl = parentId ? document.getElementById(`reply-msg-${parentId}`) : document.getElementById('comment-msg');
147
- const name = nameEl.value.trim();
148
- const body = bodyEl.value.trim();
149
- if (!body) { showMsg(msgEl, 'コメントを入力してください', 'text-red-500'); return; }
150
- const res = await fetch('/api/comment', {
151
- method: 'POST', headers: {'Content-Type': 'application/json'},
152
- body: JSON.stringify({article_id: articleId, name, body, parent_id: parentId})
153
- });
154
- const data = await res.json();
155
- if (data.success) { location.reload(); }
156
- else { showMsg(msgEl, '投稿に失敗しました', 'text-red-500'); }
157
- }
158
-
159
- function toggleReplyForm(commentId) {
160
- const el = document.getElementById(`reply-${commentId}`);
161
- el.classList.toggle('hidden');
162
- if (!el.classList.contains('hidden')) {
163
- el.querySelector('textarea').focus();
164
- }
165
- }
166
-
167
- async function likeArticle(articleId) {
168
- const key = `liked_${articleId}`;
169
- const isLiked = !!localStorage.getItem(key);
170
- const btn = document.getElementById('like-btn');
171
- btn.disabled = true;
172
- const res = await fetch(`/api/like/${articleId}`, {
173
- method: 'POST', headers: {'Content-Type': 'application/json'},
174
- body: JSON.stringify({action: isLiked ? 'remove' : 'add'})
175
- });
176
- const data = await res.json();
177
- if (data.success) {
178
- if (isLiked) {
179
- localStorage.removeItem(key);
180
- document.getElementById('like-icon').textContent = '♡';
181
- btn.classList.remove('bg-[#fdf0f2]', 'dark:bg-[#3d1a20]');
182
- } else {
183
- localStorage.setItem(key, '1');
184
- document.getElementById('like-icon').textContent = '♥';
185
- btn.classList.add('bg-[#fdf0f2]', 'dark:bg-[#3d1a20]');
186
- }
187
- document.getElementById('like-count').textContent = data.likes;
188
- }
189
- btn.disabled = false;
190
- }
191
-
192
- function showMsg(el, text, cls) {
193
- el.textContent = text; el.className = `text-sm text-center mt-2 ${cls}`; el.classList.remove('hidden');
194
- }
195
-
196
- // ページ読み込み時:すでにいいね済みなら見た目を反映
197
- (function() {
198
- const articleId = '{{ article.get("id","") if article else "" }}';
199
- if (articleId && localStorage.getItem(`liked_${articleId}`)) {
200
- document.getElementById('like-icon').textContent = '♥';
201
- const btn = document.getElementById('like-btn');
202
- if (btn) btn.classList.add('bg-[#fdf0f2]', 'dark:bg-[#3d1a20]');
203
- }
204
- })();
205
- </script>
206
- {% if article and 'twitter-tweet' in content %}
207
- <script async src="https://platform.twitter.com/widgets.js"></script>
208
- {% endif %}
209
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
templates/blog_list.html DELETED
@@ -1,82 +0,0 @@
1
- {% extends "base.html" %}
2
- {% block title %}わせます9期ブログ{% endblock %}
3
- {% block body %}
4
- <div class="max-w-2xl mx-auto px-4 py-6">
5
- <div class="flex justify-between items-center mb-6">
6
- <h1 class="text-2xl font-bold text-[#bf283a] dark:text-[#f08090]">わせます9期ブログ</h1>
7
- <a href="/admin" class="text-sm text-gray-400 dark:text-gray-500 hover:text-[#bf283a] dark:hover:text-[#f08090] transition">🛠️ 管理</a>
8
- </div>
9
-
10
- <!-- 検索・絞り込み -->
11
- <form method="get" class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border dark:border-gray-700 p-4 mb-6 space-y-2">
12
- <div class="flex gap-2">
13
- <input type="text" name="q" value="{{ q }}" placeholder="🔍 タイトル・本文を検索"
14
- class="flex-1 border dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[#e09099]">
15
- <button type="submit" class="bg-[#bf283a] text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-[#a01f2f] transition">検索</button>
16
- </div>
17
- <div class="flex gap-2 flex-wrap">
18
- <select name="author" onchange="this.form.submit()"
19
- class="border dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 focus:outline-none">
20
- <option value="">👤 全員</option>
21
- {% for a in author_list %}
22
- <option value="{{ a }}" {% if author == a %}selected{% endif %}>{{ a }}</option>
23
- {% endfor %}
24
- </select>
25
- <select name="tag" onchange="this.form.submit()"
26
- class="border dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-100 focus:outline-none">
27
- <option value="">🏷️ 全タグ</option>
28
- {% for t in all_tags %}
29
- <option value="{{ t }}" {% if tag == t %}selected{% endif %}>{{ t }}</option>
30
- {% endfor %}
31
- </select>
32
- {% if q or author or tag %}
33
- <a href="/" class="text-sm text-gray-400 dark:text-gray-500 hover:text-[#bf283a] dark:hover:text-[#f08090] transition self-center ml-2">✕ リセット</a>
34
- {% endif %}
35
- </div>
36
- </form>
37
-
38
- <!-- 記事一覧 -->
39
- {% if articles %}
40
- {% for a in articles %}
41
- {% set ec = a.get("eyecatch") %}
42
- {% set ec_url = ec.get("url","") if ec is mapping else (ec or "") %}
43
- {% set tags = get_tags(a) %}
44
- {% set snippet = make_snippet(get_content(a)) %}
45
- <a href="/?id={{ a.id }}" class="block bg-white dark:bg-gray-800 rounded-xl border dark:border-gray-700 shadow-sm hover:shadow-md hover:border-[#bf283a] dark:hover:border-[#f08090] transition mb-4 overflow-hidden">
46
- <div class="flex gap-4 p-4">
47
- {% if ec_url %}
48
- <img src="{{ ec_url }}" alt="" class="w-24 h-24 object-cover rounded-lg flex-shrink-0">
49
- {% else %}
50
- <div class="w-24 h-24 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center flex-shrink-0 text-gray-400 dark:text-gray-500 text-xs">No Image</div>
51
- {% endif %}
52
- <div class="flex-1 min-w-0">
53
- <h2 class="font-bold text-gray-800 dark:text-gray-100 text-base leading-snug mb-1 truncate">{{ a.get("title","無題") }}</h2>
54
- <p class="text-xs text-gray-400 dark:text-gray-500 mb-2">👤 {{ a.get("author","") }} | 📅 {{ a.get("publishedAt","")[:10] }} | 💬 {{ comment_counts.get(a.id, 0) }}件</p>
55
- <div class="mb-2">
56
- {% for t in tags %}<span class="tag-badge">#{{ t }}</span>{% endfor %}
57
- </div>
58
- <p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">{{ snippet }}</p>
59
- </div>
60
- </div>
61
- </a>
62
- {% endfor %}
63
- {% else %}
64
- <div class="text-center text-gray-400 dark:text-gray-500 py-16">記事が見つかりませんでした</div>
65
- {% endif %}
66
-
67
- <!-- ページネーション -->
68
- {% if total_pages > 1 %}
69
- <div class="flex justify-center items-center gap-4 mt-6">
70
- {% if page > 0 %}
71
- <a href="/?page={{ page-1 }}&q={{ q }}&author={{ author }}&tag={{ tag }}"
72
- class="px-4 py-2 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-[#fdf0f2] dark:hover:bg-gray-700 transition">← 前へ</a>
73
- {% endif %}
74
- <span class="text-sm text-gray-500 dark:text-gray-400">{{ page+1 }} / {{ total_pages }}</span>
75
- {% if page < total_pages - 1 %}
76
- <a href="/?page={{ page+1 }}&q={{ q }}&author={{ author }}&tag={{ tag }}"
77
- class="px-4 py-2 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg text-sm text-gray-600 dark:text-gray-300 hover:bg-[#fdf0f2] dark:hover:bg-gray-700 transition">次へ →</a>
78
- {% endif %}
79
- </div>
80
- {% endif %}
81
- </div>
82
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
テキストブロックモック.html DELETED
@@ -1,125 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="ja">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>ブロックエディタ・プロトタイプ</title>
7
- <script src="https://cdn.tailwindcss.com"></script>
8
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
9
- <style>
10
- body { background-color: #f8f9fa; font-family: sans-serif; }
11
- .block-card { transition: all 0.2s ease; border-left: 4px solid transparent; }
12
- .block-card:hover { border-left-color: #ff69b4; background-color: #fff; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); }
13
- .editor-overlay { display: none; background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }
14
- .editor-overlay.active { display: flex; }
15
- /* リッチテキストのプレビュー用 */
16
- .rich-preview h2 { border-left: 4px solid #ff69b4; padding-left: 10px; font-weight: bold; font-size: 1.25rem; margin-top: 1rem; }
17
- .rich-preview p { line-height: 1.8; margin-bottom: 0.5rem; }
18
- </style>
19
- </head>
20
- <body class="pb-24">
21
-
22
- <!-- ヘッダー -->
23
- <header class="bg-white border-b sticky top-0 z-30 p-4 flex justify-between items-center">
24
- <button class="text-gray-600"><i class="fas fa-chevron-left"></i> 戻る</button>
25
- <h1 class="font-bold">記事の編集</h1>
26
- <button class="bg-pink-500 text-white px-4 py-1 rounded-full text-sm font-bold">保存</button>
27
- </header>
28
-
29
- <main class="max-w-2xl mx-auto p-4 space-y-6">
30
-
31
- <!-- タイトルエリア -->
32
- <div class="bg-white p-4 rounded-xl shadow-sm border">
33
- <label class="text-xs text-gray-400 font-bold uppercase">記事タイトル</label>
34
- <input type="text" value="週末のサークル活動記録" class="w-full text-xl font-bold border-none focus:ring-0 p-0" placeholder="タイトルを入力...">
35
- </div>
36
-
37
- <!-- ブロックリスト -->
38
- <div id="block-list" class="space-y-4">
39
-
40
- <!-- テキストブロック -->
41
- <div class="block-card bg-white p-4 rounded-xl border relative group">
42
- <div class="flex justify-between mb-2">
43
- <span class="text-xs font-bold text-pink-500 bg-pink-50 px-2 py-0.5 rounded">テキスト</span>
44
- <div class="flex gap-2 text-gray-400">
45
- <button class="hover:text-gray-600"><i class="fas fa-arrow-up"></i></button>
46
- <button class="hover:text-gray-600"><i class="fas fa-arrow-down"></i></button>
47
- <button class="hover:text-red-500"><i class="fas fa-trash"></i></button>
48
- </div>
49
- </div>
50
- <div class="rich-preview cursor-pointer" onclick="openEditor('block-1')">
51
- <p>今日はみんなで公園に集まりました。天気が良くて最高でした!</p>
52
- <h2>ランチの様子</h2>
53
- <p>お昼は近くのカフェで美味しいカレーを食べました。ボリューム満点で大満足です。</p>
54
- </div>
55
- <div class="mt-4 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
56
- <button class="bg-gray-100 hover:bg-gray-200 text-gray-600 text-xs py-1 px-3 rounded-full">+ ブロックを追加</button>
57
- </div>
58
- </div>
59
-
60
- <!-- 画像ブロック -->
61
- <div class="block-card bg-white p-4 rounded-xl border relative">
62
- <div class="flex justify-between mb-2">
63
- <span class="text-xs font-bold text-blue-500 bg-blue-50 px-2 py-0.5 rounded">画像</span>
64
- <div class="flex gap-2 text-gray-400">
65
- <button><i class="fas fa-arrow-up"></i></button>
66
- <button><i class="fas fa-arrow-down"></i></button>
67
- <button><i class="fas fa-trash"></i></button>
68
- </div>
69
- </div>
70
- <img src="https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?w=800&q=80" class="w-full h-48 object-cover rounded-lg">
71
- <p class="text-center text-sm text-gray-500 mt-2 italic">みんなで集合写真!</p>
72
- </div>
73
-
74
- <!-- 空のテキストブロック -->
75
- <div class="block-card bg-white p-4 rounded-xl border border-dashed text-center py-8 cursor-pointer hover:bg-gray-50" onclick="openEditor('new')">
76
- <p class="text-gray-400"><i class="fas fa-plus-circle mb-2 text-2xl"></i><br>ここをタップして文章を書く</p>
77
- </div>
78
-
79
- </div>
80
- </main>
81
-
82
- <!-- アイデア②の心臓部:編集モーダル -->
83
- <div id="editor-modal" class="editor-overlay fixed inset-0 z-50 flex-col items-center justify-end sm:justify-center p-0 sm:p-4">
84
- <div class="bg-white w-full max-w-2xl sm:rounded-2xl h-[90vh] sm:h-auto flex flex-col shadow-2xl overflow-hidden">
85
- <!-- モーダルヘッダー -->
86
- <div class="p-4 border-b flex justify-between items-center bg-gray-50">
87
- <button onclick="closeEditor()" class="text-gray-500 font-bold">キャンセル</button>
88
- <span class="font-bold">テキストを編集</span>
89
- <button onclick="closeEditor()" class="text-pink-500 font-bold">完了</button>
90
- </div>
91
-
92
- <!-- 擬似ツールバー -->
93
- <div class="p-2 border-b flex gap-4 overflow-x-auto bg-white">
94
- <button class="w-8 h-8 flex items-center justify-center border rounded"><i class="fas fa-bold"></i></button>
95
- <button class="w-8 h-8 flex items-center justify-center border rounded"><i class="fas fa-heading"></i></button>
96
- <button class="w-8 h-8 flex items-center justify-center border rounded"><i class="fas fa-list-ul"></i></button>
97
- <button class="w-8 h-8 flex items-center justify-center border rounded text-pink-500"><i class="fas fa-font"></i></button>
98
- <div class="h-8 w-[1px] bg-gray-200"></div>
99
- <button class="bg-blue-50 text-blue-600 px-3 py-0.5 rounded-full text-xs font-bold whitespace-nowrap"><i class="fas fa-magic"></i> AIで装飾</button>
100
- </div>
101
-
102
- <!-- エディタ本体 -->
103
- <textarea id="modal-textarea" class="w-full h-96 p-4 focus:ring-0 border-none text-lg leading-relaxed" placeholder="ここに入力...">今日はみんなで公園に集まりました。天気が良くて最高でした!
104
-
105
- ## ランチの様子
106
- お昼は近くのカフェで美味しいカレーを食べました。ボリューム満点で大満足です。</textarea>
107
-
108
- <div class="p-4 bg-gray-50 text-xs text-gray-400 text-center">
109
- ヒント:AI装飾ボタンを押すと、自動的に見出しや太字を適用します。
110
- </div>
111
- </div>
112
- </div>
113
-
114
- <script>
115
- function openEditor(id) {
116
- document.getElementById('editor-modal').classList.add('active');
117
- document.body.style.overflow = 'hidden';
118
- }
119
- function closeEditor() {
120
- document.getElementById('editor-modal').classList.remove('active');
121
- document.body.style.overflow = 'auto';
122
- }
123
- </script>
124
- </body>
125
- </html>