220 lines
9.1 KiB
Rust
220 lines
9.1 KiB
Rust
//! MNOTE 通用页面布局组件(Wolai / Notion 风格)
|
|
|
|
use leptos::prelude::*;
|
|
|
|
const SIDEBAR_TREE_JS: &str = r##"
|
|
(function(){
|
|
function closestAction(target, selector) {
|
|
return target && typeof target.closest === 'function' ? target.closest(selector) : null;
|
|
}
|
|
|
|
function resolveWorkspaceId(trigger) {
|
|
var direct = (trigger.getAttribute('data-workspace-id') || '').trim();
|
|
if (direct) return direct;
|
|
var root = trigger.closest('[data-workspace-id]');
|
|
return root ? (root.getAttribute('data-workspace-id') || '').trim() : '';
|
|
}
|
|
|
|
async function dispatchTreeCommand(trigger, body) {
|
|
trigger.setAttribute('data-pending', 'true');
|
|
trigger.setAttribute('disabled', 'disabled');
|
|
try {
|
|
var response = await fetch('/api/tree/commands', {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
});
|
|
var payload = await response.json().catch(function(){ return null; });
|
|
if (!response.ok || !payload || !payload.result) {
|
|
throw new Error((payload && payload.message) || 'tree_command_failed_' + response.status);
|
|
}
|
|
return payload.result;
|
|
} catch (error) {
|
|
trigger.removeAttribute('disabled');
|
|
trigger.setAttribute('data-pending', 'false');
|
|
trigger.setAttribute('data-error', error instanceof Error ? error.message : String(error));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function createPage(trigger, parentId) {
|
|
var workspaceId = resolveWorkspaceId(trigger);
|
|
var effectiveParentId = (parentId || trigger.getAttribute('data-parent-id') || '').trim();
|
|
if (!workspaceId) return;
|
|
var result = await dispatchTreeCommand(trigger, {
|
|
action: 'create',
|
|
workspaceId: workspaceId,
|
|
parentId: effectiveParentId || null,
|
|
title: '新页面'
|
|
});
|
|
var nextWorkspaceId = result.workspaceId || workspaceId;
|
|
window.location.href = '/documents/' + encodeURIComponent(result.documentId) + '?workspaceId=' + encodeURIComponent(nextWorkspaceId);
|
|
}
|
|
|
|
document.addEventListener('click', function(e) {
|
|
var createTrigger = closestAction(e.target, '[data-mnote-action="create-page"]');
|
|
if (createTrigger) {
|
|
e.preventDefault();
|
|
void createPage(createTrigger, null);
|
|
return;
|
|
}
|
|
|
|
var tree = document.getElementById('sidebar-tree-root');
|
|
if (!tree || !tree.contains(e.target)) return;
|
|
var btn = closestAction(e.target, '[data-rust-action]');
|
|
if (!btn) return;
|
|
var nodeId = btn.getAttribute('data-node-id');
|
|
var action = btn.getAttribute('data-rust-action');
|
|
|
|
if (action === 'toggle') {
|
|
var row = btn.closest('.tree-row');
|
|
if (!row) return;
|
|
var li = row.parentElement;
|
|
var children = li.querySelector(':scope > .tree-children');
|
|
if (children) {
|
|
children.classList.toggle('tree-children--collapsed');
|
|
var isCollapsed = children.classList.contains('tree-children--collapsed');
|
|
row.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
|
btn.textContent = isCollapsed ? '▸' : '▾';
|
|
}
|
|
e.preventDefault();
|
|
} else if (action === 'open') {
|
|
var workspaceId = resolveWorkspaceId(btn);
|
|
window.location.href = '/documents/' + encodeURIComponent(nodeId) + (workspaceId ? '?workspaceId=' + encodeURIComponent(workspaceId) : '');
|
|
e.preventDefault();
|
|
} else if (action === 'create') {
|
|
e.preventDefault();
|
|
void createPage(btn, nodeId);
|
|
} else if (action === 'rename') {
|
|
e.preventDefault();
|
|
var title = window.prompt('重命名页面');
|
|
if (title && title.trim()) {
|
|
void dispatchTreeCommand(btn, {
|
|
action: 'rename',
|
|
workspaceId: resolveWorkspaceId(btn),
|
|
documentId: nodeId,
|
|
title: title.trim()
|
|
}).then(function(){ window.location.reload(); });
|
|
}
|
|
}
|
|
});
|
|
|
|
var tree = document.getElementById('sidebar-tree-root');
|
|
if (!tree) return;
|
|
var currentPath = window.location.pathname;
|
|
var match = currentPath.match(/^\/documents\/([^\/]+)/);
|
|
if (match) {
|
|
var activeId = match[1];
|
|
var links = tree.querySelectorAll('[data-rust-action="open"]');
|
|
for (var i = 0; i < links.length; i++) {
|
|
if (links[i].getAttribute('data-node-id') === activeId) {
|
|
links[i].closest('.tree-row').setAttribute('data-active', 'true');
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
"##;
|
|
|
|
/// MNOTE Wolai 风格页面布局
|
|
///
|
|
/// 包含左侧栏 + 内容区的双栏布局。
|
|
/// 侧栏显示品牌、导航链接和可选的页面树。
|
|
///
|
|
/// # 用法
|
|
///
|
|
/// ```ignore
|
|
/// view! {
|
|
/// <PageLayout current_nav="home" sidebar_tree_html={None}>
|
|
/// <section>...</section>
|
|
/// </PageLayout>
|
|
/// }
|
|
/// ```
|
|
#[component]
|
|
pub fn PageLayout(
|
|
children: Children,
|
|
current_nav: &'static str,
|
|
/// 侧栏页面树 HTML(可选),由路由 handler 渲染
|
|
#[prop(optional)]
|
|
sidebar_tree_html: Option<String>,
|
|
/// 工作区名称(可选),显示在侧栏顶部
|
|
#[prop(optional)]
|
|
workspace_name: Option<String>,
|
|
/// workspace shell 侧栏 sections HTML(可选),由 projection 渲染
|
|
#[prop(optional)]
|
|
workspace_sidebar_html: Option<String>,
|
|
/// 顶栏当前页面标题(可选)
|
|
#[prop(optional)]
|
|
topbar_title: Option<String>,
|
|
) -> impl IntoView {
|
|
let sidebar_tree_html = sidebar_tree_html.unwrap_or_default();
|
|
|
|
let ws_name = workspace_name
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| "开发用户 的空间".to_string());
|
|
let topbar_title = topbar_title
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| "个人".to_string());
|
|
let sidebar_sections_html = workspace_sidebar_html
|
|
.map(|value| value.trim().to_string())
|
|
.filter(|value| !value.is_empty())
|
|
.unwrap_or_else(|| {
|
|
let dataset = serde_json::json!({
|
|
"workspaces": [{ "id": "default", "name": ws_name.clone() }],
|
|
"documents": []
|
|
});
|
|
let projection = crate::workspace_shell::build_workspace_shell_projection(
|
|
&dataset, "default", None, &ws_name,
|
|
);
|
|
crate::workspace_shell::render_workspace_shell_sidebar_html(
|
|
&projection,
|
|
Some(sidebar_tree_html.as_str()),
|
|
None,
|
|
)
|
|
});
|
|
|
|
view! {
|
|
<div class="mnote-shell wolai-workspace-shell" data-mnote-web-owner="mnote-web" data-mnote-shell="workspace">
|
|
<aside class="mnote-sidebar wolai-sidebar" data-testid="wolai-sidebar">
|
|
<div class="mnote-sidebar-header wolai-sidebar-header" data-testid="wolai-workspace-identity">
|
|
<a href="/" class="mnote-sidebar-brand wolai-avatar" aria-label="工作区首页">"L"</a>
|
|
<span class="sidebar-workspace-name">{ws_name}</span>
|
|
<span class="wolai-sidebar-chevron" aria-hidden="true">"⌄"</span>
|
|
</div>
|
|
<nav class="mnote-sidebar-nav wolai-quick-actions" aria-label="快捷操作">
|
|
<a href="/search" class:active={current_nav == "search"} title="搜索"><span class="nav-icon">"⌕"</span></a>
|
|
<a href="/graph" title="关系图"><span class="nav-icon">"⌬"</span></a>
|
|
<a href="/actions" title="快捷动作"><span class="nav-icon">"↯"</span></a>
|
|
<a href="/inbox" title="收纳"><span class="nav-icon">"▣"</span></a>
|
|
<a href="/files" title="文件"><span class="nav-icon">"▱"</span></a>
|
|
<a href="/more" title="更多"><span class="nav-icon">"…"</span></a>
|
|
</nav>
|
|
<div class="wolai-sidebar-body" inner_html={sidebar_sections_html}></div>
|
|
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
|
</aside>
|
|
<div class="mnote-main">
|
|
<header class="wolai-topbar" data-testid="wolai-topbar">
|
|
<div class="wolai-topbar-left"><span class="wolai-menu-icon">"☰"</span><span class="wolai-home-icon">"⌂"</span><span>{topbar_title}</span></div>
|
|
<div class="wolai-topbar-actions" aria-label="页面操作">
|
|
<span title="收藏">"☆"</span>
|
|
<span title="演示">"▻"</span>
|
|
<span title="评论">"◴"</span>
|
|
<span title="关系">"⌬"</span>
|
|
<span title="成员">"♙+"</span>
|
|
<span title="历史">"◷"</span>
|
|
<span title="更多">"…"</span>
|
|
</div>
|
|
</header>
|
|
<article class="mnote-content">
|
|
{children()}
|
|
</article>
|
|
<div class="wolai-floating-actions" aria-label="浮动操作">
|
|
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button">"AI"</button>
|
|
<button type="button" data-testid="wolai-floating-help" class="wolai-floating-button">"?"</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|