9thblogs / templates /admin_editor.html
Claude
記事詳細のコメント件数表示・AI装飾後のHTMLモード強制・X埋め込み装飾禁止
45a24c3 unverified
Raw
History Blame Contribute Delete
45.4 kB
<!DOCTYPE html>
<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 !important; overflow: visible !important; }
.ql-editor { min-height: 280px; padding: 12px 16px; line-height: 1.8; overflow-y: visible !important; }
.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を入力...&#10;&#10;例:&#10;&lt;h2&gt;見出し&lt;/h2&gt;&#10;&lt;p&gt;本文テキスト&lt;/p&gt;"></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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>