easyMDE 会注入失效的 maxcdn.bootstrapcdn.com 样式链接导致工具栏图标加载失败。 按 SECURITY_TODO #9 无外部 CDN 政策,本地化 Font Awesome 4.7.0(css+fonts), 并在两处 EasyMDE 配置中禁用 autoDownloadFontAwesome。
280 lines
13 KiB
HTML
280 lines
13 KiB
HTML
{{define "article_create"}}
|
||
{{template "header" .}}
|
||
{{template "markdown_assets" .}}
|
||
|
||
<section class="max-w-3xl mx-auto px-4 py-10">
|
||
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
|
||
|
||
<div id="articleError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
|
||
{{.Error}}
|
||
</div>
|
||
|
||
<form id="articleForm" action="{{.FormAction}}" method="post" class="space-y-6">
|
||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||
<!-- Hidden: attachment ownership (token on create, id on edit) -->
|
||
<input type="hidden" name="session_token" value="{{.SessionToken}}">
|
||
|
||
<!-- Title -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_title"}}</label>
|
||
<input type="text" name="title" value="{{.FormTitle}}"
|
||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||
placeholder="{{index .Tr "article_title"}}">
|
||
</div>
|
||
|
||
<!-- Slug -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_slug"}}</label>
|
||
<input type="text" name="slug" value="{{.FormSlug}}"
|
||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||
placeholder="{{index .Tr "article_slug_hint"}}">
|
||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_slug_hint"}}</p>
|
||
</div>
|
||
|
||
<!-- Summary -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_summary"}}</label>
|
||
<textarea name="summary" rows="3"
|
||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors resize-y"
|
||
placeholder="{{index .Tr "article_summary"}}">{{.FormSummary}}</textarea>
|
||
</div>
|
||
|
||
<!-- Content (EasyMDE Markdown Editor) -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_content"}}</label>
|
||
<textarea id="articleContent" name="content">{{.FormContent}}</textarea>
|
||
</div>
|
||
|
||
<!-- Cover -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_cover"}}</label>
|
||
<input type="text" name="cover" value="{{.FormCover}}"
|
||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||
placeholder="https://...">
|
||
</div>
|
||
|
||
<!-- Tags -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_tags"}}</label>
|
||
<input type="text" name="tags" value="{{.FormTags}}"
|
||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors"
|
||
placeholder="{{index .Tr "article_tags_hint"}}">
|
||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_tags_hint"}}</p>
|
||
</div>
|
||
|
||
<!-- Attachments -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_attachments"}}</label>
|
||
<div class="flex items-center gap-3 mb-3">
|
||
<input type="file" id="attachmentInput" class="text-sm text-gray-600" disabled>
|
||
<button type="button" id="attachmentUploadBtn"
|
||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer disabled:opacity-50"
|
||
disabled>
|
||
{{index .Tr "article_upload"}}
|
||
</button>
|
||
<span id="attachmentMsg" class="text-xs text-gray-400"></span>
|
||
</div>
|
||
<table class="w-full text-left border border-gray-200 rounded-lg overflow-hidden">
|
||
<thead class="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_name"}}</th>
|
||
<th class="px-3 py-2 text-xs font-semibold text-gray-600">{{index .Tr "article_att_size"}}</th>
|
||
<th class="px-3 py-2 text-xs font-semibold text-gray-600 text-right">{{index .Tr "settings_actions"}}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="attachmentList" class="divide-y divide-gray-100"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Published At -->
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">{{index .Tr "article_published_at"}}</label>
|
||
<input type="datetime-local" name="published_at" value="{{.FormPublishedAt}}"
|
||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors">
|
||
<p class="text-xs text-gray-400 mt-1">{{index .Tr "article_published_at_hint"}}</p>
|
||
</div>
|
||
|
||
<!-- IsTop -->
|
||
<div class="flex items-center gap-2">
|
||
<input type="checkbox" name="is_top" value="1" id="isTopCheckbox" {{if .FormIsTop}}checked{{end}}
|
||
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500">
|
||
<label for="isTopCheckbox" class="text-sm font-medium text-gray-700">{{index .Tr "article_is_top"}}</label>
|
||
</div>
|
||
|
||
<!-- Submit Buttons -->
|
||
<div class="flex gap-3">
|
||
<button type="submit" name="status" value="0"
|
||
class="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors cursor-pointer">
|
||
{{index .Tr "article_save_draft"}}
|
||
</button>
|
||
<button type="submit" name="status" value="1"
|
||
class="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors cursor-pointer">
|
||
{{index .Tr "article_publish"}}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
{{template "footer" .}}
|
||
|
||
<script>
|
||
var easyMDE = new EasyMDE({
|
||
element: document.getElementById('articleContent'),
|
||
autoDownloadFontAwesome: false,
|
||
spellChecker: false,
|
||
autosave: { enabled: false },
|
||
placeholder: '{{index .Tr "article_content"}}',
|
||
previewRender: function (plainText, preview) {
|
||
return '<div class="md-body">' + BlogMD.render(plainText) + '</div>';
|
||
},
|
||
toolbar: [
|
||
'bold', 'italic', 'heading', '|',
|
||
'quote', 'unordered-list', 'ordered-list', '|',
|
||
'link', 'image', 'code', 'table', '|',
|
||
'preview', 'side-by-side', 'fullscreen', '|',
|
||
'guide'
|
||
],
|
||
status: false,
|
||
minHeight: '300px'
|
||
});
|
||
|
||
// ---- Attachments ----
|
||
(function () {
|
||
var articleID = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
|
||
var sessionToken = "{{ .SessionToken }}";
|
||
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
|
||
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
|
||
var uploadBtn = document.getElementById('attachmentUploadBtn');
|
||
var fileInput = document.getElementById('attachmentInput');
|
||
var msgEl = document.getElementById('attachmentMsg');
|
||
var listEl = document.getElementById('attachmentList');
|
||
|
||
// Enable the uploader (uploads allowed only while logged in, which is true here).
|
||
uploadBtn.disabled = false;
|
||
fileInput.disabled = false;
|
||
|
||
function fmtSize(b) {
|
||
if (b < 1024) return b + ' B';
|
||
var u = ['KiB', 'MiB', 'GiB'], i = -1;
|
||
do { b /= 1024; i++; } while (b >= 1024 && i < u.length - 1);
|
||
return b.toFixed(1) + ' ' + u[i];
|
||
}
|
||
|
||
function addRow(att) {
|
||
var tr = document.createElement('tr');
|
||
tr.className = 'hover:bg-gray-50';
|
||
tr.dataset.id = att.id;
|
||
var nameTd = document.createElement('td');
|
||
nameTd.className = 'px-3 py-2 text-sm text-gray-800';
|
||
var link = document.createElement('a');
|
||
link.href = att.url; link.target = '_blank'; link.textContent = att.filename;
|
||
nameTd.appendChild(link);
|
||
var sizeTd = document.createElement('td');
|
||
sizeTd.className = 'px-3 py-2 text-sm text-gray-500';
|
||
sizeTd.textContent = fmtSize(att.size);
|
||
var actTd = document.createElement('td');
|
||
actTd.className = 'px-3 py-2 text-sm text-right whitespace-nowrap';
|
||
var insBtn = document.createElement('button');
|
||
insBtn.type = 'button';
|
||
insBtn.textContent = "{{index .Tr "article_att_insert"}}";
|
||
insBtn.className = 'text-blue-600 hover:text-blue-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||
insBtn.onclick = function () {
|
||
var md = att.is_image
|
||
? ''
|
||
: '[' + att.filename + '](' + att.url + ')';
|
||
var cm = easyMDE.codemirror;
|
||
cm.replaceSelection(md + '\n');
|
||
cm.focus();
|
||
};
|
||
|
||
// Images: extra button to copy the URL into the cover field.
|
||
var coverBtn = null;
|
||
if (att.is_image) {
|
||
coverBtn = document.createElement('button');
|
||
coverBtn.type = 'button';
|
||
coverBtn.textContent = "{{index .Tr "article_att_set_cover"}}";
|
||
coverBtn.className = 'text-green-600 hover:text-green-800 font-medium mr-3 cursor-pointer bg-transparent border-none';
|
||
coverBtn.onclick = function () {
|
||
var cover = document.querySelector('input[name="cover"]');
|
||
if (cover) { cover.value = att.url; msgEl.textContent = "{{index .Tr "article_att_cover_set"}}"; }
|
||
};
|
||
}
|
||
var delBtn = document.createElement('button');
|
||
delBtn.type = 'button';
|
||
delBtn.textContent = "{{index .Tr "settings_delete"}}";
|
||
delBtn.className = 'text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none';
|
||
delBtn.onclick = function () {
|
||
if (!confirm("{{index .Tr "article_att_delete_confirm"}}")) return;
|
||
fetch('/api/admin/articles/attachments/' + att.id, {
|
||
method: 'DELETE',
|
||
headers: { 'X-CSRF-Token': csrfToken }
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (r) {
|
||
if (r.ok) { tr.remove(); }
|
||
else { msgEl.textContent = r.error || 'error'; }
|
||
});
|
||
};
|
||
actTd.appendChild(insBtn);
|
||
if (coverBtn) { actTd.appendChild(coverBtn); }
|
||
actTd.appendChild(delBtn);
|
||
tr.appendChild(nameTd);
|
||
tr.appendChild(sizeTd);
|
||
tr.appendChild(actTd);
|
||
listEl.appendChild(tr);
|
||
}
|
||
|
||
uploadBtn.addEventListener('click', function () {
|
||
if (!fileInput.files.length) { msgEl.textContent = "{{index .Tr "article_att_pick"}}"; return; }
|
||
var fd = new FormData();
|
||
fd.append('file', fileInput.files[0]);
|
||
if (articleID) { fd.append('article_id', articleID); }
|
||
else { fd.append('session_token', sessionToken); }
|
||
msgEl.textContent = "{{index .Tr "article_att_uploading"}}";
|
||
fetch('/api/admin/articles/attachments', {
|
||
method: 'POST',
|
||
headers: { 'X-CSRF-Token': csrfToken },
|
||
body: fd
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (r) {
|
||
if (r.error) { msgEl.textContent = r.error; return; }
|
||
msgEl.textContent = '';
|
||
addRow(r);
|
||
fileInput.value = '';
|
||
})
|
||
.catch(function () { msgEl.textContent = "{{index .Tr "article_att_error"}}"; });
|
||
});
|
||
|
||
// Edit page: load existing attachments.
|
||
if (articleID) {
|
||
fetch('/api/admin/articles/' + articleID + '/attachments')
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (r) {
|
||
(r.attachments || []).forEach(addRow);
|
||
});
|
||
}
|
||
})();
|
||
|
||
// ---- Article form submit(JSON API;草稿/发布按钮由 e.submitter 分流) ----
|
||
(function () {
|
||
var form = document.getElementById('articleForm');
|
||
if (!form) return;
|
||
form.addEventListener('submit', function (e) {
|
||
e.preventDefault();
|
||
var btn = e.submitter || null;
|
||
// EasyMDE 隐藏了 textarea:先同步编辑器内容再提交。
|
||
var ta = document.getElementById('articleContent');
|
||
if (ta && easyMDE) { ta.value = easyMDE.value(); }
|
||
var method = articleID ? 'PUT' : 'POST';
|
||
var url = articleID ? '/api/admin/articles/' + articleID : '/api/admin/articles';
|
||
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
|
||
if (r.ok) { window.location.href = r.redirect || '/admin'; }
|
||
else { blogShowError('articleError', r.error || 'Failed to save article.'); }
|
||
});
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
{{end}}
|