專案儀表更新
This commit is contained in:
@@ -178,7 +178,307 @@ button { cursor: pointer; font-family: inherit; }
|
||||
margin: 0 auto;
|
||||
padding: 2rem 2rem 4rem;
|
||||
}
|
||||
.project-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.75rem;
|
||||
padding: 1.4rem 1.6rem;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.project-header-info {
|
||||
max-width: min(720px, 100%);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(99,102,241,0.12);
|
||||
color: #dbeafe;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.project-header h1 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.project-subtitle {
|
||||
color: var(--text-secondary);
|
||||
max-width: 740px;
|
||||
}
|
||||
|
||||
.project-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(255,255,255,0.14);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.85rem 1.15rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), color var(--transition), transform var(--transition);
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.panel-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.panel-header h2,
|
||||
.panel-card-header h3 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-note {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.summary-card-title {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.summary-card-value {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.summary-card-note {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.card-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pages-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.page-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255,255,255,0.03);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.page-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.page-item-info strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-file {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.page-item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-empty {
|
||||
padding: 1.2rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px dashed rgba(255,255,255,0.16);
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
.tree-root {
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.03);
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
.tree-item {
|
||||
list-style: none;
|
||||
padding: 0.65rem 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--transition), border-color var(--transition);
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.tree-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
list-style: none;
|
||||
padding-left: 1rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.tree-drop-hover {
|
||||
background: rgba(99,102,241,0.1);
|
||||
}
|
||||
|
||||
.tree-item-actions .btn {
|
||||
padding: 0.35rem 0.7rem;
|
||||
}
|
||||
|
||||
.media-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.media-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: rgba(255,255,255,0.03);
|
||||
}
|
||||
|
||||
.media-card img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.media-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.media-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* ── 搜尋列 ─────────────────────────────────────────────────────── */
|
||||
.search-bar-wrap {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -281,6 +281,14 @@ function renderProjects(projects) {
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<a href="/dashboard/project/${encodeURIComponent(p.slug)}" class="btn btn-secondary btn-sm" id="dashboard-btn-${p.slug}" style="margin-right:0.5rem;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M3 13h8V3H3v10z"/>
|
||||
<path d="M13 21h8V11h-8v10z"/>
|
||||
<path d="M3 21h8v-6H3v6z"/>
|
||||
</svg>
|
||||
專案儀表板
|
||||
</a>
|
||||
<a href="/editor/${p.slug}" class="btn btn-primary btn-sm" id="edit-btn-${p.slug}">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
|
||||
@@ -7,178 +7,424 @@
|
||||
<link rel="stylesheet" href="/static/css/my-style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>專案管理</h1>
|
||||
</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">
|
||||
<div id="overview" class="panel">專案:<span id="projName"></span></div>
|
||||
|
||||
<div id="settings" class="panel" style="display:none">
|
||||
<h2>網站設定</h2>
|
||||
<form id="settingsForm">
|
||||
<label>網站標題<br><input id="siteTitle" type="text"></label><br>
|
||||
<label>描述<br><textarea id="siteDesc"></textarea></label><br>
|
||||
<button id="saveSettings">儲存設定</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="pages" class="panel" style="display:none">
|
||||
<h2>頁面管理</h2>
|
||||
<div id="pagesTreeContainer">
|
||||
<div id="pagesTree" style="padding:8px;border:1px solid #eee;background:#fff"></div>
|
||||
<div style="margin-top:8px"><button id="saveTreeBtn">儲存頁面結構</button></div>
|
||||
<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="media" class="panel" style="display:none">
|
||||
<h2>媒體庫</h2>
|
||||
<input type="file" id="mediaFile"><button id="uploadBtn">上傳</button>
|
||||
<div id="mediaGrid"></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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',e=>{
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
|
||||
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';
|
||||
document.querySelectorAll('.panel').forEach(p => p.style.display = 'none');
|
||||
document.getElementById(t.dataset.tab).style.display = 'block';
|
||||
}));
|
||||
|
||||
// Extract slug from path /dashboard/project/<slug>
|
||||
const parts = location.pathname.split('/');
|
||||
const slug = parts.length >= 4 ? decodeURIComponent(parts[3]) : null;
|
||||
if(!slug){ document.getElementById('overview').innerText='未提供專案'; }
|
||||
else document.getElementById('projName').innerText = slug;
|
||||
if (!slug) {
|
||||
document.getElementById('project-title').innerText = '專案未找到';
|
||||
document.getElementById('projName').innerText = '—';
|
||||
}
|
||||
|
||||
async function loadSettings(){
|
||||
function escHtml(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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;
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
document.getElementById('siteTitle').value = data.title || '';
|
||||
document.getElementById('siteDesc').value = data.description || '';
|
||||
}
|
||||
|
||||
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 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();
|
||||
});
|
||||
|
||||
async function listMedia(){
|
||||
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'; card.style.border='1px solid #ddd'; card.style.padding='8px'; card.style.display='inline-block'; card.style.margin='6px';
|
||||
const img = document.createElement('img'); img.src = it.thumb || it.url; img.style.width='160px'; img.style.height='auto';
|
||||
const info = document.createElement('div'); info.innerHTML = `<div>${it.original_name||it.filename}</div><div style="font-size:12px;color:#666">${it.uploaded_at||''}</div>`;
|
||||
const btnCopy = document.createElement('button'); btnCopy.textContent='複製連結'; btnCopy.addEventListener('click', ()=>{ navigator.clipboard.writeText(it.url); alert('已複製連結'); });
|
||||
const btnDel = document.createElement('button'); btnDel.textContent='刪除'; btnDel.style.marginLeft='6px'; 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('刪除失敗'); });
|
||||
card.appendChild(img); card.appendChild(info); card.appendChild(btnCopy); card.appendChild(btnDel); grid.appendChild(card);
|
||||
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('上傳失敗'); } });
|
||||
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('上傳失敗');
|
||||
}
|
||||
});
|
||||
|
||||
if(slug) loadSettings(), listMedia();
|
||||
// pages tree: improved rendering with expand/collapse and drag/drop reorder
|
||||
async function loadPagesTree(){
|
||||
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='';
|
||||
if (!res.ok) return;
|
||||
const tree = await res.json();
|
||||
const container = document.getElementById('pagesTree');
|
||||
container.innerHTML = '';
|
||||
|
||||
function createLi(item){
|
||||
function createLi(item) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'tree-item';
|
||||
li.style.padding='6px';
|
||||
li.draggable = true;
|
||||
li.dataset.type = item.type || 'file';
|
||||
li.dataset.name = item.name || item.title || '';
|
||||
|
||||
const titleWrap = document.createElement('span'); titleWrap.className='tree-title';
|
||||
const titleWrap = document.createElement('span');
|
||||
titleWrap.className = 'tree-title';
|
||||
titleWrap.textContent = item.title || item.name || '';
|
||||
|
||||
const btns = document.createElement('span'); btns.style.float = 'right';
|
||||
btns.innerHTML = "<button class='btn-edit' title='編輯'>✏️</button> <button class='btn-toggle' title='展開/收合'>▼</button>";
|
||||
const btns = document.createElement('span');
|
||||
btns.className = 'tree-item-actions';
|
||||
btns.innerHTML = "<button class='btn btn-ghost btn-sm btn-toggle' title='展開/收合'>▼</button>";
|
||||
|
||||
li.appendChild(titleWrap); li.appendChild(btns);
|
||||
li.appendChild(titleWrap);
|
||||
li.appendChild(btns);
|
||||
|
||||
// children container
|
||||
if(item.children && item.children.length){
|
||||
if (item.children && item.children.length) {
|
||||
const childUl = renderList(item.children);
|
||||
childUl.style.marginLeft = '18px';
|
||||
childUl.style.paddingLeft = '8px';
|
||||
childUl.className = 'tree-children';
|
||||
li.appendChild(childUl);
|
||||
}
|
||||
|
||||
// drag handlers
|
||||
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; });
|
||||
|
||||
// allow dropping onto li (insert before/after) and into folder (append)
|
||||
li.addEventListener('dragover', (e)=>{ e.preventDefault(); e.dataTransfer.dropEffect='move'; li.style.background='#f7f7f7'; });
|
||||
li.addEventListener('dragleave', (e)=>{ li.style.background=''; });
|
||||
li.addEventListener('drop', (e)=>{
|
||||
e.preventDefault(); li.style.background='';
|
||||
const nameEnc = e.dataTransfer.getData('text/name'); if(!nameEnc) return;
|
||||
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();
|
||||
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;
|
||||
// if dropping onto an item that has children (folder-like), append as child
|
||||
const onTop = e.clientY < (li.getBoundingClientRect().top + li.getBoundingClientRect().height/2);
|
||||
if(li.querySelector('ul')){
|
||||
// append as last child
|
||||
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){
|
||||
} else if (onTop) {
|
||||
li.parentNode.insertBefore(src, li);
|
||||
} else {
|
||||
if(li.nextSibling) li.parentNode.insertBefore(src, li.nextSibling); else li.parentNode.appendChild(src);
|
||||
if (li.nextSibling) li.parentNode.insertBefore(src, li.nextSibling);
|
||||
else li.parentNode.appendChild(src);
|
||||
}
|
||||
});
|
||||
|
||||
// toggle button handler
|
||||
const toggleBtn = btns.querySelector('.btn-toggle');
|
||||
if(toggleBtn){
|
||||
toggleBtn.addEventListener('click', ()=>{
|
||||
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' ? '▶' : '▼'; }
|
||||
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'); ul.style.listStyle='none'; ul.style.paddingLeft='4px'; ul.style.margin='0';
|
||||
items.forEach(it=> ul.appendChild(createLi(it)) );
|
||||
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));
|
||||
if (tree && tree.root) container.appendChild(renderList(tree.root));
|
||||
}
|
||||
|
||||
// build payload from DOM
|
||||
function buildPayload(rootEl){
|
||||
function walk(ul){
|
||||
function buildPayload(rootEl) {
|
||||
function walk(ul) {
|
||||
const arr = [];
|
||||
const lis = Array.from(ul.children).filter(n=>n.tagName === 'LI');
|
||||
lis.forEach(li=>{
|
||||
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: name, title: title, type: type };
|
||||
const obj = { name, title, type };
|
||||
const childUl = li.querySelector(':scope > ul');
|
||||
if(childUl) obj.children = walk(childUl);
|
||||
if (childUl) obj.children = walk(childUl);
|
||||
arr.push(obj);
|
||||
});
|
||||
return arr;
|
||||
@@ -186,18 +432,34 @@
|
||||
return { root: walk(rootEl.querySelector(':scope > ul') || document.createElement('ul')) };
|
||||
}
|
||||
|
||||
document.getElementById('saveTreeBtn').addEventListener('click', async ()=>{
|
||||
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 = '儲存頁面結構';
|
||||
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){ loadPagesTree(); }
|
||||
if (slug) {
|
||||
loadProjectSummary();
|
||||
loadSettings();
|
||||
renderPageList();
|
||||
listMedia();
|
||||
loadPagesTree();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user