Files
go_blog/templates/layouts/base.html
T
kevin 6220de9c84 fix: 编辑/新建文章页 Font Awesome 改用本地 vendored 资源
easyMDE 会注入失效的 maxcdn.bootstrapcdn.com 样式链接导致工具栏图标加载失败。
按 SECURITY_TODO #9 无外部 CDN 政策,本地化 Font Awesome 4.7.0(css+fonts),
并在两处 EasyMDE 配置中禁用 autoDownloadFontAwesome。
2026-08-28 16:26:06 +08:00

330 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{define "header"}}
<!DOCTYPE html>
<html lang="{{.Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{.CSRFToken}}">
<title>{{.Title}} - {{index .Tr "site_title"}}</title>
{{if .SiteFavicon}}
{{if .SiteFaviconIsURL}}
<link rel="icon" href="{{.SiteFavicon}}">
{{else}}
<link rel="icon" href="/uploads/logos/{{.SiteFavicon}}">
{{end}}
{{end}}
<link rel="alternate" type="application/rss+xml" title="RSS Feed" href="/rss">
<!-- All third-party assets are vendored locally (static/vendor, embedded
via go:embed) — no external CDN origins, see SECURITY_TODO #9. -->
<link rel="stylesheet" href="/static/css/app.css?v=1">
<link rel="stylesheet" href="/static/vendor/cropper.min.css?v=1">
<script src="/static/vendor/cropper.min.js?v=1"></script>
<link rel="stylesheet" href="/static/vendor/easymde.min.css?v=1">
<link rel="stylesheet" href="/static/vendor/font-awesome/4.7.0/css/font-awesome.min.css?v=1">
<script src="/static/vendor/easymde.min.js?v=1"></script>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col">
<!-- Navigation -->
<nav class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-5xl mx-auto px-4 py-2.5 flex items-center justify-between">
<a href="/" class="text-xl font-bold text-gray-800 hover:text-blue-600 transition-colors flex items-center gap-2">
{{if .SiteLogo}}
{{if .SiteLogoIsURL}}
<img src="{{.SiteLogo}}" alt="logo" class="h-7 w-auto">
{{else}}
<img src="/uploads/logos/{{.SiteLogo}}" alt="logo" class="h-7 w-auto">
{{end}}
{{end}}
{{if .SiteLogoText}}{{.SiteLogoText}}{{else}}{{index .Tr "site_title"}}{{end}}
</a>
<div class="flex items-center gap-4">
<a href="/" class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
<span>{{index .Tr "home"}}</span>
</a>
{{range .NavLinks}}
<a href="{{.URL}}" {{if .OpenNew}}target="_blank" rel="noopener noreferrer"{{end}} class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
<span>{{.Title $.Lang}}</span>
{{if .OpenNew}}
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
{{end}}
</a>
{{end}}
{{if .IsLoggedIn}}
<!-- Avatar Dropdown (click to toggle) -->
<div class="relative" id="avatarDropdown">
<button type="button" id="avatarBtn" class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" onclick="toggleDropdown()">
<div class="w-8 h-8 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
{{if .Avatar}}
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
{{else}}
<svg class="w-4 h-4 text-gray-500 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v1.2c0 .66.54 1.2 1.2 1.2h16.8c.66 0 1.2-.54 1.2-1.2v-1.2c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
{{end}}
</div>
<span class="text-sm font-medium text-gray-700">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.Username}}{{end}}</span>
</button>
<div id="dropdownMenu" class="absolute right-0 mt-2 w-44 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 hidden">
<a href="/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "profile"}}
</a>
<a href="/my/articles" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "my_articles"}}
</a>
{{if eq .Role "admin"}}
<div class="border-t border-gray-100 my-1"></div>
<a href="/admin" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
{{index .Tr "admin_panel"}}
</a>
{{end}}
<div class="border-t border-gray-100 my-1"></div>
<form action="/logout" method="post" class="m-0 logout-form">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<button type="submit" class="w-full text-left block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors cursor-pointer bg-transparent border-none">
{{index .Tr "logout"}}
</button>
</form>
</div>
</div>
{{else}}
<a href="/login" class="text-gray-600 hover:text-blue-600 transition-colors text-sm flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
</svg>
<span>{{index .Tr "login"}}</span>
</a>
{{end}}
</div>
</div>
</nav>
<!-- Main Content -->
<main class="flex-1">
{{if .SiteHeaderText}}
<!-- Header banner -->
<div class="bg-blue-50 border-b border-blue-100 text-blue-800 text-sm text-center py-2 px-4">
{{.SiteHeaderText}}
</div>
{{end}}
{{end}}
{{define "markdown_assets"}}
{{/* Markdown rendering assets: marked (pinned UMD build), DOMPurify,
highlight.js and the shared renderer + styles. Include on any page that
renders Markdown (articles, comments, editor previews).
Local static files carry a ?v= cache-buster: bump it whenever
static/js/markdown.js or static/css/markdown.css changes, otherwise
browsers may keep serving stale cached copies. */}}
<link rel="stylesheet" href="/static/vendor/github.min.css?v=1">
<link rel="stylesheet" href="/static/css/markdown.css?v=2">
<script src="/static/vendor/marked.min.js?v=1"></script>
<script src="/static/vendor/purify.min.js?v=1"></script>
<script src="/static/vendor/highlight.min.js?v=1"></script>
<script src="/static/js/markdown.js?v=2"></script>
{{end}}
{{define "footer"}}
</main>
<!-- Footer -->
<footer class="bg-white border-t border-gray-200 py-4 mt-auto">
<div class="max-w-5xl mx-auto px-4">
<!-- Footer Actions Row -->
<div class="flex justify-center items-center gap-3 mb-2">
<!-- RSS Subscribe Button -->
<div class="relative inline-block">
<button onclick="toggleRSSMenu()" class="flex items-center gap-2 px-3 py-1.5 bg-orange-500 hover:bg-orange-600 text-white rounded-lg transition-colors text-sm font-medium">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19 7.38 20 6.18 20 5 20 4 19 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/>
</svg>
<span>{{if eq .Lang "zh"}}RSS 订阅{{else}}RSS Feed{{end}}</span>
</button>
<!-- RSS Dropdown Menu -->
<div id="rssMenu" class="hidden absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<a href="/rss" target="_blank" class="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
<span>{{if eq .Lang "zh"}}打开 RSS 链接{{else}}Open RSS Link{{end}}</span>
</a>
<button onclick="copyRSSLink()" class="w-full flex items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors text-left">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span id="copyText">{{if eq .Lang "zh"}}复制订阅链接{{else}}Copy Feed URL{{end}}</span>
</button>
</div>
</div>
<!-- Language Switcher -->
<a href="?lang={{.SwitchLang}}" class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 hover:text-blue-600 hover:bg-gray-50 transition-colors border border-gray-300 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"/>
</svg>
<span>{{index .Tr "lang_switch"}}</span>
</a>
</div>
<!-- Footer Text -->
<div class="text-center text-gray-500 text-xs">
{{if .SiteFooterText}}{{.SiteFooterText}}{{else}}{{index .Tr "footer_text"}}{{end}}
</div>
<div class="text-center text-gray-400 text-[10px] mt-1">
{{.BuildInfo}}
</div>
</div>
</footer>
<script>
// ---- Shared API helpers/api/* JSON 接口统一入口) ----
window.blogAPI = function (method, url, body) {
var meta = document.querySelector('meta[name="csrf-token"]');
var csrf = meta ? meta.getAttribute('content') : '';
var opts = {
method: method,
headers: {
'X-CSRF-Token': csrf,
'Accept': 'application/json'
},
credentials: 'same-origin'
};
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
return fetch(url, opts).then(function (r) {
return r.json().catch(function () {
return { ok: false, code: 'api_error', error: r.statusText };
});
});
};
// 将表单序列化为 JSON 数据对象:
// - 文本/select/textarea 从 FormData 取值(checkboxes 在下述循环覆盖)
// - checkbox 一律转 bool(未选中也发送 false,匹配服务端 JSON 绑定)
// - file 字段剔除(文件上传走单独的 multipart 端点)
// - 提交按钮(如文章表单的草稿/发布 name=status)取自 submitter
window.blogForm = function (form, submitter) {
var data = {};
new FormData(form).forEach(function (v, k) {
if (k === '_csrf') return;
if (!(k in data)) data[k] = v;
});
Array.prototype.forEach.call(form.querySelectorAll('input[type="file"]'), function (el) {
if (el.name) { data[el.name] = undefined; delete data[el.name]; }
});
Array.prototype.forEach.call(form.querySelectorAll('input[type="checkbox"]'), function (el) {
if (el.name) data[el.name] = el.checked;
});
if (submitter && submitter.name) {
data[submitter.name] = submitter.value;
}
return data;
};
// 在指定错误 div 中显示 API 错误文案。
window.blogShowError = function (divId, msg) {
var el = document.getElementById(divId);
if (el) { el.textContent = msg; el.classList.remove('hidden'); }
};
// Logout formsclass=logout-form)统一改走 JSON API 后跳转首页。
document.addEventListener('submit', function (e) {
var form = e.target;
if (form.classList && form.classList.contains('logout-form')) {
e.preventDefault();
blogAPI('POST', '/api/auth/logout').then(function (r) {
window.location.href = (r && r.redirect) || '/';
});
}
});
// 列表删除提交(onsubmit = "return blogDelete(this)"):
// DELETE data-url 指向的 JSON 接口,成功后 reload(保留筛选状态)。
window.blogDelete = function (form) {
var url = form.getAttribute('data-url');
var confirmText = form.getAttribute('data-confirm');
if (confirmText && !confirm(confirmText)) return false;
blogAPI('DELETE', url).then(function (r) {
if (r.ok) { window.location.reload(); }
else { alert(r.error || 'Failed'); }
});
return false;
};
// 设置页小表单委托(onsubmit = "return blogSettingsForm(this)"):
// POST data-api-urlbody 为 {action: data-action} + 表单序列化字段,
// 成功后跳转 data.redirect(通常刷新列表),失败 alert 文案。
window.blogSettingsForm = function (form) {
var url = form.getAttribute('data-api-url');
var confirmText = form.getAttribute('data-confirm');
if (confirmText && !confirm(confirmText)) return false;
var data = blogForm(form, null);
var action = form.getAttribute('data-action');
if (action) data.action = action;
blogAPI('POST', url, data).then(function (r) {
if (r.ok) { window.location.href = r.redirect || window.location.href; }
else { alert(r.error || 'Failed'); }
});
return false;
};
function toggleDropdown() {
var menu = document.getElementById('dropdownMenu');
menu.classList.toggle('hidden');
}
// Close dropdown when clicking outside
document.addEventListener('click', function(e) {
var dropdown = document.getElementById('avatarDropdown');
var menu = document.getElementById('dropdownMenu');
if (dropdown && !dropdown.contains(e.target)) {
menu.classList.add('hidden');
}
});
// RSS Menu Functions
function toggleRSSMenu() {
var menu = document.getElementById('rssMenu');
menu.classList.toggle('hidden');
}
function copyRSSLink() {
var rssURL = window.location.origin + '/rss';
var copyText = document.getElementById('copyText');
var originalText = copyText.textContent;
var lang = '{{.Lang}}';
// Copy to clipboard
navigator.clipboard.writeText(rssURL).then(function() {
// Show success feedback
copyText.textContent = lang === 'zh' ? '✓ 已复制!' : '✓ Copied!';
copyText.parentElement.classList.add('text-green-600');
// Reset after 2 seconds
setTimeout(function() {
copyText.textContent = originalText;
copyText.parentElement.classList.remove('text-green-600');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy:', err);
copyText.textContent = lang === 'zh' ? '✗ 复制失败' : '✗ Copy failed';
});
}
// Close RSS menu when clicking outside
document.addEventListener('click', function(e) {
var rssButton = document.querySelector('[onclick="toggleRSSMenu()"]');
var rssMenu = document.getElementById('rssMenu');
if (rssButton && rssMenu && !rssButton.contains(e.target) && !rssMenu.contains(e.target)) {
rssMenu.classList.add('hidden');
}
});
</script>
</body>
</html>
{{end}}