Spaces:
Running
Running
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>{% if article %}記事の編集{% else %}新規記事{% endif %} - わせます9期ブログ</title> | |
| <script>tailwind.config = { darkMode: 'media' }</script> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> | |
| <link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet"> | |
| <style> | |
| .block-card { transition: all 0.2s ease; border-left: 4px solid transparent; } | |
| .block-card:hover { border-left-color: #bf283a; } | |
| .editor-overlay { display: none; } | |
| .editor-overlay.active { display: flex; } | |
| .blog-preview { font-size: 16px; line-height: 1.8; word-break: break-word; } | |
| .blog-preview h1,.blog-preview h2,.blog-preview h3 { font-weight: bold; margin: 0.8em 0 0.4em; } | |
| .blog-preview h1 { font-size: 1.6rem; } | |
| .blog-preview h2 { border-left: 4px solid #bf283a; padding-left: 10px; font-size: 1.25rem; } | |
| .blog-preview h3 { font-size: 1.1rem; } | |
| .blog-preview p { line-height: 1.8; margin-bottom: 0.5rem; } | |
| .blog-preview strong { font-weight: bold; } | |
| .blog-preview em { font-style: italic; } | |
| .blog-preview ul { list-style: disc; padding-left: 1.5em; margin-bottom: 0.5rem; } | |
| .blog-preview ol { list-style: decimal; padding-left: 1.5em; margin-bottom: 0.5rem; } | |
| .blog-preview img { max-width: 100%; border-radius: 6px; margin: 4px 0; display: block; } | |
| #modal-textarea { font-size: 16px; } | |
| .tag-badge { display:inline-block; background:#e0f2fe; color:#0369a1; border-radius:9999px; padding:2px 10px; font-size:12px; margin:2px; } | |
| .toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); | |
| background: #1f2937; color: white; padding: 10px 24px; border-radius: 9999px; | |
| font-size: 14px; z-index: 100; opacity: 0; transition: opacity 0.3s; pointer-events: none; } | |
| .toast.show { opacity: 1; } | |
| .ql-toolbar.ql-snow { border: none; border-bottom: 1px solid #e5e7eb; padding: 6px 8px; flex-wrap: wrap; | |
| position: sticky; top: 0; z-index: 2; background: white; } | |
| .ql-container.ql-snow { border: none; font-size: 16px; height: auto ; overflow: visible ; } | |
| .ql-editor { min-height: 280px; padding: 12px 16px; line-height: 1.8; overflow-y: visible ; } | |
| .ql-editor p, .ql-editor h1, .ql-editor h2, .ql-editor h3 { margin-bottom: 0.5em; } | |
| #quill-container { overflow-y: auto; } | |
| .mode-btn { padding: 3px 12px; border-radius: 9999px; font-size: 12px; font-weight: bold; | |
| cursor: pointer; transition: all 0.15s; border: 1px solid #d1d5db; color: #6b7280; } | |
| .mode-btn.active { background: #bf283a; color: white; border-color: #bf283a; } | |
| @media (prefers-color-scheme: dark) { | |
| .block-card { background-color: #1f2937; border-color: #374151; } | |
| .block-card:hover { border-left-color: #f08090; } | |
| .block-card input, .block-card textarea, .block-card select { | |
| background-color: #374151; color: #e5e7eb; border-color: #4b5563; | |
| } | |
| .blog-preview { color: #e5e7eb; } | |
| .blog-preview h2 { border-left-color: #f08090; } | |
| .block-card .text-gray-700 { color: #d1d5db; } | |
| .block-card .text-gray-400 { color: #9ca3af; } | |
| .block-card .hover\:bg-gray-50:hover { background-color: #374151; } | |
| .ql-toolbar.ql-snow { border-bottom-color: #374151; background: #1f2937; } | |
| .ql-toolbar.ql-snow .ql-stroke { stroke: #9ca3af; } | |
| .ql-toolbar.ql-snow .ql-fill { fill: #9ca3af; } | |
| .ql-toolbar.ql-snow .ql-picker { color: #9ca3af; } | |
| .ql-toolbar.ql-snow .ql-picker-options { background: #1f2937; border-color: #374151; } | |
| .ql-container.ql-snow { background: #1f2937; color: #e5e7eb; } | |
| .mode-btn { border-color: #4b5563; color: #9ca3af; background: transparent; } | |
| .mode-btn.active { background: #bf283a; color: white; border-color: #bf283a; } | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 dark:bg-gray-900 pb-24"> | |
| <!-- 保存完了バナー(リロード後に表示) --> | |
| <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"> | |
| ✅ 下書き保存しました | |
| </div> | |
| <!-- スティッキーヘッダー --> | |
| <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"> | |
| <a href="/admin/articles" class="text-gray-500 dark:text-gray-400 text-sm hover:text-[#bf283a] dark:hover:text-[#f08090] transition"> | |
| <i class="fas fa-chevron-left mr-1"></i>一覧に戻る | |
| </a> | |
| <h1 class="font-bold text-gray-800 dark:text-gray-100 text-sm"> | |
| {% if article %}📝 記事の編集{% else %}✍️ 新規記事{% endif %} | |
| </h1> | |
| <div class="flex gap-2"> | |
| <button onclick="save('draft')" | |
| 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"> | |
| 下書き保存 | |
| </button> | |
| <button onclick="save('public')" | |
| class="bg-[#bf283a] text-white px-4 py-1.5 rounded-full text-sm font-bold hover:bg-[#a01f2f] transition"> | |
| 公開する | |
| </button> | |
| </div> | |
| </header> | |
| <main class="max-w-2xl mx-auto p-4 space-y-4"> | |
| <!-- タイトル --> | |
| <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700"> | |
| <label class="text-xs text-gray-400 dark:text-gray-500 font-bold uppercase block mb-1">記事タイトル</label> | |
| <textarea id="title-input" rows="1" | |
| oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'" | |
| placeholder="タイトルを入力..." | |
| style="font-size:18px" | |
| 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> | |
| </div> | |
| <!-- 著者・タグ --> | |
| <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"> | |
| <div> | |
| <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-1">著者</label> | |
| <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]"> | |
| {% for a in author_list %} | |
| <option value="{{ a }}" {% if article and article.get('author') == a %}selected{% endif %}>{{ a }}</option> | |
| {% endfor %} | |
| </select> | |
| </div> | |
| <div> | |
| <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-1">タグ(カンマ区切り)</label> | |
| <textarea id="tags-input" rows="1" | |
| oninput="this.style.height='auto';this.style.height=this.scrollHeight+'px'" | |
| onkeydown="if(event.key==='Enter')event.preventDefault()" | |
| placeholder="イベント, 日常" | |
| 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 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> | |
| </div> | |
| </div> | |
| <!-- バックアップから復元 --> | |
| {% if backups %} | |
| <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700"> | |
| <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-2">📦 バックアップから復元</label> | |
| <div class="flex gap-2"> | |
| <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]"> | |
| {% for bk in backups %} | |
| <option data-backup='{{ bk | tojson }}'>v{{ bk.get('version','?') }} - {{ bk.get('createdAt_jst', bk.get('createdAt','')[:16] | replace('T',' ')) }}</option> | |
| {% endfor %} | |
| </select> | |
| <button onclick="applySelectedBackup()" | |
| 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"> | |
| 復元 | |
| </button> | |
| </div> | |
| </div> | |
| {% endif %} | |
| <!-- アイキャッチ画像 --> | |
| <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700"> | |
| <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-2">🖼️ キャッチ画像</label> | |
| <input type="hidden" id="eyecatch-url" value="{{ eyecatch_url if eyecatch_url else '' }}"> | |
| <div id="eyecatch-preview" class="{% if not eyecatch_url %}hidden{% endif %} mb-3"> | |
| <div class="relative inline-block"> | |
| <img id="eyecatch-img" src="{{ eyecatch_url if eyecatch_url else '' }}" alt="" class="h-32 rounded-lg object-cover"> | |
| <button onclick="removeEyecatch()" | |
| 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> | |
| </div> | |
| </div> | |
| <div id="eyecatch-upload" class="{% if eyecatch_url %}hidden{% endif %}"> | |
| <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"> | |
| <i class="fas fa-cloud-upload-alt text-2xl text-gray-400 dark:text-gray-500 mb-2"></i> | |
| <span class="text-sm text-gray-500 dark:text-gray-400">クリックして画像を選択</span> | |
| <span class="text-xs text-gray-400 dark:text-gray-500 mt-1">JPG / PNG / HEIC</span> | |
| <input type="file" accept="image/*" class="hidden" onchange="uploadEyecatch(this)"> | |
| </label> | |
| </div> | |
| </div> | |
| <!-- ブロックリスト --> | |
| <div> | |
| <div class="flex justify-between items-center mb-2"> | |
| <span class="text-xs text-gray-400 dark:text-gray-500 font-bold uppercase">本文ブロック</span> | |
| <span class="text-xs text-gray-400 dark:text-gray-500">↑↓ で順番を変更</span> | |
| </div> | |
| <div id="block-list" class="space-y-1"></div> | |
| </div> | |
| <!-- 全体AI装飾 --> | |
| <div class="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-sm border dark:border-gray-700"> | |
| <label class="text-xs text-gray-400 dark:text-gray-500 font-bold block mb-2">✨ AIで全ブロックを一括装飾</label> | |
| <div class="flex gap-2"> | |
| <input id="global-ai-instruction" type="text" | |
| placeholder="例: 見出しをつけてスタイルを統一して" | |
| style="font-size:16px" | |
| 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"> | |
| <button id="global-ai-btn" onclick="runGlobalAI()" | |
| class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-blue-600 transition whitespace-nowrap"> | |
| <i class="fas fa-magic mr-1"></i>実行 | |
| </button> | |
| </div> | |
| <p id="global-ai-msg" class="text-xs mt-2 hidden"></p> | |
| </div> | |
| </main> | |
| <!-- テキスト編集モーダル(スマホ: フルスクリーン / PC: センター) --> | |
| <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%"> | |
| <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]"> | |
| <!-- モーダルヘッダー --> | |
| <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"> | |
| <button onclick="closeEditor(false)" class="text-gray-500 dark:text-gray-400 font-bold text-sm">キャンセル</button> | |
| <div class="flex gap-1"> | |
| <button id="btn-mode-rich" class="mode-btn active" onclick="setEditorMode('rich')">リッチ</button> | |
| <button id="btn-mode-html" class="mode-btn" onclick="setEditorMode('html')">HTML</button> | |
| </div> | |
| <button onclick="closeEditor(true)" class="text-[#bf283a] dark:text-[#f08090] font-bold text-sm">完了</button> | |
| </div> | |
| <!-- HTMLモード用ツールバー --> | |
| <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"> | |
| <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> | |
| <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> | |
| <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> | |
| <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> | |
| <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> | |
| <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> | |
| <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> | |
| <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> | |
| <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> | |
| <div class="h-8 w-px bg-gray-200 dark:bg-gray-600 mx-1"></div> | |
| <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"> | |
| <i class="fas fa-magic"></i> AIで装飾 | |
| </button> | |
| </div> | |
| <!-- AI パネル --> | |
| <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"> | |
| <div class="flex gap-2"> | |
| <input id="ai-instruction" type="text" placeholder="指示を入力(例:見出しをつけて)" | |
| style="font-size:16px" | |
| 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"> | |
| <button id="ai-btn" onclick="runAI()" | |
| class="bg-blue-500 text-white px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-blue-600 transition whitespace-nowrap"> | |
| 実行 | |
| </button> | |
| </div> | |
| </div> | |
| <!-- リッチテキストエディタ(Quill) --> | |
| <div id="quill-container" class="flex-1 overflow-y-auto bg-white dark:bg-gray-800 min-h-0"> | |
| <div id="quill-editor"></div> | |
| </div> | |
| <!-- HTMLテキストエリア(HTMLモード) --> | |
| <textarea id="modal-textarea" | |
| 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" | |
| style="min-height:300px" | |
| placeholder="ここにHTMLを入力... 例: <h2>見出し</h2> <p>本文テキスト</p>"></textarea> | |
| <div class="border-t dark:border-gray-700 flex-shrink-0"> | |
| <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"> | |
| <i class="fas fa-eye mr-1"></i>プレビューを表示/非表示 | |
| </button> | |
| <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> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- バックアップ復元確認モーダル --> | |
| <div id="restore-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50"> | |
| <div class="bg-white dark:bg-gray-800 rounded-2xl p-6 w-80 shadow-2xl border dark:border-gray-700"> | |
| <h2 class="font-bold text-gray-800 dark:text-gray-100 mb-2">バックアップを復元</h2> | |
| <p class="text-sm text-gray-500 dark:text-gray-400 mb-4">現在の編集内容が失われます。続けますか?</p> | |
| <div class="flex gap-3"> | |
| <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> | |
| <button onclick="executeRestore()" class="flex-1 bg-blue-500 text-white rounded-xl py-2 text-sm font-bold hover:bg-blue-600">復元する</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- 全体プレビューモーダル --> | |
| <div id="preview-modal" class="hidden fixed inset-0 z-50 bg-white dark:bg-gray-900 overflow-y-auto"> | |
| <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"> | |
| <span class="font-bold text-sm text-gray-700 dark:text-gray-200">📄 記事プレビュー</span> | |
| <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> | |
| </div> | |
| <div class="max-w-2xl mx-auto px-4 py-6 pb-20"> | |
| <h1 id="fp-title" class="text-2xl font-bold text-gray-800 dark:text-gray-100 mb-3 leading-snug"></h1> | |
| <p id="fp-meta" class="text-sm text-gray-400 dark:text-gray-500 mb-4"></p> | |
| <div id="fp-tags" class="mb-6"></div> | |
| <div id="fp-body" class="blog-preview"></div> | |
| </div> | |
| </div> | |
| <!-- プレビューフローティングボタン --> | |
| <button onclick="openFullPreview()" | |
| 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"> | |
| <i class="fas fa-eye"></i> プレビュー | |
| </button> | |
| <div id="toast" class="toast">保存しました</div> | |
| <script> | |
| let blocks = {{ blocks_json | safe }}; | |
| let articleId = "{{ article.get('id','') if article else '' }}"; | |
| let currentEditIdx = null; | |
| let tempContent = ""; | |
| let restoreTarget = null; | |
| let editorMode = 'rich'; | |
| let quill = null; | |
| function initQuill() { | |
| if (quill) return; | |
| quill = new Quill('#quill-editor', { | |
| theme: 'snow', | |
| scrollingContainer: '#quill-container', | |
| modules: { | |
| toolbar: [ | |
| [{ header: [1, 2, 3, false] }], | |
| [{ size: ['small', false, 'large', 'huge'] }], | |
| ['bold', 'italic', 'underline', 'strike'], | |
| [{ color: [] }], | |
| [{ list: 'bullet' }, { list: 'ordered' }], | |
| ['blockquote', 'link', 'video'], | |
| ['clean'] | |
| ] | |
| } | |
| }); | |
| quill.on('text-change', updatePreview); | |
| // Quill ツールバーにカスタム X 埋め込みボタンを追加 | |
| const qlToolbar = document.querySelector('#quill-container .ql-toolbar'); | |
| if (qlToolbar) { | |
| const xGroup = document.createElement('span'); | |
| xGroup.className = 'ql-formats'; | |
| const xBtn = document.createElement('button'); | |
| xBtn.innerHTML = '𝕏'; | |
| xBtn.title = 'X(Twitter)埋め込み'; | |
| xBtn.style.cssText = 'font-size:12px;font-weight:bold;width:auto;padding:0 6px;'; | |
| xBtn.addEventListener('click', () => insertEmbed('twitter')); | |
| xGroup.appendChild(xBtn); | |
| qlToolbar.appendChild(xGroup); | |
| } | |
| // キーボードを閉じずにツールバーボタンを使えるようにする | |
| if (qlToolbar) { | |
| // デスクトップ: mousedown で blur を抑制 | |
| qlToolbar.addEventListener('mousedown', e => e.preventDefault()); | |
| // iOS: タッチ開始時にカーソル位置を保存し、タッチ終了後に復元 | |
| let _savedRange = null; | |
| let _toolbarTouched = false; | |
| qlToolbar.addEventListener('touchstart', () => { | |
| _savedRange = quill.getSelection(); | |
| _toolbarTouched = true; | |
| }, { passive: true }); | |
| qlToolbar.addEventListener('touchend', () => { | |
| if (_savedRange !== null) { | |
| setTimeout(() => { | |
| quill.root.focus({ preventScroll: true }); | |
| quill.setSelection(_savedRange.index, _savedRange.length, 'silent'); | |
| _savedRange = null; | |
| _toolbarTouched = false; | |
| }, 20); | |
| } | |
| }); | |
| } | |
| } | |
| function setEditorMode(mode) { | |
| editorMode = mode; | |
| const quillCont = document.getElementById('quill-container'); | |
| const ta = document.getElementById('modal-textarea'); | |
| const htmlTb = document.getElementById('html-toolbar'); | |
| const btnRich = document.getElementById('btn-mode-rich'); | |
| const btnHtml = document.getElementById('btn-mode-html'); | |
| if (mode === 'rich') { | |
| initQuill(); | |
| quill.root.innerHTML = ta.value; | |
| quillCont.classList.remove('hidden'); | |
| ta.classList.add('hidden'); | |
| htmlTb.classList.add('hidden'); | |
| btnRich.classList.add('active'); | |
| btnHtml.classList.remove('active'); | |
| } else { | |
| if (quill) ta.value = quill.root.innerHTML; | |
| quillCont.classList.add('hidden'); | |
| ta.classList.remove('hidden'); | |
| htmlTb.classList.remove('hidden'); | |
| btnHtml.classList.add('active'); | |
| btnRich.classList.remove('active'); | |
| } | |
| updatePreview(); | |
| } | |
| function getEditorContent() { | |
| if (editorMode === 'rich' && quill) return quill.root.innerHTML; | |
| return document.getElementById('modal-textarea').value; | |
| } | |
| function setEditorContent(html) { | |
| document.getElementById('modal-textarea').value = html; | |
| if (quill) quill.root.innerHTML = html; | |
| } | |
| function buildAddRow(insertIdx) { | |
| const div = document.createElement('div'); | |
| div.className = 'flex justify-center gap-2 py-2'; | |
| div.innerHTML = ` | |
| <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> | |
| <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> | |
| `; | |
| return div; | |
| } | |
| function renderBlocks() { | |
| const list = document.getElementById('block-list'); | |
| list.innerHTML = ''; | |
| list.appendChild(buildAddRow(0)); | |
| if (blocks.length === 0) return; | |
| blocks.forEach((block, i) => { | |
| const card = document.createElement('div'); | |
| card.className = 'block-card bg-white dark:bg-gray-800 rounded-xl border dark:border-gray-700 shadow-sm'; | |
| card.id = `card-${block.id}`; | |
| card.innerHTML = buildCardHTML(block, i); | |
| list.appendChild(card); | |
| list.appendChild(buildAddRow(i + 1)); | |
| }); | |
| } | |
| function buildCardHTML(block, i) { | |
| const isFirst = i === 0; | |
| const isLast = i === blocks.length - 1; | |
| const badge = block.type === 'text' | |
| ? '<span class="text-xs font-bold text-[#bf283a] bg-[#fdf0f2] px-2 py-0.5 rounded">テキスト</span>' | |
| : '<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>'; | |
| const controls = ` | |
| <div class="flex gap-1 text-gray-400"> | |
| <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"> | |
| <i class="fas fa-arrow-up text-xs"></i> | |
| </button> | |
| <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"> | |
| <i class="fas fa-arrow-down text-xs"></i> | |
| </button> | |
| <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"> | |
| <i class="fas fa-trash text-xs"></i> | |
| </button> | |
| </div>`; | |
| let content = ''; | |
| if (block.type === 'text') { | |
| const preview = block.html | |
| ? `<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>` | |
| : `<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>`; | |
| content = `<div class="mt-2">${preview}</div>`; | |
| } else { | |
| content = buildImageContent(block, i); | |
| } | |
| return `<div class="p-4"><div class="flex justify-between items-center mb-1">${badge}${controls}</div>${content}</div>`; | |
| } | |
| function buildImageContent(block, i) { | |
| if (block.url) { | |
| return ` | |
| <div class="mt-2"> | |
| <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"> | |
| ${block.alt ? `<p class="text-xs text-center text-gray-400 dark:text-gray-500 mt-1 italic">${escHtml(block.alt)}</p>` : ''} | |
| <div class="flex gap-2 mt-2"> | |
| <input type="text" value="${escHtml(block.alt||'')}" placeholder="キャプション" | |
| onchange="updateBlockField(${i},'alt',this.value)" | |
| 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"> | |
| <input type="number" value="${block.maxWidth||900}" min="200" max="2000" step="100" | |
| onchange="updateBlockField(${i},'maxWidth',parseInt(this.value))" | |
| 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"> | |
| <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> | |
| </div> | |
| <p class="text-xs text-gray-400 dark:text-gray-500 mt-1">最大幅: ${block.maxWidth||900}px</p> | |
| </div>`; | |
| } | |
| return ` | |
| <div class="mt-2"> | |
| <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"> | |
| <i class="fas fa-cloud-upload-alt text-2xl text-gray-400 dark:text-gray-500 mb-2"></i> | |
| <span class="text-sm text-gray-500 dark:text-gray-400">クリックして画像をアップロード</span> | |
| <input type="file" accept="image/*" class="hidden" onchange="uploadBlockImage(${i},this)"> | |
| </label> | |
| </div>`; | |
| } | |
| function moveBlock(idx, dir) { | |
| const ni = idx + dir; | |
| if (ni < 0 || ni >= blocks.length) return; | |
| [blocks[idx], blocks[ni]] = [blocks[ni], blocks[idx]]; | |
| renderBlocks(); | |
| } | |
| function deleteBlock(idx) { | |
| blocks.splice(idx, 1); | |
| renderBlocks(); | |
| } | |
| function addBlock(idx, type) { | |
| const id = 'block-' + Math.random().toString(36).substr(2, 8); | |
| const nb = type === 'text' | |
| ? {type:'text', id, html:'', editorMode:'rich'} | |
| : {type:'image', id, url:'', alt:'', maxWidth:900}; | |
| blocks.splice(idx, 0, nb); | |
| renderBlocks(); | |
| if (type === 'text') openEditor(idx); | |
| } | |
| function updateBlockField(idx, field, value) { blocks[idx][field] = value; } | |
| function clearImage(idx) { blocks[idx].url = ''; blocks[idx].alt = ''; renderBlocks(); } | |
| function syncModalToViewport() { | |
| const modal = document.getElementById('editor-modal'); | |
| if (!modal.classList.contains('active')) return; | |
| if (!window.visualViewport) return; | |
| const vv = window.visualViewport; | |
| modal.style.top = vv.offsetTop + 'px'; | |
| modal.style.height = vv.height + 'px'; | |
| } | |
| if (window.visualViewport) { | |
| window.visualViewport.addEventListener('resize', syncModalToViewport); | |
| window.visualViewport.addEventListener('scroll', syncModalToViewport); | |
| } | |
| function openEditor(idx) { | |
| currentEditIdx = idx; | |
| const block = blocks[idx]; | |
| const html = block.html || ''; | |
| const blockMode = block.editorMode || 'rich'; | |
| // 両エディタに内容をセットしてからモードを切り替える | |
| document.getElementById('modal-textarea').value = html; | |
| if (quill) quill.root.innerHTML = html; | |
| tempContent = html; | |
| document.getElementById('editor-modal').classList.add('active'); | |
| document.body.style.overflow = 'hidden'; | |
| syncModalToViewport(); | |
| setEditorMode(blockMode); | |
| } | |
| function closeEditor(save) { | |
| if (save && currentEditIdx !== null) { | |
| blocks[currentEditIdx].html = getEditorContent(); | |
| blocks[currentEditIdx].editorMode = editorMode; | |
| } | |
| currentEditIdx = null; | |
| const modal = document.getElementById('editor-modal'); | |
| modal.classList.remove('active'); | |
| modal.style.top = ''; | |
| modal.style.height = ''; | |
| document.body.style.overflow = ''; | |
| document.getElementById('ai-panel').classList.add('hidden'); | |
| document.getElementById('modal-preview').classList.add('hidden'); | |
| renderBlocks(); | |
| } | |
| function updatePreview() { | |
| const prev = document.getElementById('modal-preview'); | |
| if (!prev.classList.contains('hidden')) prev.innerHTML = getEditorContent(); | |
| } | |
| function togglePreview() { | |
| const prev = document.getElementById('modal-preview'); | |
| prev.classList.toggle('hidden'); | |
| if (!prev.classList.contains('hidden')) prev.innerHTML = getEditorContent(); | |
| } | |
| document.getElementById('modal-textarea').addEventListener('input', updatePreview); | |
| function tb(type) { | |
| const ta = document.getElementById('modal-textarea'); | |
| const s = ta.selectionStart, e = ta.selectionEnd; | |
| const sel = ta.value.substring(s, e); | |
| let open = '', close = '', full = null; | |
| if (type === 'bold') { open = '<strong>'; close = '</strong>'; } | |
| else if (type === 'h2') { full = `<h2>${sel || '見出し'}</h2>`; } | |
| else if (type === 'h3') { full = `<h3>${sel || '見出し'}</h3>`; } | |
| else if (type === 'p') { full = `<p>${sel || 'テキスト'}</p>`; } | |
| else if (type === 'ul') { full = `<ul>\n <li>${sel || '項目'}</li>\n</ul>`; } | |
| else if (type === 'blockquote') { | |
| 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>`; | |
| } | |
| else if (type === 'link') { | |
| const href = prompt('URLを入力:'); | |
| if (href) full = `<a href="${href}">${sel || href}</a>`; | |
| } | |
| if (full !== null) { | |
| ta.value = ta.value.substring(0, s) + full + ta.value.substring(e); | |
| ta.setSelectionRange(s + full.length, s + full.length); | |
| } else if (open) { | |
| const replaced = open + sel + close; | |
| ta.value = ta.value.substring(0, s) + replaced + ta.value.substring(e); | |
| ta.setSelectionRange(s + open.length, s + open.length + sel.length); | |
| } | |
| updatePreview(); ta.focus(); | |
| } | |
| function toggleAiPanel() { document.getElementById('ai-panel').classList.toggle('hidden'); } | |
| async function runAI() { | |
| const ins = document.getElementById('ai-instruction').value.trim(); | |
| const currentHtml = getEditorContent(); | |
| if (!ins || !currentHtml) { alert('本文と指示を入力してください'); return; } | |
| const btn = document.getElementById('ai-btn'); | |
| btn.disabled = true; btn.textContent = '処理中...'; | |
| try { | |
| const res = await fetch('/api/ai-decorate', { | |
| method: 'POST', headers: {'Content-Type':'application/json'}, | |
| body: JSON.stringify({html: currentHtml, instruction: ins}) | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { setEditorContent(data.html); setEditorMode('html'); } | |
| else { alert('AIエラー: ' + data.error); } | |
| } catch(err) { alert('通信エラー: ' + err.message); } | |
| finally { btn.disabled = false; btn.textContent = '実行'; } | |
| } | |
| async function uploadBlockImage(idx, input) { | |
| const file = input.files[0]; | |
| if (!file) return; | |
| const fd = new FormData(); | |
| fd.append('file', file); | |
| fd.append('max_width', blocks[idx].maxWidth || 900); | |
| try { | |
| const res = await fetch('/api/upload-image', {method:'POST', body: fd}); | |
| const data = await res.json(); | |
| if (data.success && data.url) { blocks[idx].url = data.url; renderBlocks(); } | |
| else { alert('アップロード失敗: ' + (data.error||'')); } | |
| } catch(err) { alert('通信エラー: ' + err.message); } | |
| } | |
| async function uploadEyecatch(input) { | |
| const file = input.files[0]; | |
| if (!file) return; | |
| const fd = new FormData(); | |
| fd.append('file', file); | |
| fd.append('max_width', '1200'); | |
| try { | |
| const res = await fetch('/api/upload-image', {method:'POST', body: fd}); | |
| const data = await res.json(); | |
| if (data.success && data.url) { | |
| document.getElementById('eyecatch-url').value = data.url; | |
| document.getElementById('eyecatch-img').src = data.url; | |
| document.getElementById('eyecatch-preview').classList.remove('hidden'); | |
| document.getElementById('eyecatch-upload').classList.add('hidden'); | |
| } else { alert('アップロード失敗: ' + (data.error||'')); } | |
| } catch(err) { alert('通信エラー: ' + err.message); } | |
| } | |
| function removeEyecatch() { | |
| document.getElementById('eyecatch-url').value = ''; | |
| document.getElementById('eyecatch-preview').classList.add('hidden'); | |
| document.getElementById('eyecatch-upload').classList.remove('hidden'); | |
| } | |
| function applySelectedBackup() { | |
| const sel = document.getElementById('backup-select'); | |
| if (!sel) return; | |
| const opt = sel.options[sel.selectedIndex]; | |
| if (!opt) return; | |
| try { | |
| restoreTarget = JSON.parse(opt.getAttribute('data-backup')); | |
| document.getElementById('restore-modal').classList.remove('hidden'); | |
| } catch(e) { alert('バックアップデータの読み込みに失敗しました'); } | |
| } | |
| function closeRestoreModal() { | |
| document.getElementById('restore-modal').classList.add('hidden'); | |
| restoreTarget = null; | |
| } | |
| function executeRestore() { | |
| if (!restoreTarget) return; | |
| const bk = restoreTarget; | |
| if (bk.contentBlocks) { | |
| try { blocks = JSON.parse(bk.contentBlocks); } | |
| catch { blocks = [{type:'text', id:'block-0', html: bk.contentRich || bk.content || ''}]; } | |
| } else { | |
| blocks = [{type:'text', id:'block-0', html: bk.contentRich || bk.content || ''}]; | |
| } | |
| if (bk.title) document.getElementById('title-input').value = bk.title.replace(/^【下書き】/, ''); | |
| if (bk.tags) { | |
| const tagsEl = document.getElementById('tags-input'); | |
| tagsEl.value = bk.tags; | |
| tagsEl.style.height = 'auto'; | |
| tagsEl.style.height = tagsEl.scrollHeight + 'px'; | |
| } | |
| const ec = bk.eyecatch; | |
| if (ec) { | |
| const ecUrl = typeof ec === 'object' ? ec.url : ec; | |
| if (ecUrl) { | |
| document.getElementById('eyecatch-url').value = ecUrl; | |
| document.getElementById('eyecatch-img').src = ecUrl; | |
| document.getElementById('eyecatch-preview').classList.remove('hidden'); | |
| document.getElementById('eyecatch-upload').classList.add('hidden'); | |
| } | |
| } | |
| closeRestoreModal(); | |
| renderBlocks(); | |
| showToast('バックアップを復元しました'); | |
| } | |
| async function save(status) { | |
| const title = document.getElementById('title-input').value.trim(); | |
| if (!title) { alert('タイトルを入力してください'); return; } | |
| const hasContent = blocks.some(b => (b.html && b.html.trim()) || b.url); | |
| if (!hasContent) { alert('本文を入力してください'); return; } | |
| const payload = { | |
| article_id: articleId || null, title, | |
| author: document.getElementById('author-select').value, | |
| tags: document.getElementById('tags-input').value, | |
| eyecatch: document.getElementById('eyecatch-url').value, | |
| blocks, status, | |
| }; | |
| const headerBtns = document.querySelectorAll('header button'); | |
| headerBtns.forEach(b => b.disabled = true); | |
| try { | |
| const res = await fetch('/api/save', { | |
| method:'POST', headers:{'Content-Type':'application/json'}, | |
| body: JSON.stringify(payload) | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| const backupMsg = data.backup_ok ? '' : '(バックアップ失敗)'; | |
| if (!data.backup_ok && data.backup_err) console.warn('backup error:', data.backup_err); | |
| if (status === 'draft') { | |
| // 下書き保存 → リロードしてバックアップ一覧を更新 | |
| const newId = data.id || articleId; | |
| sessionStorage.setItem('draft_saved_msg', '✅ 下書き保存しました' + backupMsg); | |
| window.location.href = `/admin/edit/${newId}`; | |
| return; | |
| } | |
| // 公開保存 | |
| if (!articleId && data.id) { | |
| articleId = data.id; | |
| history.replaceState({}, '', `/admin/edit/${data.id}`); | |
| document.querySelector('header h1').textContent = '📝 記事の編集'; | |
| } | |
| if (status === 'public' && data.id !== articleId) { | |
| articleId = data.id; | |
| history.replaceState({}, '', `/admin/edit/${data.id}`); | |
| } | |
| showToast('公開しました!' + backupMsg); | |
| } else { alert('保存エラー: ' + (data.error || '不明なエラー')); } | |
| } catch(err) { alert('通信エラー: ' + err.message); } | |
| finally { headerBtns.forEach(b => b.disabled = false); } | |
| } | |
| function _pasteHtml(html) { | |
| if (editorMode === 'rich' && quill) { | |
| const range = quill.getSelection(true) || { index: quill.getLength() - 1 }; | |
| quill.clipboard.dangerouslyPasteHTML(range.index, html); | |
| } else { | |
| const ta = document.getElementById('modal-textarea'); | |
| const s = ta.selectionStart, e = ta.selectionEnd; | |
| ta.value = ta.value.substring(0, s) + html + ta.value.substring(e); | |
| ta.setSelectionRange(s + html.length, s + html.length); | |
| updatePreview(); | |
| } | |
| } | |
| function insertEmbed(type) { | |
| if (type === 'youtube') { | |
| const url = prompt('YouTube URLを入力してください\n例: https://www.youtube.com/watch?v=XXXXX'); | |
| if (!url) return; | |
| const m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/); | |
| if (!m) { alert('YouTube URLが認識できませんでした'); return; } | |
| const html = `<div class="embed-yt"><iframe src="https://www.youtube.com/embed/${m[1]}" allowfullscreen></iframe></div>`; | |
| _pasteHtml(html); | |
| } else if (type === 'twitter') { | |
| const url = prompt('X(Twitter) のポストURLを入力してください\n例: https://x.com/user/status/123...'); | |
| if (!url) return; | |
| if (!/(?:twitter\.com|x\.com)\/\w+\/status\/\d+/.test(url)) { | |
| alert('X(Twitter)のURLが認識できませんでした'); return; | |
| } | |
| const html = `<blockquote class="twitter-tweet"><a href="${url}"></a></blockquote>`; | |
| _pasteHtml(html); | |
| } | |
| } | |
| function blocksToHtml() { | |
| return blocks.map(b => { | |
| if (b.type === 'text') return b.html || ''; | |
| if (b.type === 'image' && b.url) { | |
| const caption = b.alt | |
| ? `<p style="text-align:center;font-size:0.8em;color:#9ca3af;margin-top:4px;">${escHtml(b.alt)}</p>` | |
| : ''; | |
| 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>`; | |
| } | |
| return ''; | |
| }).join('\n'); | |
| } | |
| function openFullPreview() { | |
| const title = document.getElementById('title-input').value.trim() || '(タイトルなし)'; | |
| const author = document.getElementById('author-select').value; | |
| const tags = document.getElementById('tags-input').value | |
| .split(',').map(t => t.trim()).filter(Boolean); | |
| document.getElementById('fp-title').textContent = title; | |
| document.getElementById('fp-meta').textContent = '👤 ' + author; | |
| document.getElementById('fp-tags').innerHTML = | |
| tags.map(t => `<span class="tag-badge">#${escHtml(t)}</span>`).join(''); | |
| document.getElementById('fp-body').innerHTML = blocksToHtml(); | |
| document.getElementById('preview-modal').classList.remove('hidden'); | |
| document.body.style.overflow = 'hidden'; | |
| } | |
| function closeFullPreview() { | |
| document.getElementById('preview-modal').classList.add('hidden'); | |
| document.body.style.overflow = ''; | |
| } | |
| function showToast(msg) { | |
| const t = document.getElementById('toast'); | |
| t.textContent = msg; t.classList.add('show'); | |
| setTimeout(() => t.classList.remove('show'), 2500); | |
| } | |
| function escHtml(s) { | |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); | |
| } | |
| async function runGlobalAI() { | |
| const ins = document.getElementById('global-ai-instruction').value.trim(); | |
| if (!ins) { alert('装飾の指示を入力してください'); return; } | |
| const textBlocks = blocks.filter(b => b.type === 'text' && b.html && b.html.trim()); | |
| if (textBlocks.length === 0) { alert('装飾するテキストブロックがありません'); return; } | |
| const btn = document.getElementById('global-ai-btn'); | |
| const msg = document.getElementById('global-ai-msg'); | |
| btn.disabled = true; | |
| btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>処理中...'; | |
| msg.textContent = ''; msg.classList.add('hidden'); | |
| try { | |
| const payload = textBlocks.map(b => ({ id: b.id, html: b.html })); | |
| const res = await fetch('/api/ai-decorate-all', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ blocks: payload, instruction: ins }) | |
| }); | |
| const data = await res.json(); | |
| if (data.success && data.decorated) { | |
| blocks.forEach(b => { | |
| if (b.type === 'text' && data.decorated[b.id] !== undefined) { | |
| b.html = data.decorated[b.id]; | |
| b.editorMode = 'html'; | |
| } | |
| }); | |
| renderBlocks(); | |
| msg.textContent = '✅ 全ブロックを装飾しました'; | |
| msg.className = 'text-xs text-green-600 dark:text-green-400 mt-2'; | |
| msg.classList.remove('hidden'); | |
| setTimeout(() => msg.classList.add('hidden'), 3000); | |
| } else { | |
| msg.textContent = 'AIエラー: ' + (data.error || '不明なエラー'); | |
| msg.className = 'text-xs text-red-500 mt-2'; | |
| msg.classList.remove('hidden'); | |
| } | |
| } catch(err) { | |
| msg.textContent = '通信エラー: ' + err.message; | |
| msg.className = 'text-xs text-red-500 mt-2'; | |
| msg.classList.remove('hidden'); | |
| } finally { | |
| btn.disabled = false; | |
| btn.innerHTML = '<i class="fas fa-magic mr-1"></i>実行'; | |
| } | |
| } | |
| renderBlocks(); | |
| // リロード後の保存完了バナー表示 | |
| (function() { | |
| const msg = sessionStorage.getItem('draft_saved_msg'); | |
| if (msg) { | |
| sessionStorage.removeItem('draft_saved_msg'); | |
| const banner = document.getElementById('save-banner'); | |
| banner.textContent = msg; | |
| banner.classList.remove('hidden'); | |
| setTimeout(() => banner.classList.add('hidden'), 4000); | |
| } | |
| })(); | |
| document.addEventListener('keydown', e => { | |
| if (e.key === 'Escape' && document.getElementById('editor-modal').classList.contains('active')) closeEditor(false); | |
| }); | |
| </script> | |
| <script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script> | |
| <script> | |
| window.addEventListener('load', () => { | |
| initQuill(); | |
| document.getElementById('html-toolbar').classList.add('hidden'); | |
| document.getElementById('modal-textarea').classList.add('hidden'); | |
| document.getElementById('quill-container').classList.remove('hidden'); | |
| const titleEl = document.getElementById('title-input'); | |
| titleEl.style.height = 'auto'; | |
| titleEl.style.height = titleEl.scrollHeight + 'px'; | |
| const tagsEl = document.getElementById('tags-input'); | |
| tagsEl.style.height = 'auto'; | |
| tagsEl.style.height = tagsEl.scrollHeight + 'px'; | |
| }); | |
| </script> | |
| </body> | |
| </html> | |