調整儀錶板和編輯器的界面,新增快速設定按鈕,並添加了相關的測試腳本和模板文件。
This commit is contained in:
Binary file not shown.
25
main.py
25
main.py
@@ -184,9 +184,19 @@ def index() -> Response:
|
|||||||
|
|
||||||
@app.route("/dashboard") # type: ignore[untyped-decorator]
|
@app.route("/dashboard") # type: ignore[untyped-decorator]
|
||||||
def dashboard() -> str:
|
def dashboard() -> str:
|
||||||
|
# render projects overview
|
||||||
return str(render_template("dashboard.html"))
|
return str(render_template("dashboard.html"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/dashboard/project/<slug>") # type: ignore[untyped-decorator]
|
||||||
|
def project_dashboard(slug: str) -> str:
|
||||||
|
# render single project management dashboard
|
||||||
|
proj_dir = _project_dir(slug)
|
||||||
|
if not proj_dir.exists():
|
||||||
|
abort(404)
|
||||||
|
return str(render_template("project_dashboard.html", slug=slug))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/editor/<slug>") # type: ignore[untyped-decorator]
|
@app.route("/editor/<slug>") # type: ignore[untyped-decorator]
|
||||||
def editor(slug: str) -> str:
|
def editor(slug: str) -> str:
|
||||||
proj_dir = _project_dir(slug)
|
proj_dir = _project_dir(slug)
|
||||||
@@ -345,6 +355,21 @@ def api_pages_tree(slug: str) -> tuple[Response, int] | Response:
|
|||||||
return jsonify(tree)
|
return jsonify(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/projects/<slug>/pages-tree", methods=["PUT"]) # type: ignore[untyped-decorator]
|
||||||
|
def api_put_pages_tree(slug: str) -> tuple[Response, int] | Response:
|
||||||
|
"""儲存自訂的頁面樹狀結構到 project.json 的 page_tree 欄位。"""
|
||||||
|
if not _project_dir(slug).exists():
|
||||||
|
return jsonify({"error": "專案不存在"}), 404
|
||||||
|
body: dict[str, Any] = request.get_json(force=True) or {}
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return jsonify({"error": "不正確的資料格式"}), 400
|
||||||
|
data = _load_project(slug)
|
||||||
|
data["page_tree"] = body
|
||||||
|
data["updated_at"] = _now_iso()
|
||||||
|
_save_project(slug, data)
|
||||||
|
return jsonify({"ok": True, "page_tree": body})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/projects/<slug>/media/upload", methods=["POST"]) # type: ignore[untyped-decorator]
|
@app.route("/api/projects/<slug>/media/upload", methods=["POST"]) # type: ignore[untyped-decorator]
|
||||||
def api_media_upload(slug: str) -> tuple[Response, int] | Response:
|
def api_media_upload(slug: str) -> tuple[Response, int] | Response:
|
||||||
proj_dir = _project_dir(slug)
|
proj_dir = _project_dir(slug)
|
||||||
|
|||||||
63
scripts/test_phase1.py
Normal file
63
scripts/test_phase1.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""Automated basic end-to-end test for Phase 1 features.
|
||||||
|
Creates a project, updates settings, uploads an image, lists media, deletes image.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
BASE = 'http://127.0.0.1:5000'
|
||||||
|
|
||||||
|
def create_project(name='test-project'):
|
||||||
|
r = requests.post(f'{BASE}/api/projects', json={'name': name, 'description': 'automated test'})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def update_settings(slug):
|
||||||
|
r = requests.put(f'{BASE}/api/projects/{slug}/settings', json={'title':'Auto Test','description':'desc'})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def upload_image(slug):
|
||||||
|
# generate small PNG
|
||||||
|
im = Image.new('RGB', (100,100), color=(123,222,100))
|
||||||
|
buf = io.BytesIO()
|
||||||
|
im.save(buf, format='PNG')
|
||||||
|
buf.seek(0)
|
||||||
|
files = {'file': ('test.png', buf, 'image/png')}
|
||||||
|
r = requests.post(f'{BASE}/api/projects/{slug}/media/upload', files=files)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def list_media(slug):
|
||||||
|
r = requests.get(f'{BASE}/api/projects/{slug}/media/list')
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def delete_media(slug, rel):
|
||||||
|
r = requests.delete(f'{BASE}/api/projects/{slug}/media/{rel}')
|
||||||
|
return r
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print('Creating project...')
|
||||||
|
proj = create_project('phase1-e2e')
|
||||||
|
slug = proj['slug']
|
||||||
|
print('Project created:', slug)
|
||||||
|
print('Updating settings...')
|
||||||
|
print(update_settings(slug))
|
||||||
|
print('Uploading image...')
|
||||||
|
up = upload_image(slug)
|
||||||
|
print('Upload result:', up)
|
||||||
|
time.sleep(0.5)
|
||||||
|
items = list_media(slug)
|
||||||
|
print('Media list count:', len(items))
|
||||||
|
if items:
|
||||||
|
it = items[0]
|
||||||
|
rel = f"media/images/{it['date']}/{it['filename']}"
|
||||||
|
print('Deleting', rel)
|
||||||
|
r = delete_media(slug, rel)
|
||||||
|
print('Delete status', r.status_code)
|
||||||
|
print('Done')
|
||||||
@@ -1,136 +1,3 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-Hant">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
||||||
<title>管理器 - Dashboard</title>
|
|
||||||
<link rel="stylesheet" href="/static/css/my-style.css">
|
|
||||||
<style>
|
|
||||||
.tabs { display:flex; gap:8px; margin-bottom:12px; }
|
|
||||||
.tab { padding:8px 12px; border:1px solid #ddd; cursor:pointer; }
|
|
||||||
.active { background:#f0f0f0; }
|
|
||||||
</style>
|
|
||||||
</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">選擇一個專案在右側編輯。</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="pagesTree"></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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',e=>{
|
|
||||||
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';
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Dashboard logic: load settings and media for ?slug=...
|
|
||||||
(function(){
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const slug = params.get('slug');
|
|
||||||
if(!slug){
|
|
||||||
document.getElementById('overview').innerText = '請在網址列加入 ?slug=your-site 以管理專案。';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// load settings
|
|
||||||
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 || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
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('儲存失敗');
|
|
||||||
});
|
|
||||||
|
|
||||||
// media upload
|
|
||||||
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;
|
|
||||||
// rel_path expected like media/images/YYYY-MM/filename
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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('上傳失敗'); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// init
|
|
||||||
loadSettings(); listMedia();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-TW">
|
<html lang="zh-TW">
|
||||||
<head>
|
<head>
|
||||||
|
|||||||
@@ -2319,7 +2319,7 @@ window.VVVEB_PROJECT_SLUG = "{% endraw %}{{ slug | safe }}{% raw %}";
|
|||||||
if(!btn) return;
|
if(!btn) return;
|
||||||
btn.addEventListener('click', function(e){
|
btn.addEventListener('click', function(e){
|
||||||
var slug = window.VVVEB_PROJECT_SLUG || '';
|
var slug = window.VVVEB_PROJECT_SLUG || '';
|
||||||
var url = '/dashboard' + (slug ? ('?slug='+encodeURIComponent(slug)) : '');
|
var url = '/dashboard/project/' + encodeURIComponent(slug || '');
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
203
templates/project_dashboard.html
Normal file
203
templates/project_dashboard.html
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<!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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',e=>{
|
||||||
|
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';
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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 || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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(){
|
||||||
|
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='';
|
||||||
|
|
||||||
|
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';
|
||||||
|
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>";
|
||||||
|
|
||||||
|
li.appendChild(titleWrap); li.appendChild(btns);
|
||||||
|
|
||||||
|
// children container
|
||||||
|
if(item.children && item.children.length){
|
||||||
|
const childUl = renderList(item.children);
|
||||||
|
childUl.style.marginLeft = '18px';
|
||||||
|
childUl.style.paddingLeft = '8px';
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// toggle button handler
|
||||||
|
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'); ul.style.listStyle='none'; ul.style.paddingLeft='4px'; ul.style.margin='0';
|
||||||
|
items.forEach(it=> ul.appendChild(createLi(it)) );
|
||||||
|
return ul;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(tree && tree.root) container.appendChild(renderList(tree.root));
|
||||||
|
}
|
||||||
|
|
||||||
|
// build payload from DOM
|
||||||
|
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: name, title: title, type: type };
|
||||||
|
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){ loadPagesTree(); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
websites/phase1-e2e/index.html
Normal file
33
websites/phase1-e2e/index.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="description" content="">
|
||||||
|
<meta name="author" content="">
|
||||||
|
<title>My page</title>
|
||||||
|
<!-- Bootstrap core CSS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/js/bootstrap.min.js"></script>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<script src="https://code.jquery.com/jquery-3.7.0.min.js"></script>
|
||||||
|
<style>
|
||||||
|
html, body
|
||||||
|
{
|
||||||
|
width:100%;
|
||||||
|
height:100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12 text-center">
|
||||||
|
<h1 class="mt-5">Bootstrap 5 start page</h1>
|
||||||
|
<p class="lead">Start by dragging components to page or double click to edit text</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 291 B |
10
websites/phase1-e2e/media/images/uploads.json
Normal file
10
websites/phase1-e2e/media/images/uploads.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"2026-05": [
|
||||||
|
{
|
||||||
|
"filename": "5717be82fe7246a6b7b437182cc5748f.png",
|
||||||
|
"original_name": "test.png",
|
||||||
|
"size": 289,
|
||||||
|
"uploaded_at": "2026-05-26T03:57:55"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
websites/phase1-e2e/project.json
Normal file
11
websites/phase1-e2e/project.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "phase1-e2e",
|
||||||
|
"slug": "phase1-e2e",
|
||||||
|
"description": "automated test",
|
||||||
|
"created_at": "2026-05-26T03:57:54",
|
||||||
|
"settings": {
|
||||||
|
"title": "Auto Test",
|
||||||
|
"description": "desc"
|
||||||
|
},
|
||||||
|
"updated_at": "2026-05-26T03:57:55"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user