Files
vvveb-cms/templates/project_dashboard.html

572 lines
23 KiB
HTML
Raw Normal View History

<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>專案管理</title>
<link rel="stylesheet" href="/static/css/my-style.css">
</head>
<body>
2026-05-26 12:12:00 +08:00
<main class="main-content project-dashboard">
<header class="project-header">
<div class="project-header-info">
<span class="eyebrow">專案儀表板</span>
<h1 id="project-title">專案管理</h1>
<p id="project-desc" class="project-subtitle">使用此頁面管理網站設定、頁面與媒體內容。</p>
</div>
2026-05-26 12:12:00 +08:00
<div class="project-header-actions">
<a id="open-dashboard-btn" class="btn btn-secondary" href="/dashboard">回網站管理器</a>
<a id="open-editor-btn" class="btn btn-primary" href="#" target="_blank">開啟編輯器</a>
<a id="open-project-btn" class="btn btn-secondary" href="#" target="_blank">查看網站</a>
</div>
</header>
<div class="tabs">
<div class="tab active" data-tab="overview">概覽</div>
<div class="tab" data-tab="settings">網站設定</div>
<div class="tab" data-tab="pages">頁面管理</div>
<div class="tab" data-tab="media">媒體庫</div>
</div>
2026-05-26 12:12:00 +08:00
<div id="content">
<section id="overview" class="panel">
<div class="panel-grid">
<div class="panel-card summary-card">
<div class="summary-card-title">專案</div>
<div class="summary-card-value" id="projName"></div>
<div class="summary-card-note" id="summary-slug">Slug</div>
</div>
<div class="panel-card summary-card">
<div class="summary-card-title">頁面數量</div>
<div class="summary-card-value" id="summary-pages">0</div>
<div class="summary-card-note">可管理的 HTML 頁面</div>
</div>
<div class="panel-card summary-card">
<div class="summary-card-title">最後更新</div>
<div class="summary-card-value" id="summary-updated"></div>
<div class="summary-card-note">最近變更時間</div>
</div>
</div>
</section>
<section id="settings" class="panel" style="display:none">
<div class="panel-header">
<h2>網站設定</h2>
</div>
<form id="settingsForm" class="card-form">
<div class="form-group">
<label for="siteTitle">網站標題</label>
<input id="siteTitle" type="text">
</div>
<div class="form-group">
<label for="siteDesc">描述</label>
<textarea id="siteDesc"></textarea>
</div>
<button class="btn btn-primary" id="saveSettings">儲存設定</button>
</form>
</section>
<section id="pages" class="panel" style="display:none">
<div class="panel-header">
<div>
<h2>頁面管理</h2>
<p class="panel-note">新增、編輯與刪除專案中的頁面,並調整頁面結構。</p>
</div>
<div class="panel-actions">
<button class="btn btn-secondary btn-sm" id="page-refresh-btn">重新整理</button>
<button class="btn btn-primary btn-sm" id="new-page-btn">新增頁面</button>
</div>
</div>
<div id="page-list" class="pages-list"></div>
<div id="page-empty" class="page-empty" style="display:none">
<p>目前沒有頁面項目。按一下「新增頁面」建立第一個頁面。</p>
</div>
<div class="divider"></div>
<div id="pagesTreeContainer" class="panel-card">
<div class="panel-card-header">
<div>
<h3>頁面樹狀結構</h3>
<p class="panel-note">拖曳排列頁面順序,並使用「儲存頁面結構」保存。</p>
</div>
<button class="btn btn-primary btn-sm" id="saveTreeBtn">儲存頁面結構</button>
</div>
<div id="pagesTree" class="tree-root"></div>
</div>
</section>
<section id="media" class="panel" style="display:none">
<div class="panel-header">
<h2>媒體庫</h2>
</div>
<div class="media-controls">
<input type="file" id="mediaFile">
<button class="btn btn-primary" id="uploadBtn">上傳</button>
</div>
<div id="mediaGrid" class="media-grid"></div>
</section>
</div>
2026-05-26 12:12:00 +08:00
</main>
<script>
2026-05-26 12:12:00 +08:00
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
2026-05-26 12:12:00 +08:00
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
document.getElementById(t.dataset.tab).style.display = 'block';
}));
const parts = location.pathname.split('/');
const slug = parts.length >= 4 ? decodeURIComponent(parts[3]) : null;
2026-05-26 12:12:00 +08:00
if (!slug) {
document.getElementById('project-title').innerText = '專案未找到';
document.getElementById('projName').innerText = '—';
}
function escHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
2026-05-26 12:12:00 +08:00
async function apiFetch(url, opts = {}) {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
async function loadProjectSummary() {
try {
const project = await apiFetch(`/api/projects/${encodeURIComponent(slug)}`);
document.getElementById('project-title').innerText = project.name || slug;
document.getElementById('project-desc').innerText = project.description || '使用此頁面管理網站設定、頁面與媒體內容。';
document.getElementById('projName').innerText = project.slug;
document.getElementById('summary-slug').innerText = project.slug;
document.getElementById('summary-pages').innerText = `${project.page_count ?? 0} 頁`;
document.getElementById('summary-updated').innerText = project.last_modified ? new Date(project.last_modified).toLocaleString() : '—';
document.getElementById('open-editor-btn').href = `/editor/${encodeURIComponent(slug)}`;
document.getElementById('open-project-btn').href = `/sites/${encodeURIComponent(slug)}/index.html`;
} catch (e) {
console.warn('載入專案摘要失敗', e);
}
}
async function loadSettings() {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/settings`);
2026-05-26 12:12:00 +08:00
if (!res.ok) return;
const data = await res.json();
document.getElementById('siteTitle').value = data.title || '';
document.getElementById('siteDesc').value = data.description || '';
}
2026-05-26 12:12:00 +08:00
async function renderPageList() {
const listEl = document.getElementById('page-list');
const emptyEl = document.getElementById('page-empty');
try {
const pages = await apiFetch(`/api/projects/${encodeURIComponent(slug)}/pages`);
if (!Array.isArray(pages) || pages.length === 0) {
listEl.innerHTML = '';
emptyEl.style.display = 'block';
return;
}
emptyEl.style.display = 'none';
listEl.innerHTML = pages.map(file => {
const title = file.replace(/\.html$/, '').split('/').pop().replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
return `
<div class="page-item">
<div class="page-item-info">
<strong>${escHtml(title)}</strong>
<span class="page-file">${escHtml(file)}</span>
</div>
<div class="page-item-actions">
<a class="btn btn-primary btn-sm" href="/editor/${encodeURIComponent(slug)}?page=${encodeURIComponent(file)}">編輯</a>
<button class="btn btn-ghost btn-sm delete-page-btn" data-file="${escHtml(file)}">刪除</button>
</div>
</div>`;
}).join('');
listEl.querySelectorAll('.delete-page-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const file = btn.dataset.file;
if (!file) return;
if (file.toLowerCase() === 'index.html') {
alert('無法刪除主頁 index.html');
return;
}
if (!confirm(`確定要刪除頁面「${file}」嗎?`)) return;
try {
await apiFetch(`/api/save?slug=${encodeURIComponent(slug)}&action=delete`, {
method: 'POST',
body: JSON.stringify({ file }),
});
await loadProjectSummary();
await renderPageList();
await loadPagesTree();
alert('頁面已刪除');
} catch (e) {
alert('刪除失敗:' + e.message);
}
});
});
} catch (e) {
listEl.innerHTML = '';
emptyEl.style.display = 'block';
console.warn('載入頁面列表失敗', e);
}
}
async function createPage() {
const name = prompt('請輸入新頁面名稱例如about:');
if (!name) return;
try {
await apiFetch(`/api/projects/${encodeURIComponent(slug)}/pages`, {
method: 'POST',
body: JSON.stringify({ name }),
});
await loadProjectSummary();
await renderPageList();
await loadPagesTree();
alert(`頁面「${name}」建立成功`);
} catch (e) {
alert('建立頁面失敗:' + e.message);
}
}
document.getElementById('new-page-btn').addEventListener('click', createPage);
document.getElementById('page-refresh-btn').addEventListener('click', async () => {
await renderPageList();
await loadPagesTree();
});
document.getElementById('saveSettings').addEventListener('click', async function (e) {
e.preventDefault();
2026-05-26 12:12:00 +08:00
const body = {
title: document.getElementById('siteTitle').value,
description: document.getElementById('siteDesc').value,
};
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
alert('設定已儲存');
} else {
alert('儲存失敗');
}
});
2026-05-26 12:12:00 +08:00
async function listMedia() {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/list`);
2026-05-26 12:12:00 +08:00
const grid = document.getElementById('mediaGrid');
grid.innerHTML = '';
if (!res.ok) return;
const items = await res.json();
items.reverse();
items.forEach(it => {
const card = document.createElement('div');
card.className = 'media-card';
const img = document.createElement('img');
img.src = it.thumb || it.url;
const info = document.createElement('div');
info.className = 'media-info';
info.innerHTML = `<div>${escHtml(it.original_name || it.filename)}</div><div class="media-meta">${escHtml(it.uploaded_at || '')}</div>`;
const btnCopy = document.createElement('button');
btnCopy.className = 'btn btn-ghost btn-sm';
btnCopy.textContent = '複製連結';
btnCopy.addEventListener('click', () => {
navigator.clipboard.writeText(it.url);
alert('已複製連結');
});
const btnDel = document.createElement('button');
btnDel.className = 'btn btn-danger btn-sm';
btnDel.textContent = '刪除';
btnDel.addEventListener('click', async () => {
if (!confirm('確定刪除此檔案?')) return;
const rel = `media/images/${it.date}/${it.filename}`;
const del = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/${encodeURIComponent(rel)}`, { method: 'DELETE' });
if (del.ok) {
alert('刪除成功');
listMedia();
} else {
alert('刪除失敗');
}
});
const controls = document.createElement('div');
controls.className = 'media-actions';
controls.appendChild(btnCopy);
controls.appendChild(btnDel);
card.appendChild(img);
card.appendChild(info);
card.appendChild(controls);
grid.appendChild(card);
});
}
2026-05-26 12:12:00 +08:00
document.getElementById('uploadBtn').addEventListener('click', async () => {
const fileInput = document.getElementById('mediaFile');
if (!fileInput.files.length) {
alert('請選擇檔案');
return;
}
const fd = new FormData();
fd.append('file', fileInput.files[0]);
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/upload`, {
method: 'POST',
body: fd,
});
if (res.ok) {
alert('上傳完成');
fileInput.value = '';
listMedia();
} else {
alert('上傳失敗');
}
});
2026-05-26 12:12:00 +08:00
async function loadPagesTree() {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/pages-tree`);
2026-05-26 12:12:00 +08:00
if (!res.ok) return;
const tree = await res.json();
const container = document.getElementById('pagesTree');
container.innerHTML = '';
2026-05-26 12:23:29 +08:00
container.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
container.addEventListener('drop', e => {
e.preventDefault();
const nameEnc = e.dataTransfer.getData('text/name');
if (!nameEnc) return;
const name = decodeURIComponent(nameEnc);
const src = container.querySelector(`[data-name='${CSS.escape(name)}']`);
if (!src) return;
let rootUl = container.querySelector(':scope > ul');
if (!rootUl) {
rootUl = document.createElement('ul');
container.appendChild(rootUl);
}
rootUl.appendChild(src);
});
2026-05-26 12:12:00 +08:00
function createLi(item) {
const li = document.createElement('li');
li.className = 'tree-item';
li.draggable = true;
li.dataset.type = item.type || 'file';
li.dataset.name = item.name || item.title || '';
2026-05-26 12:23:29 +08:00
li.dataset.showInNav = item.show_in_nav !== false ? 'true' : 'false';
li.dataset.isHomepage = item.is_homepage ? 'true' : 'false';
li.dataset.requiresPassword = item.requires_password ? 'true' : 'false';
2026-05-26 12:33:48 +08:00
li.dataset.hasHeader = item.has_header !== false ? 'true' : 'false';
li.dataset.hasFooter = item.has_footer === true ? 'true' : 'false';
2026-05-26 12:12:00 +08:00
const titleWrap = document.createElement('span');
titleWrap.className = 'tree-title';
titleWrap.textContent = item.title || item.name || '';
2026-05-26 12:12:00 +08:00
const btns = document.createElement('span');
btns.className = 'tree-item-actions';
btns.innerHTML = "<button class='btn btn-ghost btn-sm btn-toggle' title='展開/收合'></button>";
2026-05-26 12:23:29 +08:00
const meta = document.createElement('span');
meta.className = 'tree-item-meta';
meta.innerHTML = `
<button class='btn btn-ghost btn-sm btn-nav' title='顯示於導覽列'></button>
<button class='btn btn-ghost btn-sm btn-homepage' title='設為首頁'></button>
2026-05-26 12:33:48 +08:00
<button class='btn btn-ghost btn-sm btn-header' title='頁首'>🧾</button>
<button class='btn btn-ghost btn-sm btn-footer' title='頁尾'></button>
2026-05-26 12:23:29 +08:00
<button class='btn btn-ghost btn-sm btn-password' title='需要密碼'>🔒</button>
`;
const btnNav = meta.querySelector('.btn-nav');
const btnHome = meta.querySelector('.btn-homepage');
2026-05-26 12:33:48 +08:00
const btnHeader = meta.querySelector('.btn-header');
const btnFooter = meta.querySelector('.btn-footer');
2026-05-26 12:23:29 +08:00
const btnPassword = meta.querySelector('.btn-password');
const updateMetaState = () => {
const showNav = li.dataset.showInNav === 'true';
const isHomepage = li.dataset.isHomepage === 'true';
const requiresPassword = li.dataset.requiresPassword === 'true';
2026-05-26 12:33:48 +08:00
const hasHeader = li.dataset.hasHeader === 'true';
const hasFooter = li.dataset.hasFooter === 'true';
2026-05-26 12:23:29 +08:00
btnNav.classList.toggle('active', showNav);
btnNav.title = showNav ? '顯示於導覽列' : '從導覽列隱藏';
btnHome.classList.toggle('active', isHomepage);
btnHome.title = isHomepage ? '首頁' : '設為首頁';
2026-05-26 12:33:48 +08:00
btnHeader.classList.toggle('active', hasHeader);
btnHeader.title = hasHeader ? '包含頁首' : '不包含頁首';
btnFooter.classList.toggle('active', hasFooter);
btnFooter.title = hasFooter ? '包含頁尾' : '不包含頁尾';
2026-05-26 12:23:29 +08:00
btnPassword.classList.toggle('active', requiresPassword);
btnPassword.title = requiresPassword ? '需要密碼' : '不需要密碼';
if (li.dataset.type !== 'file') {
btnHome.disabled = true;
2026-05-26 12:33:48 +08:00
btnHeader.disabled = true;
btnFooter.disabled = true;
2026-05-26 12:23:29 +08:00
btnHome.title = '僅限單一頁面';
2026-05-26 12:33:48 +08:00
btnHeader.title = '僅限單一頁面';
btnFooter.title = '僅限單一頁面';
2026-05-26 12:23:29 +08:00
}
};
updateMetaState();
btnNav.addEventListener('click', () => {
li.dataset.showInNav = li.dataset.showInNav === 'true' ? 'false' : 'true';
updateMetaState();
});
btnPassword.addEventListener('click', () => {
li.dataset.requiresPassword = li.dataset.requiresPassword === 'true' ? 'false' : 'true';
updateMetaState();
});
2026-05-26 12:33:48 +08:00
btnHeader.addEventListener('click', () => {
if (li.dataset.type !== 'file') return;
li.dataset.hasHeader = li.dataset.hasHeader === 'true' ? 'false' : 'true';
updateMetaState();
});
btnFooter.addEventListener('click', () => {
if (li.dataset.type !== 'file') return;
li.dataset.hasFooter = li.dataset.hasFooter === 'true' ? 'false' : 'true';
updateMetaState();
});
2026-05-26 12:23:29 +08:00
btnHome.addEventListener('click', () => {
if (li.dataset.type !== 'file') return;
container.querySelectorAll('.tree-item').forEach(other => {
other.dataset.isHomepage = 'false';
});
container.querySelectorAll('.btn-homepage').forEach(button => button.classList.remove('active'));
li.dataset.isHomepage = 'true';
updateMetaState();
});
2026-05-26 12:12:00 +08:00
li.appendChild(titleWrap);
2026-05-26 12:23:29 +08:00
li.appendChild(meta);
2026-05-26 12:12:00 +08:00
li.appendChild(btns);
2026-05-26 12:12:00 +08:00
if (item.children && item.children.length) {
const childUl = renderList(item.children);
2026-05-26 12:12:00 +08:00
childUl.className = 'tree-children';
li.appendChild(childUl);
}
2026-05-26 12:12:00 +08:00
li.addEventListener('dragstart', e => {
e.dataTransfer.setData('text/name', encodeURIComponent(li.dataset.name));
e.currentTarget.style.opacity = 0.4;
});
li.addEventListener('dragend', e => { e.currentTarget.style.opacity = 1; });
li.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; li.classList.add('tree-drop-hover'); });
li.addEventListener('dragleave', () => { li.classList.remove('tree-drop-hover'); });
li.addEventListener('drop', e => {
e.preventDefault();
2026-05-26 12:23:29 +08:00
e.stopPropagation();
2026-05-26 12:12:00 +08:00
li.classList.remove('tree-drop-hover');
const nameEnc = e.dataTransfer.getData('text/name');
if (!nameEnc) return;
const name = decodeURIComponent(nameEnc);
const src = container.querySelector(`[data-name='${CSS.escape(name)}']`);
2026-05-26 12:12:00 +08:00
if (!src || src === li) return;
const onTop = e.clientY < (li.getBoundingClientRect().top + li.getBoundingClientRect().height / 2);
if (li.querySelector('ul')) {
li.querySelector('ul').appendChild(src);
2026-05-26 12:12:00 +08:00
} else if (onTop) {
li.parentNode.insertBefore(src, li);
} else {
2026-05-26 12:12:00 +08:00
if (li.nextSibling) li.parentNode.insertBefore(src, li.nextSibling);
else li.parentNode.appendChild(src);
}
});
const toggleBtn = btns.querySelector('.btn-toggle');
2026-05-26 12:12:00 +08:00
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const child = li.querySelector('ul');
2026-05-26 12:12:00 +08:00
if (child) {
child.style.display = child.style.display === 'none' ? '' : 'none';
toggleBtn.textContent = child.style.display === 'none' ? '▶' : '▼';
}
});
}
return li;
}
2026-05-26 12:12:00 +08:00
function renderList(items) {
const ul = document.createElement('ul');
items.forEach(it => ul.appendChild(createLi(it)));
return ul;
}
2026-05-26 12:12:00 +08:00
if (tree && tree.root) container.appendChild(renderList(tree.root));
}
2026-05-26 12:12:00 +08:00
function buildPayload(rootEl) {
function walk(ul) {
const arr = [];
2026-05-26 12:12:00 +08:00
const lis = Array.from(ul.children).filter(n => n.tagName === 'LI');
lis.forEach(li => {
const title = li.querySelector('.tree-title')?.textContent || '';
const name = li.dataset.name || '';
const type = li.dataset.type || 'file';
2026-05-26 12:23:29 +08:00
const obj = {
name,
title,
type,
show_in_nav: li.dataset.showInNav === 'true',
is_homepage: li.dataset.isHomepage === 'true',
requires_password: li.dataset.requiresPassword === 'true',
2026-05-26 12:33:48 +08:00
has_header: li.dataset.hasHeader === 'true',
has_footer: li.dataset.hasFooter === 'true',
2026-05-26 12:23:29 +08:00
};
const childUl = li.querySelector(':scope > ul');
2026-05-26 12:12:00 +08:00
if (childUl) obj.children = walk(childUl);
arr.push(obj);
});
return arr;
}
return { root: walk(rootEl.querySelector(':scope > ul') || document.createElement('ul')) };
}
2026-05-26 12:12:00 +08:00
document.getElementById('saveTreeBtn').addEventListener('click', async () => {
const container = document.getElementById('pagesTree');
const payload = buildPayload(container);
2026-05-26 12:12:00 +08:00
const btn = document.getElementById('saveTreeBtn');
btn.disabled = true;
btn.textContent = '儲存中...';
try {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/pages-tree`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) alert('頁面結構已儲存');
else alert('儲存失敗');
} catch (e) {
alert('儲存失敗');
}
btn.disabled = false;
btn.textContent = '儲存頁面結構';
});
2026-05-26 12:12:00 +08:00
if (slug) {
loadProjectSummary();
loadSettings();
renderPageList();
listMedia();
loadPagesTree();
}
</script>
</body>
</html>