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,410 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "𝑓 Funktionen" -- the exclusive UI for creating, viewing, editing and
|
||||
* deleting the document's user-defined functions (src/engine/userFunctions.ts).
|
||||
* Two views in one BaseModal, toggled by `mode`: a list of existing functions,
|
||||
* and a form shared by both "new" and "edit". See the plan's "Bedienung"
|
||||
* section for the exact mockups this follows.
|
||||
*/
|
||||
import { nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import CodeReference from './CodeReference.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
|
||||
type Mode = 'list' | 'edit';
|
||||
type Kind = 'expression' | 'block';
|
||||
const mode = ref<Mode>('list');
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ name: '', params: [''] as string[], body: '', kind: 'expression' as Kind });
|
||||
const formError = ref<string | null>(null);
|
||||
|
||||
// reset to the list every time the modal is (re-)opened, so a stray edit-in-
|
||||
// -progress never lingers into the next open.
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
mode.value = 'list';
|
||||
formError.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function openNew() {
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.params = [''];
|
||||
form.body = '';
|
||||
form.kind = 'expression';
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function openEdit(id: string) {
|
||||
const f = store.doc.customFunctions.find((x) => x.id === id);
|
||||
if (!f) return;
|
||||
editingId.value = id;
|
||||
form.name = f.name;
|
||||
form.params = f.params.length ? [...f.params] : [''];
|
||||
form.body = f.body;
|
||||
form.kind = f.kind;
|
||||
formError.value = null;
|
||||
mode.value = 'edit';
|
||||
}
|
||||
|
||||
function addParamField() {
|
||||
form.params.push('');
|
||||
}
|
||||
function removeParamField(i: number) {
|
||||
form.params.splice(i, 1);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const params = form.params.map((p) => p.trim()).filter((p) => p !== '');
|
||||
const result = editingId.value
|
||||
? store.updateCustomFunction(editingId.value, form.name, params, form.body, form.kind)
|
||||
: store.addCustomFunction(form.name, params, form.body, form.kind);
|
||||
if (result && typeof result === 'object' && 'error' in result) {
|
||||
formError.value = result.error;
|
||||
return;
|
||||
}
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
formError.value = null;
|
||||
mode.value = 'list';
|
||||
}
|
||||
|
||||
function removeFunction(id: string, name: string) {
|
||||
if (confirm(`Funktion "${name}" löschen? Formeln, die sie aufrufen, zeigen danach #NAME?.`)) {
|
||||
store.deleteCustomFunction(id);
|
||||
}
|
||||
}
|
||||
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
|
||||
/** Put a reference snippet at the caret (replacing any selection). */
|
||||
async function insertSnippet(text: string) {
|
||||
const el = bodyInput.value;
|
||||
const start = el?.selectionStart ?? form.body.length;
|
||||
const end = el?.selectionEnd ?? form.body.length;
|
||||
form.body = form.body.slice(0, start) + text + form.body.slice(end);
|
||||
await nextTick();
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(start + text.length, start + text.length);
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:open="open"
|
||||
:wide="mode === 'edit'"
|
||||
:title="mode === 'list' ? 'Funktionen' : editingId ? 'Funktion bearbeiten' : 'Neue Funktion'"
|
||||
@close="close"
|
||||
>
|
||||
<!-- ---- list view ---- -->
|
||||
<template v-if="mode === 'list'">
|
||||
<p v-if="!store.doc.customFunctions.length" class="hint">
|
||||
Noch keine Funktionen definiert. Mit „+ Neue Funktion" eine anlegen und danach wie
|
||||
<code>SUM</code> oder <code>AVG</code> in einer Zelle aufrufen.
|
||||
</p>
|
||||
<ul v-else class="fn-list">
|
||||
<li v-for="f in store.doc.customFunctions" :key="f.id" class="fn-row">
|
||||
<code class="fn-sig">
|
||||
<span v-if="f.kind === 'block'" class="fn-badge" title="Mit Schleife (Code)">↻</span>
|
||||
{{ f.name }}({{ f.params.join('; ') }})
|
||||
</code>
|
||||
<span class="fn-actions">
|
||||
<button class="mini" @click="openEdit(f.id)">Bearb.</button>
|
||||
<button class="mini danger" title="Löschen" @click="removeFunction(f.id, f.name)">🗑</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- ---- edit / create form ---- -->
|
||||
<div v-else class="edit-layout">
|
||||
<div class="editor">
|
||||
<label class="row">
|
||||
Name
|
||||
<input v-model="form.name" type="text" placeholder="z. B. MARGE" />
|
||||
</label>
|
||||
|
||||
<div class="row">
|
||||
<span class="params-label">Art</span>
|
||||
<div class="kind-toggle">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ on: form.kind === 'expression' }"
|
||||
@click="form.kind = 'expression'"
|
||||
>
|
||||
Einfache Formel
|
||||
</button>
|
||||
<button type="button" :class="{ on: form.kind === 'block' }" @click="form.kind = 'block'">
|
||||
Mit Schleife
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row params-row">
|
||||
<span class="params-label">Parameter</span>
|
||||
<div class="params-list">
|
||||
<div v-for="(_, i) in form.params" :key="i" class="param-field">
|
||||
<input v-model="form.params[i]" type="text" :placeholder="`param${i + 1}`" />
|
||||
<button
|
||||
class="mini remove"
|
||||
title="Parameter entfernen"
|
||||
:disabled="form.params.length <= 1"
|
||||
@click="removeParamField(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<button class="mini" @click="addParamField">+ Parameter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="row body-row">
|
||||
{{ form.kind === 'block' ? 'Code' : 'Formel' }}
|
||||
<textarea
|
||||
ref="bodyInput"
|
||||
v-model="form.body"
|
||||
class="formula-input"
|
||||
spellcheck="false"
|
||||
:rows="form.kind === 'block' ? 12 : 5"
|
||||
:placeholder="
|
||||
form.kind === 'block'
|
||||
? 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis'
|
||||
: 'z. B. (revenue - cost) / revenue'
|
||||
"
|
||||
/>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Befehle, Funktionen und die eigenen Parameter rechts suchen, aufklappen für
|
||||
Beschreibung und Beispiel, mit <strong>Einfügen</strong> an der Cursorposition
|
||||
übernehmen.
|
||||
</p>
|
||||
|
||||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||||
</div>
|
||||
<CodeReference
|
||||
class="reference"
|
||||
:context="form.kind"
|
||||
:params="form.params"
|
||||
@insert="insertSnippet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="mode === 'list'">
|
||||
<button class="primary" @click="openNew">+ Neue Funktion</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button @click="cancelEdit">Abbrechen</button>
|
||||
<button class="primary" @click="save">Speichern</button>
|
||||
</template>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.hint code {
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
.fn-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.fn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.fn-sig {
|
||||
font-size: 12.5px;
|
||||
font-family: inherit;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.fn-badge {
|
||||
display: inline-block;
|
||||
margin-right: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.kind-toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.kind-toggle button {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
}
|
||||
.kind-toggle button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.kind-toggle button.on {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.fn-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.row input[type='text'] {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.params-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.param-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.param-field input {
|
||||
width: 92px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.remove {
|
||||
padding: 2px 7px;
|
||||
}
|
||||
.body-row .formula-input {
|
||||
min-height: 30px;
|
||||
max-height: 40vh;
|
||||
}
|
||||
.formula-input {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
resize: vertical;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.formula-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface);
|
||||
}
|
||||
.edit-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
/* side by side: fill the (viewport-capped) modal body and let the
|
||||
reference list scroll inside it instead of the whole body */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
/* stacked: let the modal body scroll as a whole */
|
||||
.edit-layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto;
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.editor .body-row {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.editor .body-row .formula-input {
|
||||
flex: 1 1 auto;
|
||||
min-height: 70px;
|
||||
max-height: none;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.form-error {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: #dc2626;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user