Rollback to upstream-obv (v0.1 effective)
Vier Customization-Iterationen (v0.2 bis v0.5.1) haben auf Mobile (iOS Safari) und Chrome Desktop jeweils neue Probleme verursacht statt sie zu lösen. Rollback entfernt: - vault.html: Script-Tag für vault-custom.js entfernt (upstream-original) - server.py: ?file= query-Patch entfernt (upstream-original) - vault-custom.js: leerer Stub mit Comment (Customizations deaktiviert) - hermes-custom.css: leerer Stub (CSS deaktiviert) Was bleibt: - Repo-Struktur (README, CHANGELOG, ARCHITECTURE, CUSTOMIZATIONS, scripts/) - Documented history der Customization-Iterationen für späteres Re-Audit - Backup-Dateien: *.lukas (alle Original-Patches vor diesem Commit) Begründung: Lukas braucht ein verlässlich funktionierendes Wiki, nicht endlose Problemlösungs-Schleifen. Basis muss stimmen, bevor neue Features ausprobiert werden. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+1
-249
@@ -1,249 +1 @@
|
|||||||
/* HermesCustom CSS — Lukas' responsive-layout + Mobile-Toggle-Pattern.
|
/* placeholder */
|
||||||
*
|
|
||||||
* Loaded via <link rel="stylesheet"> in vault.html.
|
|
||||||
* Applied at first paint (no FOUC).
|
|
||||||
*
|
|
||||||
* LAYOUT PHILOSOPHY (v0.5):
|
|
||||||
* - Desktop ≥1025px: 3-panel layout, tree visible, graph visible
|
|
||||||
* - Tablet 600-1024px: 2-panel compact, tree visible OR graph visible
|
|
||||||
* via "Show Graph" button toggle (starts hidden)
|
|
||||||
* - Mobile <600px: single column, tree as hamburger, graph as FAB → modal
|
|
||||||
*
|
|
||||||
* The key UX change vs v0.4: Graph is NEVER in bottom panel on Mobile
|
|
||||||
* (waste of vertical space). It's a toggle button → full-screen modal.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* === Always-on === */
|
|
||||||
:root {
|
|
||||||
/* iOS safe-area inset variables for cross-browser usage */
|
|
||||||
--sat: env(safe-area-inset-top, 0px);
|
|
||||||
--sab: env(safe-area-inset-bottom, 0px);
|
|
||||||
--sal: env(safe-area-inset-left, 0px);
|
|
||||||
--sar: env(safe-area-inset-right, 0px);
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
/* Prevent iOS from auto-shrinking — keep our viewport stable */
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vault-app.hermes-rc,
|
|
||||||
.vault-app.hermes-rc * {
|
|
||||||
box-sizing: border-box !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc {
|
|
||||||
/* Use dvh (dynamic viewport height) with vh fallback. dvh excludes iOS
|
|
||||||
URL-bar from the 100vh, so the app fills the visible area.
|
|
||||||
Padding-top pushes the grid below the iOS status bar / Dynamic Island. */
|
|
||||||
width: 100% !important;
|
|
||||||
max-width: 100vw !important;
|
|
||||||
height: 100vh !important; /* fallback */
|
|
||||||
height: 100dvh !important; /* modern browsers (incl. iOS 16+) */
|
|
||||||
padding-top: var(--sat) !important;
|
|
||||||
padding-left: var(--sal) !important;
|
|
||||||
padding-right: var(--sar) !important;
|
|
||||||
padding-bottom: var(--sab) !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc .vault-content {
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
overflow-y: auto !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
word-wrap: break-word !important;
|
|
||||||
overflow-wrap: break-word !important;
|
|
||||||
hyphens: auto !important;
|
|
||||||
min-width: 0 !important;
|
|
||||||
}
|
|
||||||
/* Critical: prevent pre/code blocks from overflowing horizontally */
|
|
||||||
.vault-app.hermes-rc .vault-content pre,
|
|
||||||
.vault-app.hermes-rc .vault-content code {
|
|
||||||
white-space: pre-wrap !important;
|
|
||||||
word-break: break-word !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
overflow-x: auto !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc .vault-content table {
|
|
||||||
display: block !important;
|
|
||||||
overflow-x: auto !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc .vault-content img {
|
|
||||||
max-width: 100% !important;
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc .vault-sidebar {
|
|
||||||
overflow-x: hidden !important;
|
|
||||||
overflow-y: auto !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc .vault-graph {
|
|
||||||
position: relative !important;
|
|
||||||
overflow: hidden !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === HermescCustom UI Elements === */
|
|
||||||
.hermes-ui-btn {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0 4px;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.hermes-ui-btn:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hamburger button (hidden by default, shown when tree hidden) */
|
|
||||||
.hermes-tree-toggle,
|
|
||||||
.hermes-graph-toggle {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.hermes-tree-toggle svg,
|
|
||||||
.hermes-graph-toggle svg {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Graph Modal (mobile graph fullscreen overlay) */
|
|
||||||
.hermes-graph-modal {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 48px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
z-index: 2000;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.hermes-graph-modal.hermes-modal-open {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
.hermes-graph-modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
height: 44px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.hermes-graph-modal-title {
|
|
||||||
color: var(--accent);
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.hermes-graph-modal-close {
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.hermes-graph-modal-close:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.hermes-graph-modal-canvas {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hermes-graph-modal-canvas canvas {
|
|
||||||
display: block;
|
|
||||||
width: 100% !important;
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar backdrop (click-outside-to-close) */
|
|
||||||
.hermes-sidebar-backdrop {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 48px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 900;
|
|
||||||
}
|
|
||||||
.hermes-backdrop-visible {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Compact Mode (≤1024px) — used for Tablet + Mobile === */
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.vault-app.hermes-rc.hermes-compact {
|
|
||||||
grid-template-columns: 220px 1fr !important;
|
|
||||||
grid-template-rows: 48px 1fr !important;
|
|
||||||
height: 100vh !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc.hermes-compact .vault-sidebar {
|
|
||||||
width: 220px !important;
|
|
||||||
max-width: 220px !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc.hermes-compact .vault-graph {
|
|
||||||
display: none !important; /* Hidden by default on compact */
|
|
||||||
}
|
|
||||||
/* Show tree-toggle in compact (tree is shown by default; toggle if user wants to hide) */
|
|
||||||
.vault-app.hermes-rc.hermes-compact .hermes-tree-toggle {
|
|
||||||
display: inline-block !important;
|
|
||||||
}
|
|
||||||
/* Show graph-toggle in compact */
|
|
||||||
.vault-app.hermes-rc.hermes-compact .hermes-graph-toggle {
|
|
||||||
display: inline-block !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Mobile Mode (≤600px) — Full mobile UX === */
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.vault-app.hermes-rc.hermes-mobile {
|
|
||||||
grid-template-columns: 1fr !important;
|
|
||||||
grid-template-rows: 48px 1fr !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc.hermes-mobile .vault-sidebar {
|
|
||||||
position: fixed !important;
|
|
||||||
top: 48px !important;
|
|
||||||
left: 0 !important;
|
|
||||||
bottom: 0 !important;
|
|
||||||
width: 85vw !important;
|
|
||||||
max-width: 320px !important;
|
|
||||||
transform: translateX(-100%) !important;
|
|
||||||
transition: transform 0.25s ease-in-out !important;
|
|
||||||
z-index: 1000 !important;
|
|
||||||
background: var(--bg-secondary) !important;
|
|
||||||
border-right: 1px solid var(--border) !important;
|
|
||||||
display: block !important;
|
|
||||||
height: calc(100vh - 48px) !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc.hermes-mobile .vault-sidebar.hermes-sidebar-open {
|
|
||||||
transform: translateX(0) !important;
|
|
||||||
}
|
|
||||||
.vault-app.hermes-rc.hermes-mobile .vault-content {
|
|
||||||
grid-column: 1 !important;
|
|
||||||
grid-row: 2 !important;
|
|
||||||
padding: 14px 16px !important;
|
|
||||||
max-width: 100% !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* === Desktop (>=1025px) — default obv layout, no overrides === */
|
|
||||||
@media (min-width: 1025px) {
|
|
||||||
.vault-app.hermes-rc.hermes-desktop .hermes-tree-toggle,
|
|
||||||
.vault-app.hermes-rc.hermes-desktop .hermes-graph-toggle {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/* HermesCustom CSS — Lukas' responsive-layout + Mobile-Toggle-Pattern.
|
||||||
|
*
|
||||||
|
* Loaded via <link rel="stylesheet"> in vault.html.
|
||||||
|
* Applied at first paint (no FOUC).
|
||||||
|
*
|
||||||
|
* LAYOUT PHILOSOPHY (v0.5):
|
||||||
|
* - Desktop ≥1025px: 3-panel layout, tree visible, graph visible
|
||||||
|
* - Tablet 600-1024px: 2-panel compact, tree visible OR graph visible
|
||||||
|
* via "Show Graph" button toggle (starts hidden)
|
||||||
|
* - Mobile <600px: single column, tree as hamburger, graph as FAB → modal
|
||||||
|
*
|
||||||
|
* The key UX change vs v0.4: Graph is NEVER in bottom panel on Mobile
|
||||||
|
* (waste of vertical space). It's a toggle button → full-screen modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* === Always-on === */
|
||||||
|
:root {
|
||||||
|
/* iOS safe-area inset variables for cross-browser usage */
|
||||||
|
--sat: env(safe-area-inset-top, 0px);
|
||||||
|
--sab: env(safe-area-inset-bottom, 0px);
|
||||||
|
--sal: env(safe-area-inset-left, 0px);
|
||||||
|
--sar: env(safe-area-inset-right, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
/* Prevent iOS from auto-shrinking — keep our viewport stable */
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-app.hermes-rc,
|
||||||
|
.vault-app.hermes-rc * {
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc {
|
||||||
|
/* Use dvh (dynamic viewport height) with vh fallback. dvh excludes iOS
|
||||||
|
URL-bar from the 100vh, so the app fills the visible area.
|
||||||
|
Padding-top pushes the grid below the iOS status bar / Dynamic Island. */
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100vw !important;
|
||||||
|
height: 100vh !important; /* fallback */
|
||||||
|
height: 100dvh !important; /* modern browsers (incl. iOS 16+) */
|
||||||
|
padding-top: var(--sat) !important;
|
||||||
|
padding-left: var(--sal) !important;
|
||||||
|
padding-right: var(--sar) !important;
|
||||||
|
padding-bottom: var(--sab) !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc .vault-content {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
word-wrap: break-word !important;
|
||||||
|
overflow-wrap: break-word !important;
|
||||||
|
hyphens: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
/* Critical: prevent pre/code blocks from overflowing horizontally */
|
||||||
|
.vault-app.hermes-rc .vault-content pre,
|
||||||
|
.vault-app.hermes-rc .vault-content code {
|
||||||
|
white-space: pre-wrap !important;
|
||||||
|
word-break: break-word !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc .vault-content table {
|
||||||
|
display: block !important;
|
||||||
|
overflow-x: auto !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc .vault-content img {
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc .vault-sidebar {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc .vault-graph {
|
||||||
|
position: relative !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === HermescCustom UI Elements === */
|
||||||
|
.hermes-ui-btn {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0 4px;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.hermes-ui-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger button (hidden by default, shown when tree hidden) */
|
||||||
|
.hermes-tree-toggle,
|
||||||
|
.hermes-graph-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.hermes-tree-toggle svg,
|
||||||
|
.hermes-graph-toggle svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Graph Modal (mobile graph fullscreen overlay) */
|
||||||
|
.hermes-graph-modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 48px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
z-index: 2000;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.hermes-graph-modal.hermes-modal-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.hermes-graph-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.hermes-graph-modal-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.hermes-graph-modal-close {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.hermes-graph-modal-close:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.hermes-graph-modal-canvas {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hermes-graph-modal-canvas canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar backdrop (click-outside-to-close) */
|
||||||
|
.hermes-sidebar-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 48px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 900;
|
||||||
|
}
|
||||||
|
.hermes-backdrop-visible {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Compact Mode (≤1024px) — used for Tablet + Mobile === */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.vault-app.hermes-rc.hermes-compact {
|
||||||
|
grid-template-columns: 220px 1fr !important;
|
||||||
|
grid-template-rows: 48px 1fr !important;
|
||||||
|
height: 100vh !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc.hermes-compact .vault-sidebar {
|
||||||
|
width: 220px !important;
|
||||||
|
max-width: 220px !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc.hermes-compact .vault-graph {
|
||||||
|
display: none !important; /* Hidden by default on compact */
|
||||||
|
}
|
||||||
|
/* Show tree-toggle in compact (tree is shown by default; toggle if user wants to hide) */
|
||||||
|
.vault-app.hermes-rc.hermes-compact .hermes-tree-toggle {
|
||||||
|
display: inline-block !important;
|
||||||
|
}
|
||||||
|
/* Show graph-toggle in compact */
|
||||||
|
.vault-app.hermes-rc.hermes-compact .hermes-graph-toggle {
|
||||||
|
display: inline-block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Mobile Mode (≤600px) — Full mobile UX === */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.vault-app.hermes-rc.hermes-mobile {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
grid-template-rows: 48px 1fr !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc.hermes-mobile .vault-sidebar {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 48px !important;
|
||||||
|
left: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
width: 85vw !important;
|
||||||
|
max-width: 320px !important;
|
||||||
|
transform: translateX(-100%) !important;
|
||||||
|
transition: transform 0.25s ease-in-out !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
background: var(--bg-secondary) !important;
|
||||||
|
border-right: 1px solid var(--border) !important;
|
||||||
|
display: block !important;
|
||||||
|
height: calc(100vh - 48px) !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc.hermes-mobile .vault-sidebar.hermes-sidebar-open {
|
||||||
|
transform: translateX(0) !important;
|
||||||
|
}
|
||||||
|
.vault-app.hermes-rc.hermes-mobile .vault-content {
|
||||||
|
grid-column: 1 !important;
|
||||||
|
grid-row: 2 !important;
|
||||||
|
padding: 14px 16px !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Desktop (>=1025px) — default obv layout, no overrides === */
|
||||||
|
@media (min-width: 1025px) {
|
||||||
|
.vault-app.hermes-rc.hermes-desktop .hermes-tree-toggle,
|
||||||
|
.vault-app.hermes-rc.hermes-desktop .hermes-graph-toggle {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,17 +217,8 @@ class VaultHandler(BaseHTTPRequestHandler):
|
|||||||
params = urllib.parse.parse_qs(query_string)
|
params = urllib.parse.parse_qs(query_string)
|
||||||
q = params.get("q", [""])[0]
|
q = params.get("q", [""])[0]
|
||||||
self._json({"results": _search(VAULT_DIR, q) if q else []})
|
self._json({"results": _search(VAULT_DIR, q) if q else []})
|
||||||
elif path == "/api/vault/file" or path.startswith("/api/vault/file/"):
|
elif path.startswith("/api/vault/file/"):
|
||||||
if path == "/api/vault/file":
|
rel = unquote(path[len("/api/vault/file/"):])
|
||||||
rel = ""
|
|
||||||
else:
|
|
||||||
rel = unquote(path[len("/api/vault/file/"):])
|
|
||||||
# Lukas-add: also support ?file=<path> query for clean URL navigation
|
|
||||||
if not rel and query_string:
|
|
||||||
from urllib.parse import parse_qs
|
|
||||||
file_param = parse_qs(query_string).get("file", [None])[0]
|
|
||||||
if file_param:
|
|
||||||
rel = file_param
|
|
||||||
file_path = VAULT_DIR / rel
|
file_path = VAULT_DIR / rel
|
||||||
if not file_path.exists() or not str(file_path).startswith(str(VAULT_DIR)):
|
if not file_path.exists() or not str(file_path).startswith(str(VAULT_DIR)):
|
||||||
self._json({"error": "Not found"}, 404)
|
self._json({"error": "Not found"}, 404)
|
||||||
|
|||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
Obsidian Web Viewer — Browse Your Vault in the Browser
|
||||||
|
========================================================
|
||||||
|
Web-based Obsidian vault browser with 3D graph, file tree, markdown
|
||||||
|
rendering, wikilink navigation, and full-text search.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python server.py --vault /path/to/your/vault
|
||||||
|
python server.py --vault ~/Documents/MyVault --port 8080
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import unquote, quote
|
||||||
|
|
||||||
|
if sys.platform == 'win32':
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
VAULT_DIR = None # Set via --vault CLI arg
|
||||||
|
|
||||||
|
# YAML parsing (optional)
|
||||||
|
_YAML_AVAILABLE = False
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
_YAML_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
MIME = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css",
|
||||||
|
".js": "application/javascript",
|
||||||
|
".json": "application/json",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache for vault tree and graph
|
||||||
|
_cache = {"tree": None, "graph": None, "ts": 0}
|
||||||
|
_CACHE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tree(vault_dir: Path) -> dict:
|
||||||
|
"""Build a JSON file tree of the vault directory."""
|
||||||
|
now = time.time()
|
||||||
|
if _cache["tree"] and (now - _cache["ts"]) < _CACHE_TTL:
|
||||||
|
return _cache["tree"]
|
||||||
|
|
||||||
|
def _walk(d: Path) -> dict:
|
||||||
|
node = {"type": "folder", "name": d.name, "children": []}
|
||||||
|
try:
|
||||||
|
entries = sorted(d.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
|
||||||
|
except PermissionError:
|
||||||
|
return node
|
||||||
|
for entry in entries:
|
||||||
|
if entry.name.startswith('.') or entry.name.startswith('_'):
|
||||||
|
continue
|
||||||
|
if entry.is_dir():
|
||||||
|
child = _walk(entry)
|
||||||
|
if child["children"]:
|
||||||
|
node["children"].append(child)
|
||||||
|
elif entry.suffix.lower() == '.md':
|
||||||
|
node["children"].append({"type": "file", "name": entry.name})
|
||||||
|
return node
|
||||||
|
|
||||||
|
if not vault_dir.exists():
|
||||||
|
return {"type": "folder", "name": "", "children": []}
|
||||||
|
tree = _walk(vault_dir)
|
||||||
|
tree["name"] = ""
|
||||||
|
_cache["tree"] = tree
|
||||||
|
_cache["ts"] = time.time()
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_frontmatter(raw: str) -> tuple:
|
||||||
|
"""Parse YAML frontmatter from markdown. Returns (dict, body_str)."""
|
||||||
|
if not raw.startswith('---'):
|
||||||
|
return {}, raw
|
||||||
|
end = raw.find('---', 3)
|
||||||
|
if end == -1:
|
||||||
|
return {}, raw
|
||||||
|
fm_text = raw[3:end].strip()
|
||||||
|
body = raw[end + 3:].strip()
|
||||||
|
fm = {}
|
||||||
|
if _YAML_AVAILABLE:
|
||||||
|
try:
|
||||||
|
fm = yaml.safe_load(fm_text) or {}
|
||||||
|
if not isinstance(fm, dict):
|
||||||
|
fm = {"value": fm}
|
||||||
|
except Exception:
|
||||||
|
fm = {}
|
||||||
|
else:
|
||||||
|
for line in fm_text.splitlines():
|
||||||
|
if ':' in line:
|
||||||
|
k, _, v = line.partition(':')
|
||||||
|
v = v.strip().strip('"').strip("'")
|
||||||
|
if v.startswith('[') and v.endswith(']'):
|
||||||
|
v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(',') if x.strip()]
|
||||||
|
fm[k.strip()] = v
|
||||||
|
return fm, body
|
||||||
|
|
||||||
|
|
||||||
|
def _search(vault_dir: Path, query: str, max_results: int = 30) -> list:
|
||||||
|
"""Search vault files by name and content."""
|
||||||
|
results = []
|
||||||
|
query_lower = query.lower()
|
||||||
|
for md_file in vault_dir.rglob("*.md"):
|
||||||
|
if md_file.name.startswith('.') or md_file.name.startswith('_'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rel = md_file.relative_to(vault_dir).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
name = md_file.stem
|
||||||
|
name_match = query_lower in name.lower()
|
||||||
|
snippet = ""
|
||||||
|
content_match = False
|
||||||
|
if not name_match:
|
||||||
|
try:
|
||||||
|
text = md_file.read_text(encoding="utf-8", errors="ignore")[:5000]
|
||||||
|
idx = text.lower().find(query_lower)
|
||||||
|
if idx >= 0:
|
||||||
|
content_match = True
|
||||||
|
start = max(0, idx - 40)
|
||||||
|
end = min(len(text), idx + len(query) + 60)
|
||||||
|
snippet = ("..." if start > 0 else "") + text[start:end].replace("\n", " ") + ("..." if end < len(text) else "")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if name_match or content_match:
|
||||||
|
results.append({
|
||||||
|
"path": rel, "name": name,
|
||||||
|
"folder": str(md_file.parent.relative_to(vault_dir)) if md_file.parent != vault_dir else "",
|
||||||
|
"snippet": snippet, "name_match": name_match,
|
||||||
|
})
|
||||||
|
if len(results) >= max_results:
|
||||||
|
break
|
||||||
|
results.sort(key=lambda r: (not r["name_match"], r["name"].lower()))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _build_graph(vault_dir: Path) -> dict:
|
||||||
|
"""Build a graph of wikilink connections between vault notes."""
|
||||||
|
if _cache["graph"] and (time.time() - _cache["ts"]) < _CACHE_TTL:
|
||||||
|
return _cache["graph"]
|
||||||
|
|
||||||
|
wikilink_re = re.compile(r'\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]')
|
||||||
|
nodes = []
|
||||||
|
edges = []
|
||||||
|
name_to_path = {}
|
||||||
|
all_paths = []
|
||||||
|
|
||||||
|
for md_file in vault_dir.rglob("*.md"):
|
||||||
|
if md_file.name.startswith('.') or md_file.name.startswith('_'):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rel = md_file.relative_to(vault_dir).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
all_paths.append((rel, md_file))
|
||||||
|
name_to_path[md_file.stem.lower()] = rel
|
||||||
|
|
||||||
|
for rel, md_file in all_paths:
|
||||||
|
try:
|
||||||
|
text = md_file.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
# Classify by folder for coloring
|
||||||
|
rp = rel.lower()
|
||||||
|
node_type = "other"
|
||||||
|
for folder in ["wiki", "reference", "daily", "journal", "notes", "projects"]:
|
||||||
|
if rp.startswith(folder + "/"):
|
||||||
|
node_type = folder
|
||||||
|
break
|
||||||
|
|
||||||
|
nodes.append({"id": rel, "label": md_file.stem, "type": node_type})
|
||||||
|
links = wikilink_re.findall(text)
|
||||||
|
seen = set()
|
||||||
|
for target in links:
|
||||||
|
resolved = name_to_path.get(target.strip().lower())
|
||||||
|
if resolved and resolved != rel and resolved not in seen:
|
||||||
|
edges.append({"source": rel, "target": resolved})
|
||||||
|
seen.add(resolved)
|
||||||
|
|
||||||
|
result = {"nodes": nodes, "edges": edges}
|
||||||
|
_cache["graph"] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class VaultHandler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a): pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = self.path.split("?")[0].rstrip("/")
|
||||||
|
query_string = self.path.split("?")[1] if "?" in self.path else ""
|
||||||
|
|
||||||
|
if path == "" or path == "/" or path == "/vault":
|
||||||
|
self._serve_file(ROOT / "vault.html")
|
||||||
|
elif path == "/api/vault/tree":
|
||||||
|
self._json(_build_tree(VAULT_DIR))
|
||||||
|
elif path == "/api/vault/graph":
|
||||||
|
self._json(_build_graph(VAULT_DIR))
|
||||||
|
elif path == "/api/vault/search":
|
||||||
|
import urllib.parse
|
||||||
|
params = urllib.parse.parse_qs(query_string)
|
||||||
|
q = params.get("q", [""])[0]
|
||||||
|
self._json({"results": _search(VAULT_DIR, q) if q else []})
|
||||||
|
elif path == "/api/vault/file" or path.startswith("/api/vault/file/"):
|
||||||
|
if path == "/api/vault/file":
|
||||||
|
rel = ""
|
||||||
|
else:
|
||||||
|
rel = unquote(path[len("/api/vault/file/"):])
|
||||||
|
# Lukas-add: also support ?file=<path> query for clean URL navigation
|
||||||
|
if not rel and query_string:
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
file_param = parse_qs(query_string).get("file", [None])[0]
|
||||||
|
if file_param:
|
||||||
|
rel = file_param
|
||||||
|
file_path = VAULT_DIR / rel
|
||||||
|
if not file_path.exists() or not str(file_path).startswith(str(VAULT_DIR)):
|
||||||
|
self._json({"error": "Not found"}, 404)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
raw = file_path.read_text(encoding="utf-8")
|
||||||
|
fm, body = _parse_frontmatter(raw)
|
||||||
|
self._json({"path": rel, "name": file_path.stem, "frontmatter": fm, "body": body})
|
||||||
|
except Exception as e:
|
||||||
|
self._json({"error": str(e)}, 500)
|
||||||
|
elif path == "/config.json":
|
||||||
|
self._serve_file(ROOT / "config.json")
|
||||||
|
else:
|
||||||
|
f = ROOT / path.lstrip("/")
|
||||||
|
if f.exists() and f.is_file():
|
||||||
|
self._serve_file(f)
|
||||||
|
else:
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def _serve_file(self, path):
|
||||||
|
if not path.exists():
|
||||||
|
self.send_error(404); return
|
||||||
|
data = path.read_bytes()
|
||||||
|
mime = MIME.get(path.suffix.lower(), "application/octet-stream")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", mime)
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
def _json(self, obj, code=200):
|
||||||
|
data = json.dumps(obj, indent=2, default=str).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Cache-Control", "no-cache")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Obsidian Web Viewer")
|
||||||
|
parser.add_argument("--vault", required=True, help="Path to your Obsidian vault folder")
|
||||||
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
|
parser.add_argument("--host", default="0.0.0.0")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
global VAULT_DIR
|
||||||
|
VAULT_DIR = Path(args.vault).resolve()
|
||||||
|
|
||||||
|
if not VAULT_DIR.exists():
|
||||||
|
print(f"ERROR: Vault path does not exist: {VAULT_DIR}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
md_count = sum(1 for _ in VAULT_DIR.rglob("*.md"))
|
||||||
|
print(f"Obsidian Web Viewer")
|
||||||
|
print(f" Vault: {VAULT_DIR}")
|
||||||
|
print(f" Notes: {md_count}")
|
||||||
|
print(f" URL: http://{args.host}:{args.port}")
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer((args.host, args.port), VaultHandler)
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nShutting down.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+1
-453
@@ -1,453 +1 @@
|
|||||||
/* HermesCustom — Lukas' additions to obsidian-web-viewer
|
/* Hermes Customizations disabled — rollback to v0.1 (upstream obv) */
|
||||||
*
|
|
||||||
* Loaded by vault.html via <script> tag (one-line patch).
|
|
||||||
*
|
|
||||||
* v0.5 — Mobile/Responsive overhaul:
|
|
||||||
* - Graph is NEVER a permanent bottom panel (waste of vertical space on phones)
|
|
||||||
* - Graph shown via full-screen modal toggle on Mobile + Tablet
|
|
||||||
* - Tree as hamburger on Mobile, full sidebar on Tablet
|
|
||||||
* - Desktop unchanged
|
|
||||||
*
|
|
||||||
* Companion: hermes-custom.css
|
|
||||||
*
|
|
||||||
* Layout decisions per viewport:
|
|
||||||
* Desktop (>=1025px): 3-panel, obv default, both toggles hidden
|
|
||||||
* Compact (601-1024px): 2-panel (tree+content), graph hidden by default,
|
|
||||||
* "Show Graph" button visible, "Tree" toggle visible
|
|
||||||
* Mobile (<=600px): 1-panel (content only), tree hamburger, graph modal
|
|
||||||
*/
|
|
||||||
(function() {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
window.HermesCustom = window.HermesCustom || {};
|
|
||||||
const HermesCustom = window.HermesCustom;
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Config
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
const BREAKPOINTS = {
|
|
||||||
mobile: 600,
|
|
||||||
compact: 1024,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 1. UI elements (injected into header)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inject toggle buttons into header.
|
|
||||||
* - Tree toggle: only visible when tree is hidden (Compact mode hides tree by default)
|
|
||||||
* - Graph toggle: only visible when graph is hidden (Compact + Mobile)
|
|
||||||
* - Search input is already in obv's header; we don't duplicate
|
|
||||||
*/
|
|
||||||
HermesCustom.injectUI = function() {
|
|
||||||
const header = document.querySelector('.vault-header');
|
|
||||||
if (!header) {
|
|
||||||
setTimeout(HermesCustom.injectUI, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph toggle button (top-right of header, before search)
|
|
||||||
if (!document.querySelector('.hermes-graph-toggle')) {
|
|
||||||
const graphBtn = document.createElement('button');
|
|
||||||
graphBtn.className = 'hermes-graph-toggle hermes-ui-btn';
|
|
||||||
graphBtn.setAttribute('aria-label', 'Toggle 3D graph');
|
|
||||||
graphBtn.innerHTML = '⊕ Graph';
|
|
||||||
graphBtn.onclick = () => HermesCustom.toggleGraphModal();
|
|
||||||
header.insertBefore(graphBtn, header.querySelector('.search-box') || header.lastChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tree toggle button (left-most in header)
|
|
||||||
if (!document.querySelector('.hermes-tree-toggle')) {
|
|
||||||
const treeBtn = document.createElement('button');
|
|
||||||
treeBtn.className = 'hermes-tree-toggle hermes-ui-btn';
|
|
||||||
treeBtn.setAttribute('aria-label', 'Toggle file tree');
|
|
||||||
treeBtn.innerHTML = '☰';
|
|
||||||
treeBtn.onclick = () => HermesCustom.toggleTreeSidebar();
|
|
||||||
header.insertBefore(treeBtn, header.firstChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backdrop for tree sidebar
|
|
||||||
if (!document.querySelector('.hermes-sidebar-backdrop')) {
|
|
||||||
const bd = document.createElement('div');
|
|
||||||
bd.className = 'hermes-sidebar-backdrop';
|
|
||||||
bd.onclick = () => HermesCustom.closeTreeSidebar();
|
|
||||||
document.body.appendChild(bd);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graph modal (hidden by default)
|
|
||||||
if (!document.querySelector('.hermes-graph-modal')) {
|
|
||||||
HermesCustom.buildGraphModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the graph modal: full-screen overlay that re-uses the
|
|
||||||
* existing canvas from obv. Strategy: clone the canvas into our modal,
|
|
||||||
* or move the existing canvas in/out. Simpler approach: create a NEW
|
|
||||||
* canvas inside the modal, and re-run the graph render there.
|
|
||||||
*/
|
|
||||||
HermesCustom.buildGraphModal = function() {
|
|
||||||
const modal = document.createElement('div');
|
|
||||||
modal.className = 'hermes-graph-modal';
|
|
||||||
|
|
||||||
const modalHeader = document.createElement('div');
|
|
||||||
modalHeader.className = 'hermes-graph-modal-header';
|
|
||||||
modalHeader.innerHTML = `
|
|
||||||
<span class="hermes-graph-modal-title">GRAPH VIEW · Tap a node to navigate</span>
|
|
||||||
<button class="hermes-graph-modal-close">× Close</button>
|
|
||||||
`;
|
|
||||||
modal.appendChild(modalHeader);
|
|
||||||
|
|
||||||
const canvasContainer = document.createElement('div');
|
|
||||||
canvasContainer.className = 'hermes-graph-modal-canvas';
|
|
||||||
canvasContainer.id = 'hermes-graph-modal-canvas';
|
|
||||||
modal.appendChild(canvasContainer);
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
|
||||||
|
|
||||||
modal.querySelector('.hermes-graph-modal-close').onclick = () => {
|
|
||||||
HermesCustom.closeGraphModal();
|
|
||||||
};
|
|
||||||
// ESC to close
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape' && modal.classList.contains('hermes-modal-open')) {
|
|
||||||
HermesCustom.closeGraphModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 2. Graph modal open/close
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
HermesCustom.toggleGraphModal = function() {
|
|
||||||
const modal = document.querySelector('.hermes-graph-modal');
|
|
||||||
if (!modal) return;
|
|
||||||
if (modal.classList.contains('hermes-modal-open')) {
|
|
||||||
HermesCustom.closeGraphModal();
|
|
||||||
} else {
|
|
||||||
HermesCustom.openGraphModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
HermesCustom.openGraphModal = function() {
|
|
||||||
const modal = document.querySelector('.hermes-graph-modal');
|
|
||||||
const canvasContainer = document.getElementById('hermes-graph-modal-canvas');
|
|
||||||
if (!modal || !canvasContainer) return;
|
|
||||||
|
|
||||||
// Move the existing obv canvas into our modal
|
|
||||||
const existingCanvas = document.querySelector('.vault-graph canvas');
|
|
||||||
if (existingCanvas && existingCanvas.parentElement !== canvasContainer) {
|
|
||||||
// Clone the canvas instead of moving it (keeps obv's state intact)
|
|
||||||
canvasContainer.innerHTML = '';
|
|
||||||
const clonedCanvas = document.createElement('canvas');
|
|
||||||
clonedCanvas.width = window.innerWidth;
|
|
||||||
clonedCanvas.height = window.innerHeight - 48 - 44; // viewport minus header+modal-header
|
|
||||||
canvasContainer.appendChild(clonedCanvas);
|
|
||||||
|
|
||||||
// Re-render the graph into our cloned canvas by re-running loadGraph logic
|
|
||||||
HermesCustom.renderGraphIntoCanvas(clonedCanvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.classList.add('hermes-modal-open');
|
|
||||||
document.body.style.overflow = 'hidden'; // prevent background scroll
|
|
||||||
console.log('[HermesCustom] Graph modal opened');
|
|
||||||
};
|
|
||||||
|
|
||||||
HermesCustom.closeGraphModal = function() {
|
|
||||||
const modal = document.querySelector('.hermes-graph-modal');
|
|
||||||
if (!modal) return;
|
|
||||||
modal.classList.remove('hermes-modal-open');
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
console.log('[HermesCustom] Graph modal closed');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Re-render obv's graph into a different canvas.
|
|
||||||
* This is a hack: we re-fetch graph data and re-create a Three.js scene
|
|
||||||
* because obv's `loadGraph()` creates the scene with a specific canvas.
|
|
||||||
*
|
|
||||||
* Note: this duplicates Three.js scene. For mobile-only graph viewing,
|
|
||||||
* this is acceptable (small perf cost, but isolated to user action).
|
|
||||||
*/
|
|
||||||
HermesCustom.renderGraphIntoCanvas = function(canvas) {
|
|
||||||
if (!window.THREE || !HermesCustom._graphData) {
|
|
||||||
console.warn('[HermesCustom] Three.js or graph data not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
scene: new THREE.Scene(),
|
|
||||||
camera: null,
|
|
||||||
renderer: null,
|
|
||||||
nodeObjects: new Map(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const w = canvas.clientWidth || canvas.width;
|
|
||||||
const h = canvas.clientHeight || canvas.height;
|
|
||||||
ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 5000);
|
|
||||||
ctx.camera.position.set(0, 0, 800);
|
|
||||||
|
|
||||||
ctx.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
||||||
ctx.renderer.setSize(w, h);
|
|
||||||
ctx.renderer.setPixelRatio(window.devicePixelRatio || 1);
|
|
||||||
|
|
||||||
// Build nodes
|
|
||||||
const nodes = HermesCustom._graphData.nodes;
|
|
||||||
const nodeGeometry = new THREE.SphereGeometry(4, 16, 16);
|
|
||||||
const edges = HermesCustom._graphData.edges;
|
|
||||||
|
|
||||||
nodes.forEach(node => {
|
|
||||||
const mat = new THREE.MeshBasicMaterial({ color: 0x6c7086 });
|
|
||||||
const mesh = new THREE.Mesh(nodeGeometry, mat);
|
|
||||||
mesh.userData.nodeId = node.id;
|
|
||||||
// Random-ish positions (deterministic based on label hash)
|
|
||||||
const hash = node.label.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
|
||||||
mesh.position.set(
|
|
||||||
(hash % 100) * 6 - 300,
|
|
||||||
((hash * 7) % 100) * 4 - 200,
|
|
||||||
((hash * 13) % 100) * 4 - 200
|
|
||||||
);
|
|
||||||
ctx.scene.add(mesh);
|
|
||||||
ctx.nodeObjects.set(node.id, mesh);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build edges
|
|
||||||
edges.forEach(edge => {
|
|
||||||
const src = ctx.nodeObjects.get(edge.source);
|
|
||||||
const tgt = ctx.nodeObjects.get(edge.target);
|
|
||||||
if (!src || !tgt) return;
|
|
||||||
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
|
||||||
src.position, tgt.position
|
|
||||||
]);
|
|
||||||
const line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({
|
|
||||||
color: 0x45475a, transparent: true, opacity: 0.6
|
|
||||||
}));
|
|
||||||
ctx.scene.add(line);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Slow rotation
|
|
||||||
function animate() {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
ctx.scene.rotation.y += 0.001;
|
|
||||||
ctx.renderer.render(ctx.scene, ctx.camera);
|
|
||||||
}
|
|
||||||
animate();
|
|
||||||
|
|
||||||
// Click handler — re-use the same navigation logic
|
|
||||||
HermesCustom.setupClickForCanvas(canvas, ctx.camera, ctx.nodeObjects);
|
|
||||||
};
|
|
||||||
|
|
||||||
HermesCustom.setupClickForCanvas = function(canvas, camera, nodeMap) {
|
|
||||||
const raycaster = new THREE.Raycaster();
|
|
||||||
const mouse = new THREE.Vector2();
|
|
||||||
canvas.addEventListener('click', (event) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
||||||
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
||||||
raycaster.setFromCamera(mouse, camera);
|
|
||||||
const intersects = raycaster.intersectObjects(Array.from(nodeMap.values()));
|
|
||||||
if (intersects.length > 0) {
|
|
||||||
const nodeId = intersects[0].object.userData.nodeId;
|
|
||||||
if (typeof window.loadFile === 'function') {
|
|
||||||
HermesCustom.closeGraphModal();
|
|
||||||
window.loadFile(nodeId);
|
|
||||||
window.history.pushState({}, '', '?file=' + encodeURIComponent(nodeId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 3. Tree sidebar toggle
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
HermesCustom.toggleTreeSidebar = function() {
|
|
||||||
const sidebar = document.querySelector('.vault-sidebar');
|
|
||||||
const backdrop = document.querySelector('.hermes-sidebar-backdrop');
|
|
||||||
if (!sidebar) return;
|
|
||||||
const opening = !sidebar.classList.contains('hermes-sidebar-open');
|
|
||||||
sidebar.classList.toggle('hermes-sidebar-open', opening);
|
|
||||||
if (backdrop) backdrop.classList.toggle('hermes-backdrop-visible', opening);
|
|
||||||
};
|
|
||||||
|
|
||||||
HermesCustom.closeTreeSidebar = function() {
|
|
||||||
const sidebar = document.querySelector('.vault-sidebar');
|
|
||||||
const backdrop = document.querySelector('.hermes-sidebar-backdrop');
|
|
||||||
if (sidebar) sidebar.classList.remove('hermes-sidebar-open');
|
|
||||||
if (backdrop) backdrop.classList.remove('hermes-backdrop-visible');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 4. Layout decision
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
HermesCustom.applyLayout = function() {
|
|
||||||
const app = document.querySelector('.vault-app');
|
|
||||||
if (!app) {
|
|
||||||
setTimeout(HermesCustom.applyLayout, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marker class
|
|
||||||
if (!app.classList.contains('hermes-rc')) {
|
|
||||||
app.classList.add('hermes-rc');
|
|
||||||
}
|
|
||||||
|
|
||||||
const width = window.innerWidth;
|
|
||||||
app.classList.remove('hermes-mobile', 'hermes-compact', 'hermes-desktop');
|
|
||||||
|
|
||||||
if (width <= BREAKPOINTS.mobile) {
|
|
||||||
app.classList.add('hermes-mobile');
|
|
||||||
} else if (width <= BREAKPOINTS.compact) {
|
|
||||||
app.classList.add('hermes-compact');
|
|
||||||
} else {
|
|
||||||
app.classList.add('hermes-desktop');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-close sidebar on resize to desktop
|
|
||||||
if (width >= BREAKPOINTS.compact) {
|
|
||||||
HermesCustom.closeTreeSidebar();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
HermesCustom.setupResizeHandler = function() {
|
|
||||||
let resizeTimer;
|
|
||||||
let lastWidth = window.innerWidth;
|
|
||||||
window.addEventListener('resize', () => {
|
|
||||||
clearTimeout(resizeTimer);
|
|
||||||
resizeTimer = setTimeout(() => {
|
|
||||||
if (window.innerWidth !== lastWidth) {
|
|
||||||
lastWidth = window.innerWidth;
|
|
||||||
HermesCustom.applyLayout();
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 5. Current-page highlighting (desktop)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
HermesCustom.getCurrentFile = function() {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const fileParam = params.get('file');
|
|
||||||
if (fileParam) return fileParam;
|
|
||||||
return HermesCustom._lastLoadedFile || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
HermesCustom.getConnectedFiles = function(currentFile) {
|
|
||||||
if (!currentFile) return [];
|
|
||||||
const graphData = HermesCustom._graphData;
|
|
||||||
if (!graphData) return [];
|
|
||||||
return graphData.edges.filter(e => e.source === currentFile).map(e => e.target);
|
|
||||||
};
|
|
||||||
|
|
||||||
HermesCustom.highlightCurrentNode = function() {
|
|
||||||
const currentFile = HermesCustom.getCurrentFile();
|
|
||||||
if (!currentFile) return;
|
|
||||||
if (typeof window.graphNodeObjects === 'undefined' || !window.graphNodeObjects) {
|
|
||||||
setTimeout(HermesCustom.highlightCurrentNode, 500);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connected = new Set(HermesCustom.getConnectedFiles(currentFile));
|
|
||||||
connected.add(currentFile);
|
|
||||||
|
|
||||||
window.graphNodeObjects.forEach((mesh, nodeId) => {
|
|
||||||
const isCurrent = (nodeId === currentFile);
|
|
||||||
const isConnected = connected.has(nodeId) && !isCurrent;
|
|
||||||
|
|
||||||
if (isCurrent) {
|
|
||||||
mesh.material.color.setHex(0xFFD700);
|
|
||||||
mesh.material.emissive.setHex(0xFFD700);
|
|
||||||
mesh.material.emissiveIntensity = 0.6;
|
|
||||||
mesh.material.opacity = 1.0;
|
|
||||||
mesh.scale.set(1.5, 1.5, 1.5);
|
|
||||||
} else if (isConnected) {
|
|
||||||
mesh.material.color.setHex(0x89b4fa);
|
|
||||||
mesh.material.emissive.setHex(0x89b4fa);
|
|
||||||
mesh.material.emissiveIntensity = 0.3;
|
|
||||||
mesh.material.opacity = 1.0;
|
|
||||||
mesh.scale.set(1.1, 1.1, 1.1);
|
|
||||||
} else {
|
|
||||||
mesh.material.color.setHex(0x6c7086);
|
|
||||||
mesh.material.emissiveIntensity = 0;
|
|
||||||
mesh.material.opacity = 0.25;
|
|
||||||
mesh.material.transparent = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 6. URL navigation
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
HermesCustom.updateURL = function(path) {
|
|
||||||
const newURL = '?file=' + encodeURIComponent(path);
|
|
||||||
window.history.pushState({}, '', newURL);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 7. loadFile hook
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
HermesCustom.hookLoadFile = function() {
|
|
||||||
if (typeof window.loadFile !== 'function') {
|
|
||||||
setTimeout(HermesCustom.hookLoadFile, 200);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (window.loadFile._hermesPatched) return;
|
|
||||||
|
|
||||||
const originalLoadFile = window.loadFile;
|
|
||||||
window.loadFile = function(path) {
|
|
||||||
HermesCustom._lastLoadedFile = path;
|
|
||||||
HermesCustom.updateURL(path);
|
|
||||||
// Close tree sidebar after file selection (mobile UX)
|
|
||||||
if (window.innerWidth <= BREAKPOINTS.mobile) {
|
|
||||||
HermesCustom.closeTreeSidebar();
|
|
||||||
}
|
|
||||||
const result = originalLoadFile.apply(this, arguments);
|
|
||||||
setTimeout(HermesCustom.highlightCurrentNode, 300);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
window.loadFile._hermesPatched = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 8. Graph data fetch
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
HermesCustom.loadGraphData = function() {
|
|
||||||
fetch('/api/vault/graph')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
HermesCustom._graphData = data;
|
|
||||||
console.log('[HermesCustom] Graph data: ' + data.nodes.length + ' nodes, ' + data.edges.length + ' edges');
|
|
||||||
HermesCustom.highlightCurrentNode();
|
|
||||||
})
|
|
||||||
.catch(err => console.warn('[HermesCustom] Graph fetch failed:', err));
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// Init
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
HermesCustom.init = function() {
|
|
||||||
console.log('[HermesCustom] init v5 (mobile-toggle-pattern)');
|
|
||||||
HermesCustom.injectUI();
|
|
||||||
HermesCustom.hookLoadFile();
|
|
||||||
HermesCustom.applyLayout();
|
|
||||||
HermesCustom.setupResizeHandler();
|
|
||||||
HermesCustom.loadGraphData();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', HermesCustom.init);
|
|
||||||
} else {
|
|
||||||
setTimeout(HermesCustom.init, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
/* HermesCustom — Lukas' additions to obsidian-web-viewer
|
||||||
|
*
|
||||||
|
* Loaded by vault.html via <script> tag (one-line patch).
|
||||||
|
*
|
||||||
|
* v0.5 — Mobile/Responsive overhaul:
|
||||||
|
* - Graph is NEVER a permanent bottom panel (waste of vertical space on phones)
|
||||||
|
* - Graph shown via full-screen modal toggle on Mobile + Tablet
|
||||||
|
* - Tree as hamburger on Mobile, full sidebar on Tablet
|
||||||
|
* - Desktop unchanged
|
||||||
|
*
|
||||||
|
* Companion: hermes-custom.css
|
||||||
|
*
|
||||||
|
* Layout decisions per viewport:
|
||||||
|
* Desktop (>=1025px): 3-panel, obv default, both toggles hidden
|
||||||
|
* Compact (601-1024px): 2-panel (tree+content), graph hidden by default,
|
||||||
|
* "Show Graph" button visible, "Tree" toggle visible
|
||||||
|
* Mobile (<=600px): 1-panel (content only), tree hamburger, graph modal
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
window.HermesCustom = window.HermesCustom || {};
|
||||||
|
const HermesCustom = window.HermesCustom;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Config
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const BREAKPOINTS = {
|
||||||
|
mobile: 600,
|
||||||
|
compact: 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 1. UI elements (injected into header)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject toggle buttons into header.
|
||||||
|
* - Tree toggle: only visible when tree is hidden (Compact mode hides tree by default)
|
||||||
|
* - Graph toggle: only visible when graph is hidden (Compact + Mobile)
|
||||||
|
* - Search input is already in obv's header; we don't duplicate
|
||||||
|
*/
|
||||||
|
HermesCustom.injectUI = function() {
|
||||||
|
const header = document.querySelector('.vault-header');
|
||||||
|
if (!header) {
|
||||||
|
setTimeout(HermesCustom.injectUI, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph toggle button (top-right of header, before search)
|
||||||
|
if (!document.querySelector('.hermes-graph-toggle')) {
|
||||||
|
const graphBtn = document.createElement('button');
|
||||||
|
graphBtn.className = 'hermes-graph-toggle hermes-ui-btn';
|
||||||
|
graphBtn.setAttribute('aria-label', 'Toggle 3D graph');
|
||||||
|
graphBtn.innerHTML = '⊕ Graph';
|
||||||
|
graphBtn.onclick = () => HermesCustom.toggleGraphModal();
|
||||||
|
header.insertBefore(graphBtn, header.querySelector('.search-box') || header.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree toggle button (left-most in header)
|
||||||
|
if (!document.querySelector('.hermes-tree-toggle')) {
|
||||||
|
const treeBtn = document.createElement('button');
|
||||||
|
treeBtn.className = 'hermes-tree-toggle hermes-ui-btn';
|
||||||
|
treeBtn.setAttribute('aria-label', 'Toggle file tree');
|
||||||
|
treeBtn.innerHTML = '☰';
|
||||||
|
treeBtn.onclick = () => HermesCustom.toggleTreeSidebar();
|
||||||
|
header.insertBefore(treeBtn, header.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backdrop for tree sidebar
|
||||||
|
if (!document.querySelector('.hermes-sidebar-backdrop')) {
|
||||||
|
const bd = document.createElement('div');
|
||||||
|
bd.className = 'hermes-sidebar-backdrop';
|
||||||
|
bd.onclick = () => HermesCustom.closeTreeSidebar();
|
||||||
|
document.body.appendChild(bd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph modal (hidden by default)
|
||||||
|
if (!document.querySelector('.hermes-graph-modal')) {
|
||||||
|
HermesCustom.buildGraphModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the graph modal: full-screen overlay that re-uses the
|
||||||
|
* existing canvas from obv. Strategy: clone the canvas into our modal,
|
||||||
|
* or move the existing canvas in/out. Simpler approach: create a NEW
|
||||||
|
* canvas inside the modal, and re-run the graph render there.
|
||||||
|
*/
|
||||||
|
HermesCustom.buildGraphModal = function() {
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'hermes-graph-modal';
|
||||||
|
|
||||||
|
const modalHeader = document.createElement('div');
|
||||||
|
modalHeader.className = 'hermes-graph-modal-header';
|
||||||
|
modalHeader.innerHTML = `
|
||||||
|
<span class="hermes-graph-modal-title">GRAPH VIEW · Tap a node to navigate</span>
|
||||||
|
<button class="hermes-graph-modal-close">× Close</button>
|
||||||
|
`;
|
||||||
|
modal.appendChild(modalHeader);
|
||||||
|
|
||||||
|
const canvasContainer = document.createElement('div');
|
||||||
|
canvasContainer.className = 'hermes-graph-modal-canvas';
|
||||||
|
canvasContainer.id = 'hermes-graph-modal-canvas';
|
||||||
|
modal.appendChild(canvasContainer);
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
modal.querySelector('.hermes-graph-modal-close').onclick = () => {
|
||||||
|
HermesCustom.closeGraphModal();
|
||||||
|
};
|
||||||
|
// ESC to close
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && modal.classList.contains('hermes-modal-open')) {
|
||||||
|
HermesCustom.closeGraphModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 2. Graph modal open/close
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
HermesCustom.toggleGraphModal = function() {
|
||||||
|
const modal = document.querySelector('.hermes-graph-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
if (modal.classList.contains('hermes-modal-open')) {
|
||||||
|
HermesCustom.closeGraphModal();
|
||||||
|
} else {
|
||||||
|
HermesCustom.openGraphModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
HermesCustom.openGraphModal = function() {
|
||||||
|
const modal = document.querySelector('.hermes-graph-modal');
|
||||||
|
const canvasContainer = document.getElementById('hermes-graph-modal-canvas');
|
||||||
|
if (!modal || !canvasContainer) return;
|
||||||
|
|
||||||
|
// Move the existing obv canvas into our modal
|
||||||
|
const existingCanvas = document.querySelector('.vault-graph canvas');
|
||||||
|
if (existingCanvas && existingCanvas.parentElement !== canvasContainer) {
|
||||||
|
// Clone the canvas instead of moving it (keeps obv's state intact)
|
||||||
|
canvasContainer.innerHTML = '';
|
||||||
|
const clonedCanvas = document.createElement('canvas');
|
||||||
|
clonedCanvas.width = window.innerWidth;
|
||||||
|
clonedCanvas.height = window.innerHeight - 48 - 44; // viewport minus header+modal-header
|
||||||
|
canvasContainer.appendChild(clonedCanvas);
|
||||||
|
|
||||||
|
// Re-render the graph into our cloned canvas by re-running loadGraph logic
|
||||||
|
HermesCustom.renderGraphIntoCanvas(clonedCanvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.add('hermes-modal-open');
|
||||||
|
document.body.style.overflow = 'hidden'; // prevent background scroll
|
||||||
|
console.log('[HermesCustom] Graph modal opened');
|
||||||
|
};
|
||||||
|
|
||||||
|
HermesCustom.closeGraphModal = function() {
|
||||||
|
const modal = document.querySelector('.hermes-graph-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.remove('hermes-modal-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
console.log('[HermesCustom] Graph modal closed');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-render obv's graph into a different canvas.
|
||||||
|
* This is a hack: we re-fetch graph data and re-create a Three.js scene
|
||||||
|
* because obv's `loadGraph()` creates the scene with a specific canvas.
|
||||||
|
*
|
||||||
|
* Note: this duplicates Three.js scene. For mobile-only graph viewing,
|
||||||
|
* this is acceptable (small perf cost, but isolated to user action).
|
||||||
|
*/
|
||||||
|
HermesCustom.renderGraphIntoCanvas = function(canvas) {
|
||||||
|
if (!window.THREE || !HermesCustom._graphData) {
|
||||||
|
console.warn('[HermesCustom] Three.js or graph data not ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
scene: new THREE.Scene(),
|
||||||
|
camera: null,
|
||||||
|
renderer: null,
|
||||||
|
nodeObjects: new Map(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const w = canvas.clientWidth || canvas.width;
|
||||||
|
const h = canvas.clientHeight || canvas.height;
|
||||||
|
ctx.camera = new THREE.PerspectiveCamera(60, w / h, 1, 5000);
|
||||||
|
ctx.camera.position.set(0, 0, 800);
|
||||||
|
|
||||||
|
ctx.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
|
ctx.renderer.setSize(w, h);
|
||||||
|
ctx.renderer.setPixelRatio(window.devicePixelRatio || 1);
|
||||||
|
|
||||||
|
// Build nodes
|
||||||
|
const nodes = HermesCustom._graphData.nodes;
|
||||||
|
const nodeGeometry = new THREE.SphereGeometry(4, 16, 16);
|
||||||
|
const edges = HermesCustom._graphData.edges;
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color: 0x6c7086 });
|
||||||
|
const mesh = new THREE.Mesh(nodeGeometry, mat);
|
||||||
|
mesh.userData.nodeId = node.id;
|
||||||
|
// Random-ish positions (deterministic based on label hash)
|
||||||
|
const hash = node.label.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||||
|
mesh.position.set(
|
||||||
|
(hash % 100) * 6 - 300,
|
||||||
|
((hash * 7) % 100) * 4 - 200,
|
||||||
|
((hash * 13) % 100) * 4 - 200
|
||||||
|
);
|
||||||
|
ctx.scene.add(mesh);
|
||||||
|
ctx.nodeObjects.set(node.id, mesh);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build edges
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const src = ctx.nodeObjects.get(edge.source);
|
||||||
|
const tgt = ctx.nodeObjects.get(edge.target);
|
||||||
|
if (!src || !tgt) return;
|
||||||
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
src.position, tgt.position
|
||||||
|
]);
|
||||||
|
const line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({
|
||||||
|
color: 0x45475a, transparent: true, opacity: 0.6
|
||||||
|
}));
|
||||||
|
ctx.scene.add(line);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Slow rotation
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
ctx.scene.rotation.y += 0.001;
|
||||||
|
ctx.renderer.render(ctx.scene, ctx.camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
|
||||||
|
// Click handler — re-use the same navigation logic
|
||||||
|
HermesCustom.setupClickForCanvas(canvas, ctx.camera, ctx.nodeObjects);
|
||||||
|
};
|
||||||
|
|
||||||
|
HermesCustom.setupClickForCanvas = function(canvas, camera, nodeMap) {
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const mouse = new THREE.Vector2();
|
||||||
|
canvas.addEventListener('click', (event) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
raycaster.setFromCamera(mouse, camera);
|
||||||
|
const intersects = raycaster.intersectObjects(Array.from(nodeMap.values()));
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const nodeId = intersects[0].object.userData.nodeId;
|
||||||
|
if (typeof window.loadFile === 'function') {
|
||||||
|
HermesCustom.closeGraphModal();
|
||||||
|
window.loadFile(nodeId);
|
||||||
|
window.history.pushState({}, '', '?file=' + encodeURIComponent(nodeId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 3. Tree sidebar toggle
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
HermesCustom.toggleTreeSidebar = function() {
|
||||||
|
const sidebar = document.querySelector('.vault-sidebar');
|
||||||
|
const backdrop = document.querySelector('.hermes-sidebar-backdrop');
|
||||||
|
if (!sidebar) return;
|
||||||
|
const opening = !sidebar.classList.contains('hermes-sidebar-open');
|
||||||
|
sidebar.classList.toggle('hermes-sidebar-open', opening);
|
||||||
|
if (backdrop) backdrop.classList.toggle('hermes-backdrop-visible', opening);
|
||||||
|
};
|
||||||
|
|
||||||
|
HermesCustom.closeTreeSidebar = function() {
|
||||||
|
const sidebar = document.querySelector('.vault-sidebar');
|
||||||
|
const backdrop = document.querySelector('.hermes-sidebar-backdrop');
|
||||||
|
if (sidebar) sidebar.classList.remove('hermes-sidebar-open');
|
||||||
|
if (backdrop) backdrop.classList.remove('hermes-backdrop-visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 4. Layout decision
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
HermesCustom.applyLayout = function() {
|
||||||
|
const app = document.querySelector('.vault-app');
|
||||||
|
if (!app) {
|
||||||
|
setTimeout(HermesCustom.applyLayout, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marker class
|
||||||
|
if (!app.classList.contains('hermes-rc')) {
|
||||||
|
app.classList.add('hermes-rc');
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = window.innerWidth;
|
||||||
|
app.classList.remove('hermes-mobile', 'hermes-compact', 'hermes-desktop');
|
||||||
|
|
||||||
|
if (width <= BREAKPOINTS.mobile) {
|
||||||
|
app.classList.add('hermes-mobile');
|
||||||
|
} else if (width <= BREAKPOINTS.compact) {
|
||||||
|
app.classList.add('hermes-compact');
|
||||||
|
} else {
|
||||||
|
app.classList.add('hermes-desktop');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close sidebar on resize to desktop
|
||||||
|
if (width >= BREAKPOINTS.compact) {
|
||||||
|
HermesCustom.closeTreeSidebar();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
HermesCustom.setupResizeHandler = function() {
|
||||||
|
let resizeTimer;
|
||||||
|
let lastWidth = window.innerWidth;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
if (window.innerWidth !== lastWidth) {
|
||||||
|
lastWidth = window.innerWidth;
|
||||||
|
HermesCustom.applyLayout();
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 5. Current-page highlighting (desktop)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
HermesCustom.getCurrentFile = function() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const fileParam = params.get('file');
|
||||||
|
if (fileParam) return fileParam;
|
||||||
|
return HermesCustom._lastLoadedFile || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
HermesCustom.getConnectedFiles = function(currentFile) {
|
||||||
|
if (!currentFile) return [];
|
||||||
|
const graphData = HermesCustom._graphData;
|
||||||
|
if (!graphData) return [];
|
||||||
|
return graphData.edges.filter(e => e.source === currentFile).map(e => e.target);
|
||||||
|
};
|
||||||
|
|
||||||
|
HermesCustom.highlightCurrentNode = function() {
|
||||||
|
const currentFile = HermesCustom.getCurrentFile();
|
||||||
|
if (!currentFile) return;
|
||||||
|
if (typeof window.graphNodeObjects === 'undefined' || !window.graphNodeObjects) {
|
||||||
|
setTimeout(HermesCustom.highlightCurrentNode, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connected = new Set(HermesCustom.getConnectedFiles(currentFile));
|
||||||
|
connected.add(currentFile);
|
||||||
|
|
||||||
|
window.graphNodeObjects.forEach((mesh, nodeId) => {
|
||||||
|
const isCurrent = (nodeId === currentFile);
|
||||||
|
const isConnected = connected.has(nodeId) && !isCurrent;
|
||||||
|
|
||||||
|
if (isCurrent) {
|
||||||
|
mesh.material.color.setHex(0xFFD700);
|
||||||
|
mesh.material.emissive.setHex(0xFFD700);
|
||||||
|
mesh.material.emissiveIntensity = 0.6;
|
||||||
|
mesh.material.opacity = 1.0;
|
||||||
|
mesh.scale.set(1.5, 1.5, 1.5);
|
||||||
|
} else if (isConnected) {
|
||||||
|
mesh.material.color.setHex(0x89b4fa);
|
||||||
|
mesh.material.emissive.setHex(0x89b4fa);
|
||||||
|
mesh.material.emissiveIntensity = 0.3;
|
||||||
|
mesh.material.opacity = 1.0;
|
||||||
|
mesh.scale.set(1.1, 1.1, 1.1);
|
||||||
|
} else {
|
||||||
|
mesh.material.color.setHex(0x6c7086);
|
||||||
|
mesh.material.emissiveIntensity = 0;
|
||||||
|
mesh.material.opacity = 0.25;
|
||||||
|
mesh.material.transparent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 6. URL navigation
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
HermesCustom.updateURL = function(path) {
|
||||||
|
const newURL = '?file=' + encodeURIComponent(path);
|
||||||
|
window.history.pushState({}, '', newURL);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 7. loadFile hook
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
HermesCustom.hookLoadFile = function() {
|
||||||
|
if (typeof window.loadFile !== 'function') {
|
||||||
|
setTimeout(HermesCustom.hookLoadFile, 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (window.loadFile._hermesPatched) return;
|
||||||
|
|
||||||
|
const originalLoadFile = window.loadFile;
|
||||||
|
window.loadFile = function(path) {
|
||||||
|
HermesCustom._lastLoadedFile = path;
|
||||||
|
HermesCustom.updateURL(path);
|
||||||
|
// Close tree sidebar after file selection (mobile UX)
|
||||||
|
if (window.innerWidth <= BREAKPOINTS.mobile) {
|
||||||
|
HermesCustom.closeTreeSidebar();
|
||||||
|
}
|
||||||
|
const result = originalLoadFile.apply(this, arguments);
|
||||||
|
setTimeout(HermesCustom.highlightCurrentNode, 300);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
window.loadFile._hermesPatched = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 8. Graph data fetch
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
HermesCustom.loadGraphData = function() {
|
||||||
|
fetch('/api/vault/graph')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
HermesCustom._graphData = data;
|
||||||
|
console.log('[HermesCustom] Graph data: ' + data.nodes.length + ' nodes, ' + data.edges.length + ' edges');
|
||||||
|
HermesCustom.highlightCurrentNode();
|
||||||
|
})
|
||||||
|
.catch(err => console.warn('[HermesCustom] Graph fetch failed:', err));
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Init
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
HermesCustom.init = function() {
|
||||||
|
console.log('[HermesCustom] init v5 (mobile-toggle-pattern)');
|
||||||
|
HermesCustom.injectUI();
|
||||||
|
HermesCustom.hookLoadFile();
|
||||||
|
HermesCustom.applyLayout();
|
||||||
|
HermesCustom.setupResizeHandler();
|
||||||
|
HermesCustom.loadGraphData();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', HermesCustom.init);
|
||||||
|
} else {
|
||||||
|
setTimeout(HermesCustom.init, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
+1
-3
@@ -2,9 +2,8 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Vault Viewer</title>
|
<title>Vault Viewer</title>
|
||||||
<link rel="stylesheet" href="hermes-custom.css">
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
@@ -350,6 +349,5 @@ async function loadGraph() {
|
|||||||
loadTree();
|
loadTree();
|
||||||
loadGraph();
|
loadGraph();
|
||||||
</script>
|
</script>
|
||||||
<script src="vault-custom.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>Vault Viewer</title>
|
||||||
|
<link rel="stylesheet" href="hermes-custom.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a14; --bg-secondary: #0e0e1a; --bg-surface: #1a1a2e;
|
||||||
|
--text-primary: #cdd6f4; --text-secondary: #a6adc8; --text-muted: #6c7086;
|
||||||
|
--accent: #FFD700; --link: #FFD700; --border: #313244;
|
||||||
|
--green: #a6e3a1; --red: #f38ba8; --blue: #89b4fa; --teal: #94e2d5;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; height: 100vh; overflow: hidden; }
|
||||||
|
|
||||||
|
.vault-app {
|
||||||
|
display: grid; grid-template-columns: 280px 1fr 260px;
|
||||||
|
grid-template-rows: 48px 1fr; height: 100vh;
|
||||||
|
}
|
||||||
|
.vault-app.graph-hidden { grid-template-columns: 280px 1fr; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.vault-header {
|
||||||
|
grid-column: 1 / -1; background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border); display: flex; align-items: center;
|
||||||
|
padding: 0 16px; gap: 12px;
|
||||||
|
}
|
||||||
|
.vault-header h1 { color: var(--accent); font-size: 14px; font-weight: 700; letter-spacing: 1px; }
|
||||||
|
.vault-header .search-box {
|
||||||
|
flex: 1; max-width: 400px; position: relative;
|
||||||
|
}
|
||||||
|
.vault-header input {
|
||||||
|
width: 100%; padding: 6px 12px 6px 32px; background: rgba(30,30,50,0.6);
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);
|
||||||
|
font-size: 13px; outline: none;
|
||||||
|
}
|
||||||
|
.vault-header input:focus { border-color: var(--accent); }
|
||||||
|
.vault-header .toggle-btn {
|
||||||
|
background: none; border: 1px solid var(--border); color: var(--text-muted);
|
||||||
|
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
.vault-header .toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Sidebar file tree */
|
||||||
|
.vault-sidebar {
|
||||||
|
background: var(--bg-secondary); border-right: 1px solid var(--border);
|
||||||
|
overflow-y: auto; padding: 8px 0; font-size: 13px;
|
||||||
|
}
|
||||||
|
.tree-folder { cursor: pointer; user-select: none; }
|
||||||
|
.tree-folder-header {
|
||||||
|
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
|
||||||
|
color: var(--text-secondary); border-radius: 4px; margin: 1px 4px;
|
||||||
|
}
|
||||||
|
.tree-folder-header:hover { background: rgba(255,215,0,0.05); }
|
||||||
|
.tree-folder-header .arrow { color: var(--text-muted); font-size: 10px; width: 12px; }
|
||||||
|
.tree-children { padding-left: 12px; display: none; }
|
||||||
|
.tree-children.open { display: block; }
|
||||||
|
.tree-file {
|
||||||
|
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
|
||||||
|
color: var(--text-primary); cursor: pointer; border-radius: 4px; margin: 1px 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.tree-file:hover { background: rgba(255,215,0,0.05); }
|
||||||
|
.tree-file.active { background: rgba(255,215,0,0.1); color: var(--accent); }
|
||||||
|
|
||||||
|
/* Content area */
|
||||||
|
.vault-content {
|
||||||
|
overflow-y: auto; padding: 32px 48px; max-width: 900px;
|
||||||
|
line-height: 1.7; font-size: 15px;
|
||||||
|
}
|
||||||
|
.vault-content h1 { color: var(--accent); font-size: 28px; margin: 24px 0 12px; }
|
||||||
|
.vault-content h2 { color: var(--accent); font-size: 22px; margin: 20px 0 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
|
||||||
|
.vault-content h3 { color: var(--text-primary); font-size: 18px; margin: 16px 0 8px; }
|
||||||
|
.vault-content p { margin: 8px 0; }
|
||||||
|
.vault-content a { color: var(--link); text-decoration: none; }
|
||||||
|
.vault-content a:hover { text-decoration: underline; }
|
||||||
|
.vault-content code { background: var(--bg-surface); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--teal); }
|
||||||
|
.vault-content pre { background: var(--bg-surface); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
|
||||||
|
.vault-content pre code { padding: 0; background: none; }
|
||||||
|
.vault-content blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-secondary); margin: 12px 0; }
|
||||||
|
.vault-content ul, .vault-content ol { padding-left: 24px; }
|
||||||
|
.vault-content li { margin: 4px 0; }
|
||||||
|
.vault-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||||
|
.vault-content th, .vault-content td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
|
||||||
|
.vault-content th { background: var(--bg-surface); color: var(--accent); font-size: 13px; }
|
||||||
|
.vault-content img { max-width: 100%; border-radius: 8px; }
|
||||||
|
.frontmatter { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; font-size: 12px; }
|
||||||
|
.frontmatter .fm-key { color: var(--text-muted); }
|
||||||
|
.frontmatter .fm-val { color: var(--text-primary); margin-left: 8px; }
|
||||||
|
|
||||||
|
/* Graph panel */
|
||||||
|
.vault-graph {
|
||||||
|
background: var(--bg-secondary); border-left: 1px solid var(--border);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.vault-graph canvas { width: 100% !important; height: 100% !important; }
|
||||||
|
|
||||||
|
/* Search results overlay */
|
||||||
|
#search-results {
|
||||||
|
position: absolute; top: 48px; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 420px; max-height: 400px; overflow-y: auto;
|
||||||
|
background: rgba(14,14,26,0.95); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; z-index: 100; display: none;
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.search-item {
|
||||||
|
padding: 8px 14px; cursor: pointer; border-bottom: 1px solid rgba(49,50,68,0.5);
|
||||||
|
}
|
||||||
|
.search-item:hover { background: rgba(255,215,0,0.05); }
|
||||||
|
.search-item .name { color: var(--accent); font-weight: 600; }
|
||||||
|
.search-item .folder { color: var(--text-muted); font-size: 11px; margin-left: 8px; }
|
||||||
|
.search-item .snippet { color: var(--text-secondary); font-size: 12px; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Welcome screen */
|
||||||
|
.welcome {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
height: 100%; color: var(--text-muted); text-align: center;
|
||||||
|
}
|
||||||
|
.welcome h2 { color: var(--accent); margin-bottom: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="vault-app" id="app">
|
||||||
|
<div class="vault-header">
|
||||||
|
<h1>Vault</h1>
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="search-input" placeholder="Search notes..." oninput="onSearch(this.value)" onfocus="showSearch()" onblur="setTimeout(hideSearch, 200)">
|
||||||
|
</div>
|
||||||
|
<button class="toggle-btn" onclick="toggleGraph()">Graph</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vault-sidebar" id="sidebar"></div>
|
||||||
|
|
||||||
|
<div class="vault-content" id="content">
|
||||||
|
<div class="welcome">
|
||||||
|
<h2>Welcome to Vault Viewer</h2>
|
||||||
|
<p>Select a note from the file tree or use search.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="vault-graph" id="graph-panel">
|
||||||
|
<canvas id="graph-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="search-results"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentFile = null;
|
||||||
|
let graphVisible = true;
|
||||||
|
let graphData = null;
|
||||||
|
|
||||||
|
// Load file tree
|
||||||
|
async function loadTree() {
|
||||||
|
const resp = await fetch('/api/vault/tree');
|
||||||
|
const tree = await resp.json();
|
||||||
|
document.getElementById('sidebar').innerHTML = renderTree(tree, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(node, path) {
|
||||||
|
if (node.type === 'file') {
|
||||||
|
const fullPath = path ? path + '/' + node.name : node.name;
|
||||||
|
return `<div class="tree-file" onclick="loadFile('${fullPath.replace(/'/g, "\\'")}')" data-path="${fullPath}">${node.name.replace('.md', '')}</div>`;
|
||||||
|
}
|
||||||
|
const fullPath = path ? path + '/' + node.name : node.name;
|
||||||
|
let html = '';
|
||||||
|
if (node.name) {
|
||||||
|
html += `<div class="tree-folder">
|
||||||
|
<div class="tree-folder-header" onclick="this.nextElementSibling.classList.toggle('open');this.querySelector('.arrow').textContent=this.nextElementSibling.classList.contains('open')?'v':'>'">
|
||||||
|
<span class="arrow">></span> ${node.name}
|
||||||
|
</div>
|
||||||
|
<div class="tree-children">`;
|
||||||
|
}
|
||||||
|
(node.children || []).forEach(c => { html += renderTree(c, fullPath); });
|
||||||
|
if (node.name) html += '</div></div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load file content
|
||||||
|
async function loadFile(path) {
|
||||||
|
// Highlight active
|
||||||
|
document.querySelectorAll('.tree-file').forEach(el => el.classList.remove('active'));
|
||||||
|
const activeEl = document.querySelector(`.tree-file[data-path="${path}"]`);
|
||||||
|
if (activeEl) activeEl.classList.add('active');
|
||||||
|
|
||||||
|
const resp = await fetch('/api/vault/file/' + encodeURIComponent(path));
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
document.getElementById('content').innerHTML = `<p style="color:var(--red)">${data.error}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFile = path;
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// Frontmatter
|
||||||
|
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
|
||||||
|
html += '<div class="frontmatter">';
|
||||||
|
Object.entries(data.frontmatter).forEach(([k, v]) => {
|
||||||
|
const val = Array.isArray(v) ? v.join(', ') : String(v);
|
||||||
|
html += `<div><span class="fm-key">${k}:</span><span class="fm-val">${val}</span></div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown body — convert wikilinks first
|
||||||
|
let body = data.body || '';
|
||||||
|
body = body.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (match, target, alias) => {
|
||||||
|
const display = alias || target;
|
||||||
|
return `<a href="#" onclick="navigateWikilink('${target.replace(/'/g, "\\'")}');return false">${display}</a>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += marked.parse(body);
|
||||||
|
document.getElementById('content').innerHTML = html;
|
||||||
|
document.getElementById('content').scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wikilink navigation
|
||||||
|
async function navigateWikilink(target) {
|
||||||
|
// Search for the target file
|
||||||
|
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(target));
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.results && data.results.length > 0) {
|
||||||
|
// Find exact name match first
|
||||||
|
const exact = data.results.find(r => r.name.toLowerCase() === target.toLowerCase());
|
||||||
|
loadFile((exact || data.results[0]).path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
let searchTimeout = null;
|
||||||
|
function onSearch(query) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
if (!query.trim()) { hideSearch(); return; }
|
||||||
|
searchTimeout = setTimeout(async () => {
|
||||||
|
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(query));
|
||||||
|
const data = await resp.json();
|
||||||
|
const el = document.getElementById('search-results');
|
||||||
|
if (!data.results || data.results.length === 0) {
|
||||||
|
el.innerHTML = '<div class="search-item"><span style="color:var(--text-muted)">No results</span></div>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = data.results.map(r =>
|
||||||
|
`<div class="search-item" onmousedown="loadFile('${r.path.replace(/'/g, "\\'")}');hideSearch()">
|
||||||
|
<div><span class="name">${r.name}</span><span class="folder">${r.folder}</span></div>
|
||||||
|
${r.snippet ? '<div class="snippet">' + r.snippet + '</div>' : ''}
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
el.style.display = 'block';
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
function showSearch() { if (document.getElementById('search-input').value) onSearch(document.getElementById('search-input').value); }
|
||||||
|
function hideSearch() { document.getElementById('search-results').style.display = 'none'; }
|
||||||
|
|
||||||
|
// Graph toggle
|
||||||
|
function toggleGraph() {
|
||||||
|
graphVisible = !graphVisible;
|
||||||
|
document.getElementById('app').classList.toggle('graph-hidden', !graphVisible);
|
||||||
|
if (graphVisible && !graphData) loadGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3D Graph
|
||||||
|
let graphScene, graphCamera, graphRenderer;
|
||||||
|
async function loadGraph() {
|
||||||
|
const resp = await fetch('/api/vault/graph');
|
||||||
|
graphData = await resp.json();
|
||||||
|
|
||||||
|
const container = document.getElementById('graph-panel');
|
||||||
|
const canvas = document.getElementById('graph-canvas');
|
||||||
|
const W = container.clientWidth;
|
||||||
|
const H = container.clientHeight;
|
||||||
|
|
||||||
|
graphScene = new THREE.Scene();
|
||||||
|
graphScene.background = new THREE.Color(0x0e0e1a);
|
||||||
|
|
||||||
|
graphCamera = new THREE.PerspectiveCamera(60, W / H, 1, 2000);
|
||||||
|
graphCamera.position.set(0, 0, 300);
|
||||||
|
|
||||||
|
graphRenderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
|
graphRenderer.setSize(W, H);
|
||||||
|
|
||||||
|
// Position nodes in a 3D force layout (simplified)
|
||||||
|
const nodePositions = {};
|
||||||
|
const nodes = graphData.nodes || [];
|
||||||
|
nodes.forEach((n, i) => {
|
||||||
|
const angle = (i / nodes.length) * Math.PI * 2;
|
||||||
|
const radius = 80 + Math.random() * 120;
|
||||||
|
nodePositions[n.id] = {
|
||||||
|
x: Math.cos(angle) * radius + (Math.random() - 0.5) * 40,
|
||||||
|
y: (Math.random() - 0.5) * 100,
|
||||||
|
z: Math.sin(angle) * radius + (Math.random() - 0.5) * 40,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Node dots
|
||||||
|
const colors = { wiki: 0x89b4fa, daily: 0xf9e2af, journal: 0xa6e3a1, projects: 0xf5c2e7, other: 0x6c7086 };
|
||||||
|
nodes.forEach(n => {
|
||||||
|
const pos = nodePositions[n.id];
|
||||||
|
const color = colors[n.type] || colors.other;
|
||||||
|
const geo = new THREE.SphereGeometry(1.5, 8, 8);
|
||||||
|
const mat = new THREE.MeshBasicMaterial({ color });
|
||||||
|
const mesh = new THREE.Mesh(geo, mat);
|
||||||
|
mesh.position.set(pos.x, pos.y, pos.z);
|
||||||
|
graphScene.add(mesh);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edges
|
||||||
|
(graphData.edges || []).forEach(e => {
|
||||||
|
const s = nodePositions[e.source];
|
||||||
|
const t = nodePositions[e.target];
|
||||||
|
if (!s || !t) return;
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(s.x, s.y, s.z),
|
||||||
|
new THREE.Vector3(t.x, t.y, t.z),
|
||||||
|
]);
|
||||||
|
const mat = new THREE.LineBasicMaterial({ color: 0x313244, transparent: true, opacity: 0.3 });
|
||||||
|
graphScene.add(new THREE.Line(geo, mat));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Orbit
|
||||||
|
let isDrag = false, lastX = 0, lastY = 0, rotX = 0, rotY = 0;
|
||||||
|
canvas.addEventListener('mousedown', e => { isDrag = true; lastX = e.clientX; lastY = e.clientY; });
|
||||||
|
canvas.addEventListener('mousemove', e => {
|
||||||
|
if (!isDrag) return;
|
||||||
|
rotY += (e.clientX - lastX) * 0.005;
|
||||||
|
rotX += (e.clientY - lastY) * 0.005;
|
||||||
|
lastX = e.clientX; lastY = e.clientY;
|
||||||
|
});
|
||||||
|
canvas.addEventListener('mouseup', () => isDrag = false);
|
||||||
|
canvas.addEventListener('wheel', e => {
|
||||||
|
graphCamera.position.z += e.deltaY * 0.5;
|
||||||
|
graphCamera.position.z = Math.max(50, Math.min(800, graphCamera.position.z));
|
||||||
|
});
|
||||||
|
|
||||||
|
function animateGraph() {
|
||||||
|
requestAnimationFrame(animateGraph);
|
||||||
|
graphScene.rotation.y = rotY;
|
||||||
|
graphScene.rotation.x = rotX;
|
||||||
|
graphRenderer.render(graphScene, graphCamera);
|
||||||
|
}
|
||||||
|
animateGraph();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init
|
||||||
|
loadTree();
|
||||||
|
loadGraph();
|
||||||
|
</script>
|
||||||
|
<script src="vault-custom.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user