Files
HomeDash/web/navi.js
HomeDash Bot da196abcb5 feat(web): 隐藏原生设置、圆形视图切换、工作空间改列表、三视图标题统一
用户提出的四条,都基于实测数据而不是猜测:

1) 隐藏 Dashy 原生设置入口
   顶栏的 .config-launcher(齿轮+铅笔+调色板)与我们的 /admin/ 重复,
   连同 .options-trigger/.settings-outer 一起隐藏。

2) 视图切换图标改成圆形,紧贴自定义入口左侧
   原生切换按钮是侧栏底部的方形图标(.switch-view-buttons),已隐藏;
   改为右下角圆形悬浮按钮 .navi-view-switch,点开是「首页/极简/工作空间」
   菜单并标出当前视图。
   顺带修掉 2px 错位:管理入口是内容盒(48+2px 边框=50),视图按钮是
   边框盒(48),并排时差 2px;给管理入口补 box-sizing: border-box。

3) 工作空间「点了没反应」
   读 Dashy 自己的 Workspace 组件(assets/Workspace-*.js)得到结论:
   右侧面板就是一个 iframe,launchApp() 只有 target 不是 newtab 才把 URL
   灌进面板;我们全局 openingMethod: newtab,所以每次点击都走 window.open
   分支,this.url 恒为空 → 面板永远空白。
   就算灌进去也没用:Gitea 实测 X-Frame-Options: sameorigin 拒绝被嵌
   (Refused to display … in a frame)。
   所以改成:点左侧分组 → 右侧列出该分组应用卡片(数据取自
   /admin/api/sections),不依赖目标站点是否允许 iframe,首页仍保持新标签页。

4) 三视图标题割裂
   实测 /minimal 是 48px 居中 -1.92px,而 / 与 /workspace 是顶栏里 40px
   左对齐、无字距。现在三个视图共用同一条规则(clamp(1.75rem,4vw,2.75rem)、
   700、-0.035em、居中;移动端 clamp(1.6rem,8vw,2.2rem)),副标题也居中置灰。

另外:工作空间面板原本用 position: fixed 钉在视口中间(1364×654,上距顶栏
7px、下距搜索栏 8px),把页面切成「顶栏/大方块/底部」三段;改为正常文档流,
内容多时页面自然滚动。
2026-09-11 18:17:23 +08:00

385 lines
14 KiB
JavaScript
Raw 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.

(() => {
'use strict';
// 浏览器插件在 document_start 阶段写入这个属性(见 extension/*/detect.js)。
const EXT_ATTR = 'data-navi-ext';
const INSTALL_SELECTOR = 'a[href*="/extension/install"]';
const SVG_NS = 'http://www.w3.org/2000/svg';
function svg(paths, opts = {}) {
const el = document.createElementNS(SVG_NS, 'svg');
el.setAttribute('viewBox', opts.viewBox || '0 0 24 24');
el.setAttribute('fill', 'none');
el.setAttribute('stroke', 'currentColor');
el.setAttribute('stroke-width', opts.width || '2.2');
el.setAttribute('stroke-linecap', 'round');
el.setAttribute('stroke-linejoin', 'round');
el.setAttribute('aria-hidden', 'true');
if (opts.size) {
el.setAttribute('width', opts.size);
el.setAttribute('height', opts.size);
}
paths.forEach((p) => {
const node = document.createElementNS(SVG_NS, p.tag);
Object.keys(p.attrs).forEach((k) => node.setAttribute(k, p.attrs[k]));
el.append(node);
});
return el;
}
// 收起态是放大镜,展开时通过 transform/opacity 平滑形变为向左箭头,
// 让图标动效与搜索框的宽度动画保持一致。
const magnifierIcon = () => svg([
{ tag: 'circle', attrs: { class: 'navi-magnifier-ring', cx: '11', cy: '11', r: '7.5' } },
{ tag: 'line', attrs: { class: 'navi-magnifier-handle', x1: '20.5', y1: '20.5', x2: '16.2', y2: '16.2' } },
{ tag: 'polyline', attrs: { class: 'navi-collapse-arrow', points: '14.5 6.5 9 12 14.5 17.5' } },
], { size: '20' });
const arrowIcon = () => svg([
{ tag: 'line', attrs: { x1: '5', y1: '12', x2: '19', y2: '12' } },
{ tag: 'polyline', attrs: { points: '13 6 19 12 13 18' } },
]);
const gearIcon = () => svg([
{ tag: 'circle', attrs: { cx: '12', cy: '12', r: '3.2' } },
{ tag: 'path', attrs: { d: 'M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 8.9 19.3a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.7 8.9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6h.09A1.7 1.7 0 0 0 10.6 3.04V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.03z' } },
], { size: '20', width: '1.9' });
function extensionInstalled() {
return document.documentElement.hasAttribute(EXT_ATTR);
}
// 已装插件的浏览器不需要安装入口:连外层网格单元一起隐藏,
// 否则 .item-wrapper 会留下一个空位。
function hideInstallEntry() {
if (!extensionInstalled()) return;
document.querySelectorAll(INSTALL_SELECTOR).forEach((link) => {
const cell = link.closest('.item-wrapper') || link.parentElement || link;
if (cell.style.display !== 'none') cell.style.display = 'none';
});
}
// 底部搜索栏:默认收起成一个放大镜悬浮球(左下角),
// 点放大镜展开成输入框,再点输入框里的放大镜收起。
function mountSearch() {
if (document.querySelector('.navi-search-bar')) return;
const form = document.createElement('form');
form.className = 'navi-search-bar';
form.dataset.state = 'collapsed';
form.method = 'GET';
form.action = '/search';
form.target = '_blank';
form.setAttribute('role', 'search');
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'navi-search-toggle';
toggle.setAttribute('aria-label', '展开搜索');
toggle.setAttribute('aria-expanded', 'false');
toggle.append(magnifierIcon());
const input = document.createElement('input');
input.type = 'search';
input.name = 'q';
input.placeholder = '搜索…';
input.autocomplete = 'off';
input.tabIndex = -1;
input.setAttribute('aria-label', '搜索内容');
const submit = document.createElement('button');
submit.type = 'submit';
submit.className = 'navi-search-submit';
submit.setAttribute('aria-label', '开始搜索');
submit.append(arrowIcon());
const setState = (state) => {
const open = state === 'open';
form.dataset.state = open ? 'open' : 'collapsed';
toggle.setAttribute('aria-expanded', String(open));
toggle.setAttribute('aria-label', open ? '收起搜索' : '展开搜索');
input.tabIndex = open ? 0 : -1;
if (open) {
input.focus({ preventScroll: true });
} else {
input.value = '';
input.blur();
}
};
toggle.addEventListener('click', (evt) => {
evt.preventDefault();
setState(form.dataset.state === 'open' ? 'collapsed' : 'open');
});
// Esc 收起(不阻止其它默认行为)
form.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape' && form.dataset.state === 'open') {
evt.stopPropagation();
setState('collapsed');
}
});
form.append(toggle, input, submit);
document.body.append(form);
}
// Dashy 页面没有管理入口:右下角补一个悬浮链接到 /admin/。
function mountAdminEntry() {
if (document.querySelector('.navi-admin-entry')) return;
const link = document.createElement('a');
link.className = 'navi-admin-entry';
link.href = '/admin/';
link.title = '导航管理';
link.setAttribute('aria-label', '导航管理');
link.append(gearIcon());
document.body.append(link);
}
// 视图切换:Dashy 原生的切换按钮挤在侧栏底部、且是方形图标。
// 这里换成圆形悬浮按钮,紧挨管理入口左侧,点开是一组视图链接。
const VIEWS = [
{ path: '/', label: '首页' },
{ path: '/minimal', label: '极简' },
{ path: '/workspace', label: '工作空间' },
];
const viewIcon = () => svg([
{ tag: 'rect', attrs: { x: '3', y: '3.5', width: '18', height: '17', rx: '3.4' } },
{ tag: 'line', attrs: { x1: '9.4', y1: '3.5', x2: '9.4', y2: '20.5' } },
{ tag: 'line', attrs: { x1: '12.8', y1: '9.4', x2: '17.8', y2: '9.4' } },
{ tag: 'line', attrs: { x1: '12.8', y1: '14.2', x2: '17.8', y2: '14.2' } },
], { size: '20', width: '1.9' });
function currentView() {
const p = location.pathname.replace(/\/+$/, '') || '/';
if (p.endsWith('/minimal')) return '/minimal';
if (p.endsWith('/workspace')) return '/workspace';
return '/';
}
function mountViewSwitch() {
if (document.querySelector('.navi-view-switch')) return;
const wrap = document.createElement('div');
wrap.className = 'navi-view-switch';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'navi-view-toggle';
btn.setAttribute('aria-label', '切换视图');
btn.setAttribute('aria-expanded', 'false');
btn.append(viewIcon());
const menu = document.createElement('div');
menu.className = 'navi-view-menu';
menu.hidden = true;
const active = currentView();
VIEWS.forEach((v) => {
const a = document.createElement('a');
a.className = 'navi-view-option';
a.href = v.path;
a.textContent = v.label;
if (v.path === active) {
a.classList.add('is-active');
a.setAttribute('aria-current', 'page');
}
menu.append(a);
});
const close = () => {
menu.hidden = true;
btn.setAttribute('aria-expanded', 'false');
};
btn.addEventListener('click', (evt) => {
evt.preventDefault();
evt.stopPropagation();
const open = menu.hidden;
menu.hidden = !open;
btn.setAttribute('aria-expanded', String(open));
});
document.addEventListener('click', (evt) => {
if (!menu.hidden && !wrap.contains(evt.target)) close();
});
document.addEventListener('keydown', (evt) => {
if (evt.key === 'Escape' && !menu.hidden) close();
});
wrap.append(btn, menu);
document.body.append(wrap);
}
// Dashy 的「工作空间」右侧面板是一个 iframe:侧栏点应用时把目标 URL
// 内嵌进去。但多数应用(Gitea、VanBlog 等)用 X-Frame-Options 拒绝被嵌,
// 面板就是一片空白(实测:Refused to display … 'sameorigin')。
// 所以这里改成:点左侧分组 → 右侧列出该分组的应用卡片,不依赖内嵌。
const WS_ICON_RE = /\.(png|svg|jpe?g|webp|gif|ico)$/i;
let wsSections = null;
let wsPending = false;
async function fetchWorkspaceSections() {
if (wsSections) return wsSections;
if (wsPending) return null;
wsPending = true;
try {
const res = await fetch('/admin/api/sections', { credentials: 'same-origin' });
if (!res.ok) return null;
const data = await res.json();
const list = Array.isArray(data) ? data : (data.sections || []);
// 与 Dashy 侧栏保持同序:剔除标记了 hideFromWorkspace 的分组。
wsSections = list.filter((s) => !(s && s.displayData && s.displayData.hideFromWorkspace));
return wsSections;
} catch (err) {
return null;
} finally {
wsPending = false;
}
}
function workspacePane() {
const host = document.querySelector('.work-space');
if (!host) return null;
let pane = host.querySelector('.navi-ws-pane');
if (!pane) {
pane = document.createElement('div');
pane.className = 'navi-ws-pane';
host.append(pane);
}
return pane;
}
function renderWorkspaceSection(section) {
const pane = workspacePane();
if (!pane || !section) return;
while (pane.firstChild) pane.removeChild(pane.firstChild);
const title = document.createElement('h2');
title.className = 'navi-ws-title';
title.textContent = section.name || '';
pane.append(title);
const items = (section.items || []).filter((it) => it && it.url);
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'navi-ws-hint';
empty.textContent = '这个分组还没有项目';
pane.append(empty);
return;
}
const grid = document.createElement('div');
grid.className = 'navi-ws-grid';
items.forEach((it) => {
const card = document.createElement('a');
card.className = 'navi-ws-card';
card.href = it.url;
card.target = '_blank';
card.rel = 'noopener noreferrer';
const icon = String(it.icon || '');
if (WS_ICON_RE.test(icon)) {
const wrap = document.createElement('span');
wrap.className = 'navi-ws-card-icon';
const img = document.createElement('img');
img.src = '/item-icons/' + icon;
img.alt = '';
img.loading = 'lazy';
wrap.append(img);
card.append(wrap);
}
const name = document.createElement('span');
name.className = 'navi-ws-card-title';
name.textContent = it.title || it.url;
card.append(name);
const sub = document.createElement('span');
sub.className = 'navi-ws-card-url';
sub.textContent = String(it.description || '').trim()
|| it.url.replace(/^https?:\/\//, '').replace(/\/$/, '');
card.append(sub);
grid.append(card);
});
pane.append(grid);
}
function enableWorkspacePane() {
if (window.__naviWorkspacePane) return;
window.__naviWorkspacePane = true;
document.addEventListener('click', async (evt) => {
if (!/^\/workspace/.test(location.pathname)) return;
const target = evt.target;
const head = target && target.closest ? target.closest('.side-bar-item-container') : null;
if (!head) return;
const sections = await fetchWorkspaceSections();
if (!sections || !sections.length) return;
const heads = Array.from(document.querySelectorAll('nav.side-bar .side-bar-item-container'));
const idx = heads.indexOf(head);
if (idx >= 0 && sections[idx]) renderWorkspaceSection(sections[idx]);
});
}
// 工作空间初次打开时先把第一个分组的内容摆上,避免右侧一片空白。
let wsSeeded = false;
async function seedWorkspacePane() {
if (wsSeeded) return;
if (!/^\/workspace/.test(location.pathname)) return;
if (!document.querySelector('.work-space')) return;
wsSeeded = true;
const sections = await fetchWorkspaceSections();
if (sections && sections.length) renderWorkspaceSection(sections[0]);
}
// 极简视图(/minimal)与工作空间视图不渲染 Dashy 页脚,备案号会整段消失。
// 这里从只读的 pageinfo 取回页脚 HTML 补一份,保证每个视图都能看到备案信息。
let footerPending = false;
async function mountFooter() {
if (footerPending || document.querySelector('.navi-footer')) return;
footerPending = true;
try {
const res = await fetch('/admin/api/pageinfo', { credentials: 'same-origin' });
if (!res.ok) return;
const info = await res.json();
const html = (info && info.footer) || '';
if (!html) return;
// 用 DOMParser 解析(不用 innerHTML),只取 .navi-footer 节点。
const parsed = new DOMParser().parseFromString(html, 'text/html');
const node = parsed.querySelector('.navi-footer');
if (!node || document.querySelector('.navi-footer')) return;
const host = document.createElement('div');
host.className = 'navi-footer-host';
host.append(node);
const bar = document.querySelector('.navi-search-bar');
if (bar && bar.parentNode) bar.parentNode.insertBefore(host, bar);
else document.body.append(host);
} catch (err) {
// 补页脚失败不影响搜索等主功能
} finally {
footerPending = false;
}
}
function run() {
mountSearch();
mountAdminEntry();
mountViewSwitch();
enableWorkspacePane();
seedWorkspacePane();
hideInstallEntry();
mountFooter();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run, { once: true });
} else {
run();
}
new MutationObserver(run).observe(document.documentElement, { childList: true, subtree: true });
})();