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,123 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import AppToolbar from './components/AppToolbar.vue';
|
||||
import FormulaBar from './components/FormulaBar.vue';
|
||||
import SheetCanvas from './components/SheetCanvas.vue';
|
||||
import SheetTabs from './components/SheetTabs.vue';
|
||||
import StatusBar from './components/StatusBar.vue';
|
||||
import SettingsPanel from './components/SettingsPanel.vue';
|
||||
import DependencyGraphView from './components/DependencyGraphView.vue';
|
||||
import { useToasts } from './composables/useToasts';
|
||||
import { useDocument } from './store/document';
|
||||
|
||||
const toolbar = ref<InstanceType<typeof AppToolbar> | null>(null);
|
||||
const { toasts, dismiss } = useToasts();
|
||||
const store = useDocument();
|
||||
|
||||
function onCanvasFiles(files: FileList) {
|
||||
if (files[0]) toolbar.value?.handleFile(files[0]);
|
||||
}
|
||||
|
||||
/** Fallback Strg/Cmd+Z/Y when no table grid has focus (e.g. an empty canvas
|
||||
* click) -- TableGrid.vue handles it directly (and correctly defers to a
|
||||
* cell editor's own native undo) whenever a table *does* have focus. Skips
|
||||
* entirely while focus sits in any other text field (rename-sheet, CSV
|
||||
* import, a colour input, ...), so their own native undo keeps working. */
|
||||
function isTextEditable(el: Element | null): boolean {
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA' || (el as HTMLElement).isContentEditable;
|
||||
}
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
const k = e.key.toLowerCase();
|
||||
if (k !== 'z' && k !== 'y') return;
|
||||
if (isTextEditable(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
if (k === 'y' || (k === 'z' && e.shiftKey)) store.redo();
|
||||
else store.undo();
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<AppToolbar ref="toolbar" />
|
||||
<FormulaBar />
|
||||
|
||||
<main class="workspace">
|
||||
<SheetCanvas @files="onCanvasFiles" />
|
||||
<SettingsPanel />
|
||||
<DependencyGraphView />
|
||||
</main>
|
||||
|
||||
<div class="bottom-bar">
|
||||
<SheetTabs />
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
<div class="toasts">
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="toast"
|
||||
:class="t.kind"
|
||||
@click="dismiss(t.id)"
|
||||
>
|
||||
{{ t.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
.bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--surface-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.workspace {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
margin: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.toasts {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 1200;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
max-width: 320px;
|
||||
}
|
||||
.toast.success {
|
||||
border-color: var(--series-2);
|
||||
}
|
||||
.toast.error {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user