Files
vvveb-cms/templates/dashboard.html
2026-05-18 12:45:57 +08:00

512 lines
22 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-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" style="justify-content: space-between; display: flex; align-items: center; margin-bottom: 0.4rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--accent);">
<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>
<span>${escHtml(p)}</span>
</div>
<a href="/editor/${slug}?page=${escHtml(p)}" class="btn btn-primary btn-sm" style="padding: 0.2rem 0.6rem; font-size: 0.75rem; gap: 0.25rem; border-radius: var(--radius-sm);">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v16a2 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>
</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>