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:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
@@ -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>