新的樹狀管理器

This commit is contained in:
2026-05-26 12:23:29 +08:00
parent 56f9a703b8
commit ee5a54ea2c
5 changed files with 246 additions and 3 deletions

Binary file not shown.

70
main.py
View File

@@ -137,9 +137,74 @@ def _copy_blank_template(dest: Path) -> None:
) )
def _flatten_tree_items(items: list[dict[str, Any]], file_names: set[str]) -> None:
for item in items:
if item.get("type") == "file" and isinstance(item.get("name"), str):
file_names.add(item["name"])
if isinstance(item.get("children"), list):
_flatten_tree_items(item["children"], file_names)
def _normalize_page_tree(slug: str, tree: dict[str, Any]) -> dict[str, Any]:
"""將已儲存的 page_tree 與實際檔案同步並補齊新頁面。"""
pages = set(_list_pages(slug))
existing_files: set[str] = set()
root_items = tree.get("root") if isinstance(tree.get("root"), list) else []
_flatten_tree_items(root_items, existing_files)
def filter_valid_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in items:
item_type = item.get("type")
if item_type == "file":
name = item.get("name")
if isinstance(name, str) and name in pages:
existing_files.add(name)
result.append({
"type": "file",
"name": name,
"title": item.get("title", Path(name).stem.replace("-", " ").title()),
"show_in_nav": bool(item.get("show_in_nav", True)),
"is_homepage": bool(item.get("is_homepage", False)),
"requires_password": bool(item.get("requires_password", False)),
})
elif item_type == "folder":
name = item.get("name")
if isinstance(name, str):
children = filter_valid_items(item.get("children", []) if isinstance(item.get("children"), list) else [])
result.append({
"type": "folder",
"name": name,
"title": item.get("title", name.replace("-", " ").title()),
"show_in_nav": bool(item.get("show_in_nav", True)),
"children": children,
})
else:
continue
return result
normalized = {"root": filter_valid_items(root_items)}
missing_pages = sorted(pages - existing_files)
if missing_pages:
for rel in missing_pages:
normalized["root"].append({
"type": "file",
"name": rel,
"title": Path(rel).stem.replace("-", " ").title(),
"show_in_nav": True,
"is_homepage": rel == "index.html",
"requires_password": False,
})
return normalized
def build_page_tree(slug: str, max_depth: int = 3) -> dict[str, Any]: def build_page_tree(slug: str, max_depth: int = 3) -> dict[str, Any]:
"""遞歸建構專案的頁面樹狀結構(根據資料夾結構)""" """遞歸建構專案的頁面樹狀結構。"""
proj_dir = _project_dir(slug) proj_dir = _project_dir(slug)
data = _load_project(slug)
page_tree = data.get("page_tree")
if isinstance(page_tree, dict) and isinstance(page_tree.get("root"), list):
return _normalize_page_tree(slug, page_tree)
def scan_folder(folder: Path, current_depth: int = 0): def scan_folder(folder: Path, current_depth: int = 0):
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
@@ -168,6 +233,9 @@ def build_page_tree(slug: str, max_depth: int = 3) -> dict[str, Any]:
"type": "file", "type": "file",
"name": rel, "name": rel,
"title": Path(name).stem.replace("-", " ").title(), "title": Path(name).stem.replace("-", " ").title(),
"show_in_nav": True,
"is_homepage": rel == "index.html",
"requires_password": False,
}) })
return items return items

View File

@@ -428,6 +428,22 @@ button { cursor: pointer; font-family: inherit; }
background: rgba(99,102,241,0.1); background: rgba(99,102,241,0.1);
} }
.tree-item-meta {
display: flex;
align-items: center;
gap: 0.3rem;
margin-left: 1rem;
}
.tree-item-meta .btn {
min-width: 2.2rem;
}
.tree-item-meta .btn.active {
border-color: rgba(59,130,246,0.6);
background: rgba(59,130,246,0.12);
}
.tree-item-actions .btn { .tree-item-actions .btn {
padding: 0.35rem 0.7rem; padding: 0.35rem 0.7rem;
} }

View File

@@ -340,6 +340,24 @@
const tree = await res.json(); const tree = await res.json();
const container = document.getElementById('pagesTree'); const container = document.getElementById('pagesTree');
container.innerHTML = ''; container.innerHTML = '';
container.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
});
container.addEventListener('drop', e => {
e.preventDefault();
const nameEnc = e.dataTransfer.getData('text/name');
if (!nameEnc) return;
const name = decodeURIComponent(nameEnc);
const src = container.querySelector(`[data-name='${CSS.escape(name)}']`);
if (!src) return;
let rootUl = container.querySelector(':scope > ul');
if (!rootUl) {
rootUl = document.createElement('ul');
container.appendChild(rootUl);
}
rootUl.appendChild(src);
});
function createLi(item) { function createLi(item) {
const li = document.createElement('li'); const li = document.createElement('li');
@@ -347,6 +365,9 @@
li.draggable = true; li.draggable = true;
li.dataset.type = item.type || 'file'; li.dataset.type = item.type || 'file';
li.dataset.name = item.name || item.title || ''; li.dataset.name = item.name || item.title || '';
li.dataset.showInNav = item.show_in_nav !== false ? 'true' : 'false';
li.dataset.isHomepage = item.is_homepage ? 'true' : 'false';
li.dataset.requiresPassword = item.requires_password ? 'true' : 'false';
const titleWrap = document.createElement('span'); const titleWrap = document.createElement('span');
titleWrap.className = 'tree-title'; titleWrap.className = 'tree-title';
@@ -356,7 +377,56 @@
btns.className = 'tree-item-actions'; btns.className = 'tree-item-actions';
btns.innerHTML = "<button class='btn btn-ghost btn-sm btn-toggle' title='展開/收合'>▼</button>"; btns.innerHTML = "<button class='btn btn-ghost btn-sm btn-toggle' title='展開/收合'>▼</button>";
const meta = document.createElement('span');
meta.className = 'tree-item-meta';
meta.innerHTML = `
<button class='btn btn-ghost btn-sm btn-nav' title='顯示於導覽列'>☰</button>
<button class='btn btn-ghost btn-sm btn-homepage' title='設為首頁'>⭐</button>
<button class='btn btn-ghost btn-sm btn-password' title='需要密碼'>🔒</button>
`;
const btnNav = meta.querySelector('.btn-nav');
const btnHome = meta.querySelector('.btn-homepage');
const btnPassword = meta.querySelector('.btn-password');
const updateMetaState = () => {
const showNav = li.dataset.showInNav === 'true';
const isHomepage = li.dataset.isHomepage === 'true';
const requiresPassword = li.dataset.requiresPassword === 'true';
btnNav.classList.toggle('active', showNav);
btnNav.title = showNav ? '顯示於導覽列' : '從導覽列隱藏';
btnHome.classList.toggle('active', isHomepage);
btnHome.title = isHomepage ? '首頁' : '設為首頁';
btnPassword.classList.toggle('active', requiresPassword);
btnPassword.title = requiresPassword ? '需要密碼' : '不需要密碼';
if (li.dataset.type !== 'file') {
btnHome.disabled = true;
btnHome.title = '僅限單一頁面';
}
};
updateMetaState();
btnNav.addEventListener('click', () => {
li.dataset.showInNav = li.dataset.showInNav === 'true' ? 'false' : 'true';
updateMetaState();
});
btnPassword.addEventListener('click', () => {
li.dataset.requiresPassword = li.dataset.requiresPassword === 'true' ? 'false' : 'true';
updateMetaState();
});
btnHome.addEventListener('click', () => {
if (li.dataset.type !== 'file') return;
container.querySelectorAll('.tree-item').forEach(other => {
other.dataset.isHomepage = 'false';
});
container.querySelectorAll('.btn-homepage').forEach(button => button.classList.remove('active'));
li.dataset.isHomepage = 'true';
updateMetaState();
});
li.appendChild(titleWrap); li.appendChild(titleWrap);
li.appendChild(meta);
li.appendChild(btns); li.appendChild(btns);
if (item.children && item.children.length) { if (item.children && item.children.length) {
@@ -374,6 +444,7 @@
li.addEventListener('dragleave', () => { li.classList.remove('tree-drop-hover'); }); li.addEventListener('dragleave', () => { li.classList.remove('tree-drop-hover'); });
li.addEventListener('drop', e => { li.addEventListener('drop', e => {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
li.classList.remove('tree-drop-hover'); li.classList.remove('tree-drop-hover');
const nameEnc = e.dataTransfer.getData('text/name'); const nameEnc = e.dataTransfer.getData('text/name');
if (!nameEnc) return; if (!nameEnc) return;
@@ -422,7 +493,14 @@
const title = li.querySelector('.tree-title')?.textContent || ''; const title = li.querySelector('.tree-title')?.textContent || '';
const name = li.dataset.name || ''; const name = li.dataset.name || '';
const type = li.dataset.type || 'file'; const type = li.dataset.type || 'file';
const obj = { name, title, type }; const obj = {
name,
title,
type,
show_in_nav: li.dataset.showInNav === 'true',
is_homepage: li.dataset.isHomepage === 'true',
requires_password: li.dataset.requiresPassword === 'true',
};
const childUl = li.querySelector(':scope > ul'); const childUl = li.querySelector(':scope > ul');
if (childUl) obj.children = walk(childUl); if (childUl) obj.children = walk(childUl);
arr.push(obj); arr.push(obj);

View File

@@ -2,5 +2,86 @@
"name": "My Website", "name": "My Website",
"slug": "my-website", "slug": "my-website",
"description": "", "description": "",
"created_at": "2026-05-17T13:53:12" "created_at": "2026-05-17T13:53:12",
"page_tree": {
"root": [
{
"name": "fold.html",
"title": "Fold",
"type": "file"
},
{
"name": "about-final.html",
"title": "About Final",
"type": "file"
},
{
"name": "index.html",
"title": "Index",
"type": "file"
},
{
"name": "my-page.html",
"title": "My Page",
"type": "file"
},
{
"name": "my-page3.html",
"title": "My Page3",
"type": "file"
},
{
"name": "my-page4.html",
"title": "My Page4",
"type": "file"
},
{
"name": "subfolder",
"title": "Subfolder",
"type": "folder",
"children": [
{
"name": "subfolder/subpage-title.html",
"title": "Subpage Title",
"type": "file"
}
]
},
{
"name": "temp",
"title": "Temp",
"type": "folder",
"children": [
{
"name": "temp/my-page5.html",
"title": "My Page5",
"type": "file"
},
{
"name": "temp/new.html",
"title": "New",
"type": "file"
},
{
"name": "subsubfolder",
"title": "Subsubfolder",
"type": "folder",
"children": [
{
"name": "temp/subsubfolder/myname.html",
"title": "Myname",
"type": "file"
}
]
}
]
},
{
"name": "test.html",
"title": "Test",
"type": "file"
}
]
},
"updated_at": "2026-05-26T04:12:23"
} }