Spaces:
Running
Running
Update app.py
#1
by Nagomi-SugarHeart - opened
- .gitattributes +35 -0
- .github/workflows/sync_to_hf.yml +0 -23
- .gitignore +0 -9
- CLAUDE.md +0 -224
- Dockerfile +19 -9
- README.md +5 -3
- app.py +729 -0
- main.py +0 -687
- requirements.txt +4 -8
- templates/admin_editor.html +0 -894
- templates/admin_list.html +0 -92
- templates/admin_login.html +0 -30
- templates/base.html +0 -37
- templates/blog_detail.html +0 -209
- templates/blog_list.html +0 -82
- テキストブロックモック.html +0 -125
.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.
|
|
|
|
| 2 |
WORKDIR /app
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
short_description:
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
jinja2
|
| 4 |
-
python-multipart
|
| 5 |
requests
|
| 6 |
-
|
| 7 |
-
|
| 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を入力... 例: <h2>見出し</h2> <p>本文テキスト</p>"></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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
| 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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|