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,208 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The sheet canvas: a pan/scroll/zoom surface that hosts table and chart
|
||||
* objects. Empty-space click deselects; objects come to front on select.
|
||||
* Files dropped anywhere on the canvas are handed to the import pipeline.
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { DEFAULT_GRID_SIZE } from '../store/types';
|
||||
import CanvasObject from './CanvasObject.vue';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
|
||||
const emit = defineEmits<{ (e: 'files', files: FileList): void }>();
|
||||
|
||||
const store = useDocument();
|
||||
const sheet = computed(() => store.activeSheet.value);
|
||||
|
||||
const zoom = computed(() => store.activeZoom.value);
|
||||
const setZoom = store.setZoom;
|
||||
|
||||
const dropActive = ref(false);
|
||||
|
||||
/** Per-sheet appearance -> CSS custom properties scoped to this canvas. */
|
||||
const appearance = computed(() => {
|
||||
const s: Record<string, string> = {};
|
||||
if (sheet.value.background) s['--bg'] = sheet.value.background;
|
||||
if (sheet.value.foreground) {
|
||||
s['--text'] = sheet.value.foreground;
|
||||
s['--text-dim'] = sheet.value.foreground;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const showGrid = computed(() => sheet.value.showGrid !== false);
|
||||
/** Also the snap increment CanvasObject.vue moves/resizes against -- see
|
||||
* its own doc comment. */
|
||||
const gridSize = computed(() => sheet.value.gridSize ?? DEFAULT_GRID_SIZE);
|
||||
/** Scaled by zoom (unlike the rest of `.canvas-scroll`, which isn't) so the
|
||||
* visible dots always land exactly where an object will actually snap --
|
||||
* otherwise a zoomed-in/out grid would visually lie about the snap. */
|
||||
const gridStyle = computed(() => {
|
||||
const px = gridSize.value * zoom.value;
|
||||
return { backgroundSize: `${px}px ${px}px` };
|
||||
});
|
||||
|
||||
/** Unscaled plane size: enclose every object with generous margin.
|
||||
* A `fixed`-viewport table only ever occupies its own `view.width`/
|
||||
* `view.height` on the canvas (that's the entire point of "feste Größe" --
|
||||
* a bounded, internally-scrolling box) regardless of how many rows/cols it
|
||||
* holds; only an `auto`-size table actually grows with its content, so only
|
||||
* that case falls back to the row/col-count estimate. */
|
||||
const plane = computed(() => {
|
||||
let w = 1600;
|
||||
let h = 1000;
|
||||
for (const o of sheet.value.objects) {
|
||||
let ow: number;
|
||||
let oh: number;
|
||||
if (o.kind === 'table' && o.view.mode === 'fixed') {
|
||||
ow = o.view.width;
|
||||
oh = o.view.height;
|
||||
} else {
|
||||
ow = o.kind === 'table' ? o.cols * 104 + 60 : o.width;
|
||||
oh = o.kind === 'table' ? o.rows * 28 + 60 : o.height;
|
||||
}
|
||||
w = Math.max(w, o.x + ow + 500);
|
||||
h = Math.max(h, o.y + oh + 400);
|
||||
}
|
||||
return { w, h };
|
||||
});
|
||||
|
||||
function onPlaneMouseDown(e: MouseEvent) {
|
||||
if ((e.target as HTMLElement).classList.contains('plane')) store.selectObject(null);
|
||||
}
|
||||
function onWheel(e: WheelEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
e.preventDefault();
|
||||
setZoom(zoom.value - Math.sign(e.deltaY) * 0.1);
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
const o = store.activeObject.value;
|
||||
if (!o) return;
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && o.kind === 'chart') {
|
||||
e.preventDefault();
|
||||
store.deleteObject(o.id);
|
||||
}
|
||||
}
|
||||
function onDrop(e: DragEvent) {
|
||||
dropActive.value = false;
|
||||
if (e.dataTransfer?.files?.length) emit('files', e.dataTransfer.files);
|
||||
}
|
||||
|
||||
function selectObj(id: string) {
|
||||
store.selectObject(id);
|
||||
store.bringToFront(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="canvas-wrap"
|
||||
:class="{ 'drawer-open': store.ui.settingsOpen, 'dep-open': store.ui.depGraph }"
|
||||
:style="appearance"
|
||||
>
|
||||
<div
|
||||
class="canvas-scroll"
|
||||
:class="{ 'no-grid': !showGrid }"
|
||||
:style="gridStyle"
|
||||
tabindex="0"
|
||||
@wheel="onWheel"
|
||||
@keydown="onKeydown"
|
||||
@dragover.prevent="dropActive = true"
|
||||
@dragleave="dropActive = false"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="zoom-layer" :style="{ width: plane.w * zoom + 'px', height: plane.h * zoom + 'px' }">
|
||||
<div
|
||||
class="plane"
|
||||
:style="{ width: plane.w + 'px', height: plane.h + 'px', transform: `scale(${zoom})` }"
|
||||
@mousedown="onPlaneMouseDown"
|
||||
>
|
||||
<CanvasObject
|
||||
v-for="o in sheet.objects"
|
||||
:key="o.id"
|
||||
:object="o"
|
||||
:zoom="zoom"
|
||||
:selected="store.doc.activeObjectId === o.id"
|
||||
@select="selectObj(o.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sheet.objects.length === 0" class="hint">
|
||||
<TheLogo word class="hint-logo" />
|
||||
<p>Leeres Blatt — über die Toolbar „Tabelle", „Diagramm" oder „Text" einfügen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dropActive" class="drop">Datei ablegen (.num .json .csv .xlsx)</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
transition: margin-left 0.18s ease, margin-right 0.18s ease;
|
||||
}
|
||||
.canvas-wrap.drawer-open {
|
||||
margin-left: 320px;
|
||||
}
|
||||
.canvas-wrap.dep-open {
|
||||
margin-right: 380px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.canvas-wrap.drawer-open {
|
||||
margin-left: 0; /* drawer overlays on narrow screens */
|
||||
}
|
||||
.canvas-wrap.dep-open {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
.canvas-scroll {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
background-color: var(--bg);
|
||||
/* grid dots use a fixed neutral so they read on any custom backdrop --
|
||||
background-size is set inline (gridStyle) from the sheet's gridSize */
|
||||
background-image:
|
||||
linear-gradient(rgba(128, 128, 128, 0.22) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(128, 128, 128, 0.22) 1px, transparent 1px);
|
||||
background-position: 0 0;
|
||||
outline: none;
|
||||
}
|
||||
.canvas-scroll.no-grid {
|
||||
background-image: none;
|
||||
}
|
||||
.plane {
|
||||
position: relative;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 44px;
|
||||
left: 44px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hint p {
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.hint-logo {
|
||||
height: 62px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.drop {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
padding: 14px 20px;
|
||||
border: 2px dashed var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
z-index: 20;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user