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>
306 lines
7.7 KiB
Vue
306 lines
7.7 KiB
Vue
<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>
|