基本架構

This commit is contained in:
2026-05-17 22:11:46 +08:00
parent d40c5d6de3
commit 0380c01a27
11 changed files with 4204 additions and 5 deletions

502
templates/dashboard.html Normal file
View File

@@ -0,0 +1,502 @@
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VvvebJS 網頁管理器</title>
<meta name="description" content="使用 VvvebJS 管理您的靜態網站專案">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/my-style.css">
</head>
<body>
<!-- ── 頂部導覽 ────────────────────────────────────────────────── -->
<header class="topbar">
<div class="topbar-brand">
<svg class="brand-icon" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="url(#grad)"/>
<path d="M8 10h16M8 16h10M8 22h13" stroke="#fff" stroke-width="2.5" stroke-linecap="round"/>
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#6366f1"/>
<stop offset="1" stop-color="#8b5cf6"/>
</linearGradient>
</defs>
</svg>
<span class="brand-name">VvvebJS <em>Manager</em></span>
</div>
<div class="topbar-actions">
<span class="site-count" id="site-count">— 個專案</span>
<button class="btn btn-primary" id="new-project-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
新增專案
</button>
</div>
</header>
<!-- ── 主內容 ─────────────────────────────────────────────────── -->
<main class="main-content">
<!-- 搜尋列 -->
<div class="search-bar-wrap">
<div class="search-bar">
<svg class="search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" id="search-input" placeholder="搜尋專案名稱…" autocomplete="off">
</div>
</div>
<!-- 卡片網格 -->
<div class="projects-grid" id="projects-grid">
<!-- 由 JS 動態填入 -->
</div>
<!-- 空狀態 -->
<div class="empty-state" id="empty-state" style="display:none">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2">
<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<h2>尚無專案</h2>
<p>點擊右上角「新增專案」開始建立您的第一個網站</p>
<button class="btn btn-primary" id="empty-new-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
建立第一個專案
</button>
</div>
</main>
<!-- ── 新增專案 Modal ──────────────────────────────────────────── -->
<div class="modal-backdrop" id="create-modal-backdrop">
<div class="modal-box" id="create-modal" role="dialog" aria-labelledby="create-modal-title" aria-modal="true">
<div class="modal-header">
<h2 id="create-modal-title">新增專案</h2>
<button class="modal-close" id="create-modal-close" aria-label="關閉">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="new-project-name">專案名稱 <span class="required">*</span></label>
<input type="text" id="new-project-name" placeholder="例如:我的公司網站" autocomplete="off">
<span class="slug-preview" id="slug-preview"></span>
</div>
<div class="form-group">
<label for="new-project-desc">描述(選填)</label>
<textarea id="new-project-desc" rows="3" placeholder="簡短說明這個網站的用途…"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="create-cancel-btn">取消</button>
<button class="btn btn-primary" id="create-confirm-btn">建立專案</button>
</div>
</div>
</div>
<!-- ── 設定 Offcanvas ─────────────────────────────────────────── -->
<div class="offcanvas-backdrop" id="settings-backdrop">
<div class="offcanvas" id="settings-panel" role="dialog" aria-labelledby="settings-title">
<div class="offcanvas-header">
<h2 id="settings-title">專案設定</h2>
<button class="modal-close" id="settings-close" aria-label="關閉">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="offcanvas-body">
<input type="hidden" id="settings-slug">
<div class="form-group">
<label for="settings-name">專案名稱</label>
<input type="text" id="settings-name" placeholder="專案名稱">
</div>
<div class="form-group">
<label for="settings-desc">描述</label>
<textarea id="settings-desc" rows="4"></textarea>
</div>
<div class="form-group">
<label>Slug資料夾名稱</label>
<input type="text" id="settings-slug-display" disabled class="input-disabled">
<p class="hint-text">Slug 為唯一識別碼,建立後無法更改</p>
</div>
<div class="divider"></div>
<h3 class="section-label">頁面清單</h3>
<ul class="pages-list" id="settings-pages-list">
<li class="pages-loading">載入中…</li>
</ul>
<button class="btn btn-ghost btn-sm" id="add-page-btn" style="margin-top:0.75rem; width:100%">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
新增頁面
</button>
<div class="divider"></div>
<div class="danger-zone">
<h3 class="section-label danger">危險操作</h3>
<button class="btn btn-danger" id="delete-project-btn">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/>
<path d="M10 11v6M14 11v6"/><path d="M9 6V4h6v2"/>
</svg>
刪除此專案
</button>
<p class="hint-text danger">此操作不可復原,將刪除所有頁面檔案</p>
</div>
</div>
<div class="offcanvas-footer">
<button class="btn btn-ghost" id="settings-cancel-btn">取消</button>
<button class="btn btn-primary" id="settings-save-btn">儲存設定</button>
</div>
</div>
</div>
<!-- ── 刪除確認 Modal ──────────────────────────────────────────── -->
<div class="modal-backdrop" id="delete-modal-backdrop">
<div class="modal-box modal-sm" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>確認刪除</h2>
</div>
<div class="modal-body">
<p>確定要刪除專案 <strong id="delete-project-name"></strong> 嗎?<br>
此操作不可復原,所有頁面檔案都將被刪除。</p>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="delete-cancel-btn">取消</button>
<button class="btn btn-danger" id="delete-confirm-btn">確認刪除</button>
</div>
</div>
</div>
<!-- ── Toast 通知 ─────────────────────────────────────────────── -->
<div class="toast-container" id="toast-container"></div>
<script>
// ── 狀態 ─────────────────────────────────────────────────────────
let allProjects = [];
let settingsCurrentSlug = '';
let deleteTargetSlug = '';
// ── API 輔助 ─────────────────────────────────────────────────────
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;
}
// ── Toast ─────────────────────────────────────────────────────────
function showToast(msg, type = 'success') {
const tc = document.getElementById('toast-container');
const t = document.createElement('div');
t.className = `toast toast-${type}`;
t.textContent = msg;
tc.appendChild(t);
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => {
t.classList.remove('show');
setTimeout(() => t.remove(), 400);
}, 3000);
}
// ── Slug 預覽 ─────────────────────────────────────────────────────
function toSlug(s) {
return s.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'untitled';
}
// ── 渲染卡片 ─────────────────────────────────────────────────────
function renderProjects(projects) {
const grid = document.getElementById('projects-grid');
const empty = document.getElementById('empty-state');
const count = document.getElementById('site-count');
count.textContent = `${projects.length} 個專案`;
if (projects.length === 0) {
grid.innerHTML = '';
empty.style.display = 'flex';
return;
}
empty.style.display = 'none';
grid.innerHTML = projects.map(p => {
const mod = p.last_modified ? p.last_modified.slice(0, 10) : '—';
const pages = p.page_count ?? 0;
return `
<div class="project-card" data-slug="${p.slug}">
<div class="card-glow"></div>
<div class="card-header-row">
<div class="card-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<rect x="2" y="3" width="20" height="14" rx="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
</div>
<div class="card-menu">
<button class="icon-btn settings-btn" data-slug="${p.slug}" title="設定">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
</div>
</div>
<div class="card-body">
<h3 class="card-title">${escHtml(p.name)}</h3>
<p class="card-desc">${escHtml(p.description || '—')}</p>
</div>
<div class="card-meta">
<span class="meta-item">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
${pages}
</span>
<span class="meta-item">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
${mod}
</span>
</div>
<div class="card-actions">
<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"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
開啟編輯器
</a>
</div>
</div>`;
}).join('');
// 綁定設定按鈕
grid.querySelectorAll('.settings-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
openSettings(btn.dataset.slug);
});
});
}
function escHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 載入專案 ─────────────────────────────────────────────────────
async function loadProjects() {
try {
allProjects = await apiFetch('/api/projects');
renderProjects(allProjects);
} catch (e) {
showToast('載入專案失敗:' + e.message, 'error');
}
}
// ── 搜尋 ─────────────────────────────────────────────────────────
document.getElementById('search-input').addEventListener('input', function() {
const q = this.value.toLowerCase();
const filtered = allProjects.filter(p =>
p.name.toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q)
);
renderProjects(filtered);
});
// ── 新增專案 Modal ────────────────────────────────────────────────
function openCreateModal() {
document.getElementById('new-project-name').value = '';
document.getElementById('new-project-desc').value = '';
document.getElementById('slug-preview').textContent = '';
document.getElementById('create-modal-backdrop').classList.add('active');
setTimeout(() => document.getElementById('new-project-name').focus(), 50);
}
function closeCreateModal() {
document.getElementById('create-modal-backdrop').classList.remove('active');
}
document.getElementById('new-project-btn').addEventListener('click', openCreateModal);
document.getElementById('empty-new-btn').addEventListener('click', openCreateModal);
document.getElementById('create-modal-close').addEventListener('click', closeCreateModal);
document.getElementById('create-cancel-btn').addEventListener('click', closeCreateModal);
document.getElementById('create-modal-backdrop').addEventListener('click', e => {
if (e.target.id === 'create-modal-backdrop') closeCreateModal();
});
document.getElementById('new-project-name').addEventListener('input', function() {
const slug = toSlug(this.value);
const preview = document.getElementById('slug-preview');
preview.textContent = this.value ? `資料夾名稱:${slug}` : '';
});
document.getElementById('create-confirm-btn').addEventListener('click', async () => {
const name = document.getElementById('new-project-name').value.trim();
const desc = document.getElementById('new-project-desc').value.trim();
if (!name) {
showToast('請輸入專案名稱', 'error');
document.getElementById('new-project-name').focus();
return;
}
try {
const btn = document.getElementById('create-confirm-btn');
btn.disabled = true;
btn.textContent = '建立中…';
await apiFetch('/api/projects', {
method: 'POST',
body: JSON.stringify({ name, description: desc }),
});
closeCreateModal();
showToast(`專案「${name}」建立成功!`);
await loadProjects();
} catch(e) {
showToast('建立失敗:' + e.message, 'error');
} finally {
const btn = document.getElementById('create-confirm-btn');
btn.disabled = false;
btn.textContent = '建立專案';
}
});
// ── 設定 Offcanvas ────────────────────────────────────────────────
async function openSettings(slug) {
settingsCurrentSlug = slug;
document.getElementById('settings-backdrop').classList.add('active');
document.getElementById('settings-slug').value = slug;
document.getElementById('settings-slug-display').value = slug;
document.getElementById('settings-pages-list').innerHTML = '<li class="pages-loading">載入中…</li>';
try {
const proj = await apiFetch(`/api/projects/${slug}`);
document.getElementById('settings-name').value = proj.name;
document.getElementById('settings-desc').value = proj.description || '';
const pages = proj.pages || [];
const listEl = document.getElementById('settings-pages-list');
if (pages.length === 0) {
listEl.innerHTML = '<li class="no-pages">尚無頁面</li>';
} else {
listEl.innerHTML = pages.map(p => `
<li class="page-item">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
${escHtml(p)}
</li>`).join('');
}
} catch(e) {
showToast('載入設定失敗:' + e.message, 'error');
}
}
function closeSettings() {
document.getElementById('settings-backdrop').classList.remove('active');
}
document.getElementById('settings-close').addEventListener('click', closeSettings);
document.getElementById('settings-cancel-btn').addEventListener('click', closeSettings);
document.getElementById('settings-backdrop').addEventListener('click', e => {
if (e.target.id === 'settings-backdrop') closeSettings();
});
document.getElementById('settings-save-btn').addEventListener('click', async () => {
const name = document.getElementById('settings-name').value.trim();
const desc = document.getElementById('settings-desc').value.trim();
if (!name) {
showToast('名稱不能為空', 'error');
return;
}
try {
await apiFetch(`/api/projects/${settingsCurrentSlug}`, {
method: 'PUT',
body: JSON.stringify({ name, description: desc }),
});
closeSettings();
showToast('設定已儲存');
await loadProjects();
} catch(e) {
showToast('儲存失敗:' + e.message, 'error');
}
});
// 新增頁面
document.getElementById('add-page-btn').addEventListener('click', async () => {
const name = prompt('請輸入新頁面名稱例如about');
if (!name) return;
try {
await apiFetch(`/api/projects/${settingsCurrentSlug}/pages`, {
method: 'POST',
body: JSON.stringify({ name }),
});
showToast(`頁面「${name}」建立成功`);
openSettings(settingsCurrentSlug); // 重新整理面板
} catch(e) {
showToast('建立頁面失敗:' + e.message, 'error');
}
});
// ── 刪除 ─────────────────────────────────────────────────────────
document.getElementById('delete-project-btn').addEventListener('click', () => {
deleteTargetSlug = settingsCurrentSlug;
const proj = allProjects.find(p => p.slug === settingsCurrentSlug);
document.getElementById('delete-project-name').textContent = proj ? proj.name : deleteTargetSlug;
document.getElementById('delete-modal-backdrop').classList.add('active');
});
document.getElementById('delete-cancel-btn').addEventListener('click', () => {
document.getElementById('delete-modal-backdrop').classList.remove('active');
});
document.getElementById('delete-confirm-btn').addEventListener('click', async () => {
try {
await apiFetch(`/api/projects/${deleteTargetSlug}`, { method: 'DELETE' });
document.getElementById('delete-modal-backdrop').classList.remove('active');
closeSettings();
showToast('專案已刪除');
await loadProjects();
} catch(e) {
showToast('刪除失敗:' + e.message, 'error');
}
});
document.getElementById('delete-modal-backdrop').addEventListener('click', e => {
if (e.target.id === 'delete-modal-backdrop')
document.getElementById('delete-modal-backdrop').classList.remove('active');
});
// ── Enter 鍵快速提交 ─────────────────────────────────────────────
document.getElementById('new-project-name').addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('create-confirm-btn').click();
});
// ── 初始化 ────────────────────────────────────────────────────────
loadProjects();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff