Files
numolus/app/frontend/components/DocumentsModal.vue
T
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

323 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>