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,322 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* "Dokumente" -- every document the user can open, in up to three groups:
|
||||
* their account's documents (server), documents shared with them (server:
|
||||
* invitations, links), and documents stored only in this browser
|
||||
* (IndexedDB). Open, rename, duplicate, delete/leave, upload, create.
|
||||
*/
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useLibrary, type DocRef } from '../store/library';
|
||||
import { useSession } from '../store/session';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'login'): void }>();
|
||||
|
||||
const library = useLibrary();
|
||||
const session = useSession();
|
||||
|
||||
interface Row {
|
||||
ref: DocRef;
|
||||
title: string;
|
||||
updatedAt: number;
|
||||
note: string;
|
||||
canRename: boolean;
|
||||
canDuplicate: boolean;
|
||||
removeLabel: string;
|
||||
canUpload: boolean;
|
||||
}
|
||||
|
||||
const ROLE_NOTE = { owner: '', editor: 'bearbeiten', viewer: 'nur lesen' } as const;
|
||||
|
||||
const groups = computed(() => {
|
||||
const own: Row[] = [];
|
||||
const shared: Row[] = [];
|
||||
for (const d of library.state.serverDocs) {
|
||||
const row: Row = {
|
||||
ref: { source: 'server', id: d.id },
|
||||
title: d.title,
|
||||
updatedAt: Date.parse(d.updated_at),
|
||||
note: d.role === 'owner' ? '' : `von ${d.owner_name} · ${ROLE_NOTE[d.role]}`,
|
||||
canRename: d.role !== 'viewer',
|
||||
canDuplicate: true,
|
||||
removeLabel: d.role === 'owner' ? 'Löschen' : 'Entfernen',
|
||||
canUpload: false,
|
||||
};
|
||||
(d.role === 'owner' ? own : shared).push(row);
|
||||
}
|
||||
for (const l of library.state.linkDocs) {
|
||||
shared.push({
|
||||
ref: { source: 'server', id: l.id },
|
||||
title: l.title,
|
||||
updatedAt: 0,
|
||||
note: `per Link · ${ROLE_NOTE[l.role]}`,
|
||||
canRename: false,
|
||||
canDuplicate: false,
|
||||
removeLabel: 'Entfernen',
|
||||
canUpload: false,
|
||||
});
|
||||
}
|
||||
const local: Row[] = library.state.docs.map((d) => ({
|
||||
ref: { source: 'local', id: d.id },
|
||||
title: d.title,
|
||||
updatedAt: d.updatedAt,
|
||||
note: '',
|
||||
canRename: true,
|
||||
canDuplicate: true,
|
||||
removeLabel: 'Löschen',
|
||||
canUpload: session.signedIn.value,
|
||||
}));
|
||||
return [
|
||||
{ key: 'own', title: 'Mein Konto', rows: own, show: session.signedIn.value },
|
||||
{ key: 'shared', title: 'Mit mir geteilt', rows: shared, show: shared.length > 0 },
|
||||
{
|
||||
key: 'local',
|
||||
title: session.signedIn.value ? 'Nur in diesem Browser' : 'In diesem Browser',
|
||||
rows: local,
|
||||
show: library.state.available && (local.length > 0 || !session.signedIn.value),
|
||||
},
|
||||
].filter((g) => g.show);
|
||||
});
|
||||
|
||||
const isCurrent = (r: DocRef) =>
|
||||
library.state.current?.source === r.source && library.state.current.id === r.id;
|
||||
const keyOf = (r: DocRef) => `${r.source}:${r.id}`;
|
||||
|
||||
const renaming = ref<string | null>(null);
|
||||
const renameValue = ref('');
|
||||
const renameInput = ref<HTMLInputElement[] | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(o) => {
|
||||
if (o) {
|
||||
renaming.value = null;
|
||||
void library.refreshServerList();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
function when(ms: number): string {
|
||||
if (!ms) return '';
|
||||
const diff = Date.now() - ms;
|
||||
if (diff < 60_000) return 'gerade eben';
|
||||
if (diff < 3_600_000) return `vor ${Math.floor(diff / 60_000)} Min.`;
|
||||
return fmt.format(ms);
|
||||
}
|
||||
function subline(r: Row): string {
|
||||
return [isCurrent(r.ref) ? 'geöffnet' : '', r.note, when(r.updatedAt)].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
async function openDoc(r: Row) {
|
||||
if (renaming.value) return;
|
||||
if (await library.open(r.ref)) emit('close');
|
||||
}
|
||||
async function startRename(r: Row) {
|
||||
renaming.value = keyOf(r.ref);
|
||||
renameValue.value = r.title;
|
||||
await nextTick();
|
||||
renameInput.value?.[0]?.select();
|
||||
}
|
||||
async function commitRename(r: Row) {
|
||||
if (renaming.value !== keyOf(r.ref)) return;
|
||||
renaming.value = null;
|
||||
if (renameValue.value.trim() && renameValue.value !== r.title) await library.rename(r.ref, renameValue.value);
|
||||
}
|
||||
async function remove(r: Row) {
|
||||
const q =
|
||||
r.removeLabel === 'Löschen'
|
||||
? `„${r.title}" endgültig löschen?`
|
||||
: `„${r.title}" aus deiner Liste entfernen? Du verlierst den Zugriff, bis es wieder mit dir geteilt wird.`;
|
||||
if (confirm(q)) await library.remove(r.ref);
|
||||
}
|
||||
async function upload(r: Row) {
|
||||
await library.upload([r.ref.id]);
|
||||
}
|
||||
async function createNew() {
|
||||
await library.createBlank();
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="open" title="Dokumente" wide @close="emit('close')">
|
||||
<p v-if="!library.state.available && !session.signedIn.value" class="hint">
|
||||
In diesem Browser können keine Dokumente gespeichert werden (IndexedDB nicht verfügbar, z. B. im
|
||||
privaten Modus). Mit einem Konto werden Dokumente auf dem Server gespeichert.
|
||||
</p>
|
||||
|
||||
<section v-for="g in groups" :key="g.key" class="group">
|
||||
<h3>{{ g.title }}</h3>
|
||||
<p v-if="!g.rows.length" class="empty">Noch keine Dokumente.</p>
|
||||
<ul v-else class="doc-list">
|
||||
<li v-for="r in g.rows" :key="keyOf(r.ref)" class="doc-row" :class="{ current: isCurrent(r.ref) }">
|
||||
<input
|
||||
v-if="renaming === keyOf(r.ref)"
|
||||
ref="renameInput"
|
||||
v-model="renameValue"
|
||||
class="rename"
|
||||
type="text"
|
||||
@keydown.enter.prevent="commitRename(r)"
|
||||
@keydown.esc.stop.prevent="renaming = null"
|
||||
@blur="commitRename(r)"
|
||||
/>
|
||||
<button v-else class="doc-open" :title="`${r.title} öffnen`" @click="openDoc(r)">
|
||||
<span class="doc-name">{{ r.title }}</span>
|
||||
<span class="doc-when">{{ subline(r) }}</span>
|
||||
</button>
|
||||
<span class="doc-actions">
|
||||
<button v-if="r.canUpload" class="mini" title="In dein Konto hochladen (danach teilbar)" @click="upload(r)">
|
||||
Hochladen
|
||||
</button>
|
||||
<button v-if="r.canRename" class="mini" @click="startRename(r)">Umbenennen</button>
|
||||
<button v-if="r.canDuplicate" class="mini" @click="library.duplicate(r.ref)">Duplizieren</button>
|
||||
<button class="mini danger" @click="remove(r)">{{ r.removeLabel }}</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!session.signedIn.value" class="hint">
|
||||
Dokumente werden nur in diesem Browser gespeichert.
|
||||
<button class="linkish" @click="emit('login')">Anmelden</button>, um sie auf allen Geräten zu haben und zu
|
||||
teilen.
|
||||
</p>
|
||||
|
||||
<template #footer>
|
||||
<button @click="emit('close')">Schließen</button>
|
||||
<button class="primary" :disabled="!library.state.available && !session.signedIn.value" @click="createNew">
|
||||
+ Neues Dokument
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group + .group {
|
||||
margin-top: 16px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.linkish {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: min(40vh, 360px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.doc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.doc-row.current {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.doc-open {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 4px 6px;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.doc-open:hover .doc-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
.doc-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.doc-when {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.rename {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
.doc-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.doc-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.doc-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 6px;
|
||||
padding: 3px 9px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
.mini:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.mini.danger:hover {
|
||||
border-color: #dc2626;
|
||||
color: #dc2626;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user