用户提出的四条,都基于实测数据而不是猜测: 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),把页面切成「顶栏/大方块/底部」三段;改为正常文档流, 内容多时页面自然滚动。
385 lines
14 KiB
JavaScript
385 lines
14 KiB
JavaScript
(() => {
|
||
'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 });
|
||
})();
|