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
+401
View File
@@ -0,0 +1,401 @@
<script setup lang="ts">
/**
* Draggable / resizable wrapper for a table, chart, text or button object on
* the sheet canvas.
*
* - the header strip and the frame border move the object;
* - the SE handle resizes a chart/text freely; for a table, what it does
* depends on the view mode (see `resizeKind` below):
* - "Automatisch" (`mode: 'auto'`, size always == content): dragging
* drives the row/col *count* directly -- growing adds empty rows/
* cols, shrinking removes them, never past real content;
* - "Feste Größe" (`mode: 'fixed'`, an explicit scrollable viewport):
* dragging only resizes that pixel viewport, independent of row/col
* count -- the original behaviour, since a fixed-size table's whole
* point is a box smaller than its (possibly much larger) content,
* scrolling internally.
* - name is editable via double-click, delete via the × button.
*
* All pointer deltas are divided by `zoom` so dragging tracks the cursor at any
* zoom level. Moving and free/viewport resizing both snap to the object's own
* sheet's grid spacing (`Sheet.gridSize`, "Raster" in the Settings panel) --
* an "Automatisch" table's resize instead already snaps to row/col
* boundaries by construction, so it isn't additionally pixel-snapped.
*/
import { computed, nextTick, ref } from 'vue';
import type { SheetObject, TableObject } from '../store/types';
import { DEFAULT_ROW_H, DEFAULT_GRID_SIZE } from '../store/types';
import { useDocument } from '../store/document';
import TableGrid from './TableGrid.vue';
import ChartView from './ChartView.vue';
import TextBox from './TextBox.vue';
import ButtonView from './ButtonView.vue';
const props = defineProps<{ object: SheetObject; selected: boolean; zoom: number }>();
const emit = defineEmits<{ (e: 'select'): void }>();
const store = useDocument();
const renaming = ref(false);
const nameDraft = ref('');
const nameInput = ref<HTMLInputElement | null>(null);
function startRename() {
renaming.value = true;
nameDraft.value = props.object.name;
nextTick(() => nameInput.value?.select());
}
function commitRename() {
store.renameObject(props.object.id, nameDraft.value);
renaming.value = false;
}
// ---- move/resize snap-to-grid --------------------------------------
/** This object's own sheet's grid spacing -- also the snap increment while
* dragging to move or resize it (SettingsPanel's "Raster" field). */
const gridSize = computed(() => {
const sheetId = store.sheetIdOfObject(props.object.id);
const sheet = sheetId ? store.sheetById(sheetId) : undefined;
return sheet?.gridSize ?? DEFAULT_GRID_SIZE;
});
const snap = (v: number) => Math.round(v / gridSize.value) * gridSize.value;
// ---- drag to move -------------------------------------------------
let drag: { px: number; py: number; ox: number; oy: number } | null = null;
function onMoveDown(e: MouseEvent) {
if (e.button !== 0) return;
emit('select');
drag = { px: e.clientX, py: e.clientY, ox: props.object.x, oy: props.object.y };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onMoveMove);
window.addEventListener('mouseup', onMoveUp);
}
function onMoveMove(e: MouseEvent) {
if (!drag) return;
const dx = (e.clientX - drag.px) / props.zoom;
const dy = (e.clientY - drag.py) / props.zoom;
store.moveObject(props.object.id, snap(drag.ox + dx), snap(drag.oy + dy));
}
function onMoveUp() {
drag = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onMoveMove);
window.removeEventListener('mouseup', onMoveUp);
}
// ---- SE resize --------------------------------------------------
let rs: { px: number; py: number; w: number; h: number } | null = null;
/** Ghost outline shown *only* while dragging an "Automatisch" table's
* handle (see onResizeMove) -- the real row/col-count change is expensive
* (a full syncWorkbook() per call) so it commits once, on release; this is
* purely a lightweight visual preview in the meantime, touching neither
* the store nor the table's actual DOM. */
const previewSize = ref<{ w: number; h: number } | null>(null);
// An "Automatisch" table's resize handle drags the row/col *count* directly
// -- these mirror TableGrid.vue's own pixel model (HEAD_W/DEFAULT_W are
// duplicated there too).
const HEAD_W = 44;
const DEFAULT_W = 104;
/** How many columns fit in `targetPx`, at least 1. */
function colsForWidth(t: TableObject, targetPx: number): number {
let w = HEAD_W;
let c = 0;
while (true) {
const next = w + (t.colWidths[c] ?? DEFAULT_W);
if (next > targetPx && c > 0) break;
w = next;
c++;
}
return Math.max(1, c);
}
/** How many rows fit in `targetPx`, at least 1. */
function rowsForHeight(t: TableObject, targetPx: number): number {
let h = t.rowHeight ?? DEFAULT_ROW_H;
let r = 0;
while (true) {
const next = h + store.rowHeightOf(t.id, r);
if (next > targetPx && r > 0) break;
h = next;
r++;
}
return Math.max(1, r);
}
/** Pixel width/height that exactly fits `t`'s *current* rows/cols -- an
* "Automatisch" table has no stored viewport size of its own (it always
* renders at its natural content size), so this is the reference point a
* resize drag starts from; no explicit "resync" is needed afterwards
* either, since the DOM already re-renders at the new natural size the
* moment `setTableRows`/`setTableCols` changes `rows`/`cols`. */
function fittingSize(t: TableObject): { width: number; height: number } {
let width = HEAD_W;
for (let c = 0; c < t.cols; c++) width += t.colWidths[c] ?? DEFAULT_W;
let height = t.rowHeight ?? DEFAULT_ROW_H;
for (let r = 0; r < t.rows; r++) height += store.rowHeightOf(t.id, r);
return { width, height };
}
/** The last row/col index holding a real (non-empty) cell, or -1 -- a
* row/col-count shrink must never go below this, or it would silently
* delete data. `t.cells` never stores an empty-string entry (an emptied
* cell removes its key, see setCellInput), so every present key is real. */
function lastNonEmptyRow(t: TableObject): number {
let max = -1;
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[1]));
return max;
}
function lastNonEmptyCol(t: TableObject): number {
let max = -1;
for (const k of Object.keys(t.cells)) max = Math.max(max, Number(k.split(',')[0]));
return max;
}
/** How the SE handle behaves for this object -- see the module doc for the
* "Automatisch" vs. "Feste Größe" split. */
const resizeKind = computed<'free' | 'viewport' | 'grid-size'>(() => {
const o = props.object;
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') return 'free';
return o.view.mode === 'fixed' ? 'viewport' : 'grid-size';
});
function onResizeDown(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
emit('select');
const o = props.object;
let w: number;
let h: number;
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
w = o.width;
h = o.height;
} else if (o.view.mode === 'fixed') {
w = o.view.width;
h = o.view.height;
} else {
const size = fittingSize(o);
w = size.width;
h = size.height;
}
rs = { px: e.clientX, py: e.clientY, w, h };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onResizeMove);
window.addEventListener('mouseup', onResizeUp);
}
function onResizeMove(e: MouseEvent) {
if (!rs) return;
const dx = (e.clientX - rs.px) / props.zoom;
const dy = (e.clientY - rs.py) / props.zoom;
const o = props.object;
if (resizeKind.value === 'free') {
if (o.kind === 'chart') store.resizeChart(o.id, snap(rs.w + dx), snap(rs.h + dy));
else if (o.kind === 'text') store.resizeText(o.id, snap(rs.w + dx), snap(rs.h + dy));
else if (o.kind === 'button') store.resizeButton(o.id, snap(rs.w + dx), snap(rs.h + dy));
} else if (resizeKind.value === 'viewport') {
// "Feste Größe": pure pixel-viewport resize, independent of row/col
// count -- cheap (setTableView never rebuilds the workbook), so a live
// update on every mousemove is fine, same as it always was.
store.setTableView(o.id, { width: snap(rs.w + dx), height: snap(rs.h + dy) });
} else {
// "Automatisch": dragging drives row/col *count* -- but that's a full
// syncWorkbook() per call, too expensive for every mousemove, so only a
// lightweight ghost-box preview updates live here (see the template);
// the real change commits once, on release, in onResizeUp.
previewSize.value = { w: Math.max(80, rs.w + dx), h: Math.max(52, rs.h + dy) };
}
}
function onResizeUp() {
const o = props.object;
if (rs && resizeKind.value === 'grid-size' && o.kind === 'table' && previewSize.value) {
// Never past non-empty content, so a hard shrink can't silently delete
// data -- it just settles at the smallest size that still shows
// everything, however far past that the drag actually went.
const targetCols = colsForWidth(o, previewSize.value.w);
const targetRows = rowsForHeight(o, previewSize.value.h);
store.setTableCols(o.id, Math.max(targetCols, lastNonEmptyCol(o) + 1));
store.setTableRows(o.id, Math.max(targetRows, lastNonEmptyRow(o) + 1));
}
previewSize.value = null;
rs = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onResizeMove);
window.removeEventListener('mouseup', onResizeUp);
}
/** A table with `hideChromeUnfocused` set shows its title bar (and, via the
* `selected` prop passed to TableGrid below, its row/column headers) only
* while it's the selected object -- a "clean look" presentation option
* ("Ohne Rahmen/Kopfzeilen, wenn nicht ausgewählt" in Settings). Every
* other object kind is unaffected -- always shown, today's behaviour. */
const showChrome = computed(() => {
const o = props.object;
return props.selected || !(o.kind === 'table' && o.hideChromeUnfocused);
});
const icon = computed(() => {
switch (props.object.kind) {
case 'table':
return '▦';
case 'chart':
return '▤';
case 'button':
return '▶';
case 'text':
default:
return '¶';
}
});
const style = computed(() => {
const o = props.object;
const base: Record<string, string> = { left: o.x + 'px', top: o.y + 'px' };
if (o.kind === 'chart' || o.kind === 'text' || o.kind === 'button') {
base.width = o.width + 'px';
base.height = o.height + 'px';
}
return base;
});
</script>
<template>
<div
class="canvas-object"
:class="{ selected, flex: object.kind === 'chart' || object.kind === 'text' || object.kind === 'button' }"
:style="style"
@mousedown="emit('select')"
>
<div v-if="showChrome" class="chrome" @mousedown.stop="onMoveDown">
<input
v-if="renaming"
ref="nameInput"
v-model="nameDraft"
class="name-edit"
@mousedown.stop
@blur="commitRename"
@keydown.enter.prevent="commitRename"
@keydown.esc.prevent="renaming = false"
/>
<span v-else class="name" @dblclick.stop="startRename">
{{ icon }} {{ object.name }}
</span>
<button
class="del"
title="Objekt löschen"
@mousedown.stop
@click.stop="store.deleteObject(object.id)"
>
×
</button>
</div>
<div class="content" :class="{ 'full-radius': !showChrome }">
<TableGrid v-if="object.kind === 'table'" :table-id="object.id" :selected="selected" />
<ChartView v-else-if="object.kind === 'chart'" :obj-id="object.id" />
<TextBox v-else-if="object.kind === 'text'" :obj-id="object.id" />
<ButtonView v-else-if="object.kind === 'button'" :obj-id="object.id" />
</div>
<div
v-if="previewSize"
class="resize-ghost"
:style="{ width: previewSize.w + 'px', height: previewSize.h + 'px' }"
/>
<div v-if="selected" class="rs-handle" @mousedown="onResizeDown" />
</div>
</template>
<style scoped>
.canvas-object {
position: absolute;
background: var(--surface);
border-radius: 10px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.canvas-object.selected {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-weak), var(--shadow);
}
.canvas-object.flex {
display: flex;
flex-direction: column;
}
.chrome {
display: flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 4px 0 10px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
border-radius: 10px 10px 0 0;
cursor: grab;
font-size: 11.5px;
color: var(--text-dim);
user-select: none;
}
.chrome:active {
cursor: grabbing;
}
.name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name-edit {
flex: 1;
font: inherit;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 1px 4px;
}
.del {
border: 0;
background: transparent;
color: var(--text-dim);
font-size: 15px;
line-height: 1;
cursor: pointer;
border-radius: 4px;
padding: 0 4px;
}
.del:hover {
background: var(--danger);
color: #fff;
}
.content {
overflow: hidden;
border-radius: 0 0 10px 10px;
}
/* the chrome bar is what normally rounds the top corners -- without it
(hideChromeUnfocused, currently unfocused), .content fills the whole box */
.content.full-radius {
border-radius: 10px;
}
.canvas-object.flex .content {
flex: 1;
min-height: 0;
}
.resize-ghost {
position: absolute;
top: 27px;
left: 0;
border: 2px dashed var(--accent);
border-radius: 0 0 8px 8px;
background: var(--accent-weak);
pointer-events: none;
z-index: 7;
}
.rs-handle {
position: absolute;
right: -5px;
bottom: -5px;
width: 12px;
height: 12px;
border-radius: 3px;
background: var(--accent);
border: 2px solid var(--surface);
cursor: nwse-resize;
z-index: 8;
}
</style>