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
+162
View File
@@ -0,0 +1,162 @@
<script setup lang="ts">
/** Numbers-style bottom tab bar. Native HTML5 drag & drop for reordering. */
import { nextTick, ref } from 'vue';
import { useDocument } from '../store/document';
const store = useDocument();
const renaming = ref<string | null>(null);
const renameDraft = ref('');
const renameInput = ref<HTMLInputElement | null>(null);
let dragIndex = -1;
function startRename(id: string, name: string) {
renaming.value = id;
renameDraft.value = name;
nextTick(() => renameInput.value?.select());
}
function commitRename() {
if (renaming.value) store.renameSheet(renaming.value, renameDraft.value);
renaming.value = null;
}
function cancelRename() {
renaming.value = null;
}
function removeSheet(id: string, name: string, hasContent: boolean) {
if (store.doc.sheets.length <= 1) return;
if (hasContent && !confirm(`Blatt „${name}" mit allen Objekten löschen?`)) return;
store.deleteSheet(id);
}
function onDragStart(i: number) {
dragIndex = i;
}
function onDrop(i: number) {
if (dragIndex >= 0 && dragIndex !== i) store.moveSheet(dragIndex, i);
dragIndex = -1;
}
</script>
<template>
<div class="tabs">
<div class="tab-list">
<div
v-for="(s, i) in store.doc.sheets"
:key="s.id"
class="tab"
:class="{ active: s.id === store.doc.activeSheetId }"
draggable="true"
@click="store.setActiveSheet(s.id)"
@dblclick="startRename(s.id, s.name)"
@dragstart="onDragStart(i)"
@dragover.prevent
@drop="onDrop(i)"
>
<input
v-if="renaming === s.id"
ref="renameInput"
v-model="renameDraft"
class="rename"
@click.stop
@blur="commitRename"
@keydown.enter.prevent="commitRename"
@keydown.esc.prevent="cancelRename"
/>
<template v-else>
<span class="label">{{ s.name }}</span>
<button
v-if="store.doc.sheets.length > 1"
class="close"
title="Blatt löschen"
@click.stop="removeSheet(s.id, s.name, s.objects.length > 0)"
>
×
</button>
</template>
</div>
</div>
<button class="add" title="Blatt hinzufügen" @click="store.addSheet()">+</button>
</div>
</template>
<style scoped>
.tabs {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
flex: 1 1 auto;
min-width: 60px;
}
.tab-list {
display: flex;
gap: 2px;
overflow-x: auto;
scrollbar-width: thin;
min-width: 0;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: 8px 8px 0 0;
font-size: 12.5px;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
user-select: none;
border: 1px solid transparent;
border-bottom: none;
}
.tab:hover {
background: var(--surface);
}
.tab.active {
background: var(--surface);
color: var(--text);
border-color: var(--border);
font-weight: 600;
}
.close {
border: 0;
background: transparent;
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 2px;
border-radius: 4px;
opacity: 0;
}
.tab:hover .close,
.tab.active .close {
opacity: 1;
}
.close:hover {
background: var(--danger);
color: #fff;
}
.rename {
font: inherit;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 1px 4px;
width: 96px;
}
.add {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 8px;
width: 26px;
height: 26px;
cursor: pointer;
font-size: 15px;
color: var(--text-dim);
}
.add:hover {
border-color: var(--accent);
color: var(--accent);
}
</style>