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>
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user