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>
337 lines
8.6 KiB
Vue
337 lines
8.6 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* "▶ Makros" -- the exclusive UI for creating, viewing, editing, deleting
|
||
* and test-running the document's macros (store/document.ts's runMacro(),
|
||
* engine/parser.ts's parseMacroBlock()). Mirrors CustomFunctionsModal.vue's
|
||
* list/edit-form shape, minus the parameter list and expression/block
|
||
* toggle a macro doesn't have -- a macro is always the same write-capable
|
||
* statement language, parameterless (triggered by a click, no arguments).
|
||
*/
|
||
import { nextTick, reactive, ref, watch } from 'vue';
|
||
import { useDocument } from '../store/document';
|
||
import { useToasts } from '../composables/useToasts';
|
||
import BaseModal from './BaseModal.vue';
|
||
import CodeReference from './CodeReference.vue';
|
||
|
||
const props = defineProps<{ open: boolean }>();
|
||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||
|
||
const store = useDocument();
|
||
const toasts = useToasts();
|
||
|
||
type Mode = 'list' | 'edit';
|
||
const mode = ref<Mode>('list');
|
||
const editingId = ref<string | null>(null);
|
||
const form = reactive({ name: '', body: '' });
|
||
const formError = ref<string | null>(null);
|
||
|
||
watch(
|
||
() => props.open,
|
||
(o) => {
|
||
if (o) {
|
||
mode.value = 'list';
|
||
formError.value = null;
|
||
}
|
||
},
|
||
);
|
||
|
||
function openNew() {
|
||
editingId.value = null;
|
||
form.name = '';
|
||
form.body = '';
|
||
formError.value = null;
|
||
mode.value = 'edit';
|
||
}
|
||
|
||
function openEdit(id: string) {
|
||
const m = store.doc.macros.find((x) => x.id === id);
|
||
if (!m) return;
|
||
editingId.value = id;
|
||
form.name = m.name;
|
||
form.body = m.body;
|
||
formError.value = null;
|
||
mode.value = 'edit';
|
||
}
|
||
|
||
function save() {
|
||
const result = editingId.value
|
||
? store.updateMacro(editingId.value, form.name, form.body)
|
||
: store.addMacro(form.name, form.body);
|
||
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 removeMacro(id: string, name: string) {
|
||
if (confirm(`Makro "${name}" löschen? Buttons, die es zugewiesen haben, verlieren die Zuweisung.`)) {
|
||
store.deleteMacro(id);
|
||
}
|
||
}
|
||
|
||
// A macro can now involve a real network round-trip (GET/POST) and even a
|
||
// blocking confirm() prompt -- guard against re-clicking "▶ Ausführen" on
|
||
// the same row while its run is still in flight.
|
||
const runningId = ref<string | null>(null);
|
||
|
||
async function runMacro(id: string) {
|
||
if (runningId.value) return;
|
||
runningId.value = id;
|
||
try {
|
||
const result = await store.runMacro(id);
|
||
if (result.ok) toasts.success(result.message);
|
||
else toasts.error(result.message);
|
||
} finally {
|
||
runningId.value = null;
|
||
}
|
||
}
|
||
|
||
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' ? 'Makros' : editingId ? 'Makro bearbeiten' : 'Neues Makro'"
|
||
@close="close"
|
||
>
|
||
<!-- ---- list view ---- -->
|
||
<template v-if="mode === 'list'">
|
||
<p v-if="!store.doc.macros.length" class="hint">
|
||
Noch keine Makros definiert. Mit „+ Neues Makro" eines anlegen — danach über „▶" hier
|
||
oder über einen Button auf der Fläche auslösbar.
|
||
</p>
|
||
<ul v-else class="fn-list">
|
||
<li v-for="m in store.doc.macros" :key="m.id" class="fn-row">
|
||
<code class="fn-sig">{{ m.name }}</code>
|
||
<span class="fn-actions">
|
||
<button
|
||
class="mini run"
|
||
title="Ausführen"
|
||
:disabled="runningId === m.id"
|
||
@click="runMacro(m.id)"
|
||
>
|
||
{{ runningId === m.id ? '… läuft' : '▶ Ausführen' }}
|
||
</button>
|
||
<button class="mini" @click="openEdit(m.id)">Bearb.</button>
|
||
<button class="mini danger" title="Löschen" @click="removeMacro(m.id, m.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. Quartalsabschluss" />
|
||
</label>
|
||
|
||
<label class="row body-row">
|
||
Aktionen
|
||
<textarea
|
||
ref="bodyInput"
|
||
v-model="form.body"
|
||
class="formula-input"
|
||
rows="14"
|
||
spellcheck="false"
|
||
placeholder="INSERTROW("Umsatz"; 1) SETVALUE(Umsatz::A2; "Neu")"
|
||
/>
|
||
</label>
|
||
<p class="hint">
|
||
Befehle rechts suchen, aufklappen für Beschreibung und Beispiel, mit
|
||
<strong>Einfügen</strong> an der Cursorposition übernehmen. Wichtig: <strong>jeder
|
||
Zellbezug nennt die Tabelle</strong> — <code>Umsatz::A1</code>, nie bloß <code>A1</code>.
|
||
</p>
|
||
|
||
<p v-if="formError" class="form-error">⚠ {{ formError }}</p>
|
||
</div>
|
||
<CodeReference class="reference" context="macro" @insert="insertSnippet" />
|
||
</div>
|
||
|
||
<template #footer>
|
||
<template v-if="mode === 'list'">
|
||
<button class="primary" @click="openNew">+ Neues Makro</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-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.run {
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
font-weight: 600;
|
||
}
|
||
.mini.run:hover {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
}
|
||
.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);
|
||
}
|
||
.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: 90px;
|
||
max-height: none;
|
||
}
|
||
.editor .formula-input {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 12.5px;
|
||
white-space: pre;
|
||
overflow-x: auto;
|
||
}
|
||
.form-error {
|
||
margin: 4px 0 0;
|
||
font-size: 12.5px;
|
||
color: #dc2626;
|
||
line-height: 1.4;
|
||
}
|
||
</style>
|