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>
113 lines
2.5 KiB
Vue
113 lines
2.5 KiB
Vue
<script setup lang="ts">
|
||
import { watch } from 'vue';
|
||
|
||
const props = defineProps<{ open: boolean; title: string; wide?: boolean }>();
|
||
const emit = defineEmits<{ (e: 'close'): void }>();
|
||
|
||
function onKey(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') emit('close');
|
||
}
|
||
watch(
|
||
() => props.open,
|
||
(o) => {
|
||
if (o) document.addEventListener('keydown', onKey);
|
||
else document.removeEventListener('keydown', onKey);
|
||
},
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<Teleport to="body">
|
||
<div v-if="open" class="backdrop" @click.self="emit('close')">
|
||
<div class="modal" :class="{ wide }" role="dialog" aria-modal="true">
|
||
<header>
|
||
<h2>{{ title }}</h2>
|
||
<button class="x" @click="emit('close')">×</button>
|
||
</header>
|
||
<div class="body"><slot /></div>
|
||
<footer><slot name="footer" /></footer>
|
||
</div>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.35);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 900;
|
||
}
|
||
.modal {
|
||
width: min(420px, calc(100vw - 32px));
|
||
background: var(--surface);
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
box-shadow: var(--shadow);
|
||
overflow: hidden;
|
||
/* never taller than the viewport: header/footer stay put, the body scrolls */
|
||
max-height: calc(100dvh - 32px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.modal.wide {
|
||
width: min(920px, calc(100vw - 32px));
|
||
}
|
||
header {
|
||
flex: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
}
|
||
.x {
|
||
border: 0;
|
||
background: transparent;
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
color: var(--text-dim);
|
||
}
|
||
.body {
|
||
padding: 16px;
|
||
font-size: 13px;
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
}
|
||
/* wide dialogs lay out one full-height child (e.g. an editor + side panel)
|
||
that shrinks with the viewport and scrolls its own parts */
|
||
.modal.wide .body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
footer {
|
||
flex: none;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
:slotted(button) {
|
||
border: 1px solid var(--border);
|
||
background: var(--surface);
|
||
border-radius: 8px;
|
||
padding: 6px 14px;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
:slotted(button.primary) {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #fff;
|
||
}
|
||
</style>
|