Files
vvveb-cms/templates/project_dashboard.html

572 lines
23 KiB
HTML
Raw Permalink 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.
<!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>
<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>
<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>
<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>
</main>
<script>
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
t.classList.add('active');
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;
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;');
}
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`);
if (!res.ok) return;
const data = await res.json();
document.getElementById('siteTitle').value = data.title || '';
document.getElementById('siteDesc').value = data.description || '';
}
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();
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('儲存失敗');
}
});
async function listMedia() {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/media/list`);
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);
});
}
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('上傳失敗');
}
});
async function loadPagesTree() {
const res = await fetch(`/api/projects/${encodeURIComponent(slug)}/pages-tree`);
if (!res.ok) return;
const tree = await res.json();
const container = document.getElementById('pagesTree');
container.innerHTML = '';
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);
});
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 || '';
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';
li.dataset.hasHeader = item.has_header !== false ? 'true' : 'false';
li.dataset.hasFooter = item.has_footer === true ? 'true' : 'false';
const titleWrap = document.createElement('span');
titleWrap.className = 'tree-title';
titleWrap.textContent = item.title || item.name || '';
const btns = document.createElement('span');
btns.className = 'tree-item-actions';
btns.innerHTML = "<button class='btn btn-ghost btn-sm btn-toggle' title='展開/收合'>▼</button>";
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>
<button class='btn btn-ghost btn-sm btn-header' title='頁首'>🧾</button>
<button class='btn btn-ghost btn-sm btn-footer' title='頁尾'>☷</button>
<button class='btn btn-ghost btn-sm btn-password' title='需要密碼'>🔒</button>
`;
const btnNav = meta.querySelector('.btn-nav');
const btnHome = meta.querySelector('.btn-homepage');
const btnHeader = meta.querySelector('.btn-header');
const btnFooter = meta.querySelector('.btn-footer');
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';
const hasHeader = li.dataset.hasHeader === 'true';
const hasFooter = li.dataset.hasFooter === 'true';
btnNav.classList.toggle('active', showNav);
btnNav.title = showNav ? '顯示於導覽列' : '從導覽列隱藏';
btnHome.classList.toggle('active', isHomepage);
btnHome.title = isHomepage ? '首頁' : '設為首頁';
btnHeader.classList.toggle('active', hasHeader);
btnHeader.title = hasHeader ? '包含頁首' : '不包含頁首';
btnFooter.classList.toggle('active', hasFooter);
btnFooter.title = hasFooter ? '包含頁尾' : '不包含頁尾';
btnPassword.classList.toggle('active', requiresPassword);
btnPassword.title = requiresPassword ? '需要密碼' : '不需要密碼';
if (li.dataset.type !== 'file') {
btnHome.disabled = true;
btnHeader.disabled = true;
btnFooter.disabled = true;
btnHome.title = '僅限單一頁面';
btnHeader.title = '僅限單一頁面';
btnFooter.title = '僅限單一頁面';
}
};
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();
});
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();
});
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();
});
li.appendChild(titleWrap);
li.appendChild(meta);
li.appendChild(btns);
if (item.children && item.children.length) {
const childUl = renderList(item.children);
childUl.className = 'tree-children';
li.appendChild(childUl);
}
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();
e.stopPropagation();
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)}']`);
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);
} else if (onTop) {
li.parentNode.insertBefore(src, li);
} else {
if (li.nextSibling) li.parentNode.insertBefore(src, li.nextSibling);
else li.parentNode.appendChild(src);
}
});
const toggleBtn = btns.querySelector('.btn-toggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const child = li.querySelector('ul');
if (child) {
child.style.display = child.style.display === 'none' ? '' : 'none';
toggleBtn.textContent = child.style.display === 'none' ? '▶' : '▼';
}
});
}
return li;
}
function renderList(items) {
const ul = document.createElement('ul');
items.forEach(it => ul.appendChild(createLi(it)));
return ul;
}
if (tree && tree.root) container.appendChild(renderList(tree.root));
}
function buildPayload(rootEl) {
function walk(ul) {
const arr = [];
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';
const obj = {
name,
title,
type,
show_in_nav: li.dataset.showInNav === 'true',
is_homepage: li.dataset.isHomepage === 'true',
requires_password: li.dataset.requiresPassword === 'true',
has_header: li.dataset.hasHeader === 'true',
has_footer: li.dataset.hasFooter === 'true',
};
const childUl = li.querySelector(':scope > ul');
if (childUl) obj.children = walk(childUl);
arr.push(obj);
});
return arr;
}
return { root: walk(rootEl.querySelector(':scope > ul') || document.createElement('ul')) };
}
document.getElementById('saveTreeBtn').addEventListener('click', async () => {
const container = document.getElementById('pagesTree');
const payload = buildPayload(container);
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 = '儲存頁面結構';
});
if (slug) {
loadProjectSummary();
loadSettings();
renderPageList();
listMedia();
loadPagesTree();
}
</script>
</body>
</html>