Files
wkhandClaude Opus 5.5 e14bada114 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>
2026-09-28 20:09:06 +02:00

130 lines
3.5 KiB
Vue

<script setup lang="ts">
/**
* A clickable canvas button object -- one of the two ways to trigger a
* macro (the other is the "▶ Ausführen" row in MacrosModal.vue). Caption
* (`label`) and colours are edited in the inspector (SettingsPanel.vue);
* which macro it runs is assignable both there and from this component's
* own right-click menu, for a quicker "build a button, wire it up, done"
* loop without leaving the canvas.
*/
import { computed, reactive, ref } from 'vue';
import { useDocument } from '../store/document';
import { useToasts } from '../composables/useToasts';
import ContextMenu from './ContextMenu.vue';
const props = defineProps<{ objId: string }>();
const store = useDocument();
const toasts = useToasts();
const obj = computed(() => {
const o = store.objectById(props.objId);
return o && o.kind === 'button' ? o : undefined;
});
// A macro can now involve a real network round-trip (GET/POST) and even a
// blocking confirm() prompt -- guard against a second click starting a
// second overlapping run while one is still in flight.
const running = ref(false);
async function run() {
const o = obj.value;
if (!o || running.value) return;
if (!o.macroId || !store.doc.macros.some((m) => m.id === o.macroId)) {
toasts.info('Diesem Button ist noch kein Makro zugewiesen — per Rechtsklick oder im Inspektor.');
return;
}
running.value = true;
try {
const result = await store.runMacro(o.macroId);
if (result.ok) toasts.success(result.message);
else toasts.error(result.message);
} finally {
running.value = false;
}
}
const boxStyle = computed(() => {
const o = obj.value;
if (!o) return {};
return {
color: o.fg || 'var(--accent)',
background: o.bg || 'var(--accent-weak)',
} as Record<string, string>;
});
// ---- right-click: run / (re)assign a macro without opening the inspector ----
const menu = reactive({ open: false, x: 0, y: 0 });
function onContextMenu(e: MouseEvent) {
e.preventDefault();
menu.x = e.clientX;
menu.y = e.clientY;
menu.open = true;
}
const menuItems = computed(() => {
const o = obj.value;
if (!o) return [];
const items: { label: string; action: () => void }[] = [];
if (o.macroId) {
items.push({ label: '▶ Makro ausführen', action: run });
}
for (const m of store.doc.macros) {
items.push({
label: `${m.id === o.macroId ? '✓ ' : ''}Makro: ${m.name}`,
action: () => store.setButtonMacroId(o.id, m.id),
});
}
if (store.doc.macros.length === 0) {
items.push({ label: 'Kein Makro vorhanden — erst in „▶ Makros" anlegen', action: () => {} });
}
if (o.macroId) {
items.push({ label: 'Zuweisung entfernen', action: () => store.setButtonMacroId(o.id, null) });
}
return items;
});
</script>
<template>
<div
v-if="obj"
class="btnbox"
:class="{ running }"
:style="boxStyle"
@click="run"
@contextmenu="onContextMenu"
>
<span class="label">{{ running ? '…' : obj.label || 'Button' }}</span>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
</div>
</template>
<style scoped>
.btnbox {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 14px;
font-weight: 600;
font-size: 14px;
text-align: center;
cursor: pointer;
user-select: none;
}
.btnbox.running {
opacity: 0.6;
cursor: default;
}
.btnbox:hover {
filter: brightness(1.06);
}
.btnbox:active {
filter: brightness(0.96);
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>