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
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
/**
* A formatted text box object. Formatting (size, bold, italic, alignment,
* text/background colour) lives on the object and is edited in the inspector;
* double-click here edits the text itself.
*/
import { computed, nextTick, ref } from 'vue';
import { useDocument } from '../store/document';
const props = defineProps<{ objId: string }>();
const store = useDocument();
const obj = computed(() => {
const o = store.objectById(props.objId);
return o && o.kind === 'text' ? o : undefined;
});
const editing = ref(false);
const draft = ref('');
const area = ref<HTMLTextAreaElement | null>(null);
function beginEdit() {
if (!obj.value) return;
draft.value = obj.value.text;
editing.value = true;
nextTick(() => area.value?.focus());
}
function commit() {
if (obj.value) store.setTextContent(obj.value.id, draft.value);
editing.value = false;
}
function cancel() {
editing.value = false;
}
const boxStyle = computed(() => {
const o = obj.value;
if (!o) return {};
return {
fontSize: o.fontSize + 'px',
fontWeight: o.bold ? '600' : '400',
fontStyle: o.italic ? 'italic' : 'normal',
textAlign: o.align,
color: o.color || 'var(--text)',
background: o.bg || 'transparent',
} as Record<string, string>;
});
</script>
<template>
<div v-if="obj" class="textbox" :style="boxStyle" @dblclick="beginEdit">
<textarea
v-if="editing"
ref="area"
v-model="draft"
class="edit"
spellcheck="false"
@blur="commit"
@keydown.esc.prevent="cancel"
@keydown.enter.exact.stop
@mousedown.stop
/>
<div v-else class="view">{{ obj.text || 'Doppelklick zum Bearbeiten' }}</div>
</div>
</template>
<style scoped>
.textbox {
width: 100%;
height: 100%;
padding: 8px 10px;
overflow: auto;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
}
.view {
min-height: 100%;
}
.edit {
width: 100%;
height: 100%;
border: 0;
outline: none;
resize: none;
padding: 0;
font: inherit;
color: inherit;
text-align: inherit;
background: transparent;
}
</style>