新的樹狀管理器
This commit is contained in:
Binary file not shown.
70
main.py
70
main.py
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user