Files
numolus/app/frontend/components/DependencyGraphView.vue
T
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

306 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Interactive dependency-graph explorer, docked to the right (like
* SettingsPanel.vue docks left) -- NOT a full-screen modal: the canvas and
* sheet tabs stay visible and interactive the whole time, so navigating the
* graph never loses track of which sheet/table you're actually looking at.
*
* A single document-wide instance (controlled via `store.ui.depGraph`, the
* same pattern as `settingsOpen`), not owned by any one `TableGrid` -- opened
* from a cell's context menu ("🕸 Abhängigkeiten anzeigen"). Clicking any
* precedent/dependent node *really* navigates there (store.openDependencyGraph
* also calls selectCell, which switches the active sheet/object as a side
* effect) -- the drawer re-centers on it exactly like a fresh open would.
*/
import { computed, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import type { CellGraphNode } from '../store/types';
const store = useDocument();
interface Loc {
tableId: string;
col: number;
row: number;
}
const loc = computed(() => store.ui.depGraph);
const open = computed(() => loc.value !== null);
const history = ref<Loc[]>([]);
// a closed -> open transition always starts a fresh navigation history
watch(open, (isOpen, wasOpen) => {
if (isOpen && !wasOpen) history.value = [];
});
function goTo(node: CellGraphNode) {
if (loc.value) history.value.push(loc.value);
store.openDependencyGraph(node.tableId, node.col, node.row);
}
function goBack() {
const prev = history.value.pop();
if (prev) store.openDependencyGraph(prev.tableId, prev.col, prev.row);
}
function close() {
store.closeDependencyGraph();
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
watch(open, (isOpen) => {
if (isOpen) document.addEventListener('keydown', onKey);
else document.removeEventListener('keydown', onKey);
});
const self = computed(() => (loc.value ? store.cellGraphSelf(loc.value.tableId, loc.value.col, loc.value.row) : null));
const neighbors = computed(() =>
loc.value
? store.cellGraphNeighbors(loc.value.tableId, loc.value.col, loc.value.row)
: { precedents: [], dependents: [] },
);
/** Always "Blatt · Tabelle!Zelle" -- the sheet name is never omitted here
* (unlike an in-formula reference, which only qualifies when needed): the
* whole point of this panel is never losing track of *where* something is. */
function label(node: CellGraphNode): string {
return `${node.sheetName ? node.sheetName + ' · ' : ''}${node.tableName}!${node.label}`;
}
function display(node: CellGraphNode): string {
return store.cellDisplay(node.tableId, node.col, node.row);
}
</script>
<template>
<aside class="dg-drawer" :class="{ open }" aria-label="Abhängigkeiten">
<header>
<button class="back" :disabled="!history.length" title="Zurück" @click="goBack">←</button>
<h2>🕸 Abhängigkeiten</h2>
<button class="x" title="Schließen" @click="close">×</button>
</header>
<div v-if="self" class="body">
<section class="self-card" :class="{ circular: self.isCircular }">
<p v-if="self.isCircular" class="circ-warn">⚠ Teil eines Zirkelbezugs</p>
<p class="cell-label">{{ label(self) }}</p>
<p class="cell-value">{{ display(self) }}</p>
<p v-if="self.raw" class="cell-raw">{{ self.raw }}</p>
</section>
<section>
<h3>Vorgänger — liest von ({{ neighbors.precedents.length }})</h3>
<p v-if="!neighbors.precedents.length" class="empty">keine</p>
<ul v-else class="node-list">
<li v-for="(n, i) in neighbors.precedents" :key="'p' + i">
<button
type="button"
class="node-btn"
:class="{ circular: n.isCircular }"
:title="n.raw || display(n)"
@click="goTo(n)"
>
<span v-if="n.isCircular" class="warn">⚠</span>
<span class="node-label">{{ label(n) }}</span>
<span class="node-value">{{ display(n) }}</span>
</button>
</li>
</ul>
</section>
<section>
<h3>Nachfolger — liest davon ({{ neighbors.dependents.length }})</h3>
<p v-if="!neighbors.dependents.length" class="empty">keine</p>
<ul v-else class="node-list">
<li v-for="(n, i) in neighbors.dependents" :key="'d' + i">
<button
type="button"
class="node-btn"
:class="{ circular: n.isCircular }"
:title="n.raw || display(n)"
@click="goTo(n)"
>
<span v-if="n.isCircular" class="warn">⚠</span>
<span class="node-label">{{ label(n) }}</span>
<span class="node-value">{{ display(n) }}</span>
</button>
</li>
</ul>
</section>
</div>
<p v-else class="body-empty">Zelle nicht gefunden.</p>
</aside>
</template>
<style scoped>
.dg-drawer {
position: absolute;
z-index: 60;
top: 0;
right: 0;
bottom: 0;
width: 380px;
max-width: 88vw;
display: flex;
flex-direction: column;
background: var(--surface);
border-left: 1px solid var(--border);
box-shadow: var(--shadow);
transform: translateX(102%);
transition: transform 0.18s ease;
}
.dg-drawer.open {
transform: none;
}
header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
flex: none;
}
h2 {
flex: 1;
margin: 0;
font-size: 14px;
}
.back,
.x {
border: 0;
background: transparent;
cursor: pointer;
color: var(--text-dim);
flex: none;
}
.back {
font-size: 15px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 3px 9px;
}
.back:disabled {
opacity: 0.35;
cursor: default;
}
.back:not(:disabled):hover,
.x:hover {
color: var(--accent);
border-color: var(--accent);
}
.x {
font-size: 18px;
}
.body {
overflow-y: auto;
padding: 4px 14px 20px;
}
.body-empty {
padding: 14px;
color: var(--text-dim);
font-size: 12.5px;
}
section {
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
section:last-child {
border-bottom: 0;
}
h3 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-dim);
}
.self-card {
border-radius: var(--radius-sm);
background: var(--accent);
color: #fff;
padding: 10px 12px;
}
.self-card.circular {
background: #dc2626;
}
.cell-label {
margin: 0;
font-family: ui-monospace, monospace;
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
}
.cell-value {
margin: 2px 0 0;
font-size: 12.5px;
opacity: 0.9;
}
.cell-raw {
margin: 4px 0 0;
font-size: 11px;
font-family: ui-monospace, monospace;
opacity: 0.8;
overflow-wrap: anywhere;
}
.circ-warn {
margin: 0 0 4px;
font-size: 11px;
font-weight: 600;
}
.empty {
margin: 0;
font-size: 12px;
font-style: italic;
color: var(--text-dim);
}
.node-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.node-btn {
width: 100%;
text-align: left;
display: flex;
align-items: baseline;
gap: 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-2);
padding: 6px 9px;
font: inherit;
cursor: pointer;
}
.node-btn:hover {
border-color: var(--accent);
}
.node-btn.circular {
border-color: #dc2626;
}
.node-label {
font-family: ui-monospace, monospace;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-value {
margin-left: auto;
flex: none;
font-size: 11.5px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
}
.warn {
font-size: 10.5px;
color: #dc2626;
flex: none;
}
</style>