Files
numolus/app/frontend/components/ShareModal.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

311 lines
9.2 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">
/**
* "Teilen" for the open document. Sharing needs the document on the server,
* which needs an account: a local document is uploaded first (after
* signing in). The owner then manages links ("anyone with the link can
* view / edit") and people (invited by e-mail, with a role).
*/
import { computed, ref, watch } from 'vue';
import { api, type ShareInfo } from '../io/api';
import { useLibrary } from '../store/library';
import { useSession } from '../store/session';
import { useToasts } from '../composables/useToasts';
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();
const toasts = useToasts();
const info = ref<ShareInfo | null>(null);
const busy = ref(false);
const inviteEmail = ref('');
const inviteRole = ref<'viewer' | 'editor'>('editor');
const current = computed(() => library.state.current);
const isServer = computed(() => current.value?.source === 'server');
const isOwner = computed(() => isServer.value && library.currentRole.value === 'owner');
const docPath = computed(() => `/documents/${current.value?.id}`);
async function load() {
info.value = null;
if (!isOwner.value) return;
try {
info.value = await api<ShareInfo>('GET', `${docPath.value}/share`);
} catch (err) {
toasts.error((err as Error).message);
}
}
watch(
() => [props.open, current.value?.id, library.currentRole.value] as const,
([o]) => {
if (o) void load();
},
);
async function call(method: string, path: string, body?: unknown) {
busy.value = true;
try {
info.value = await api<ShareInfo>(method, `${docPath.value}/share${path}`, { body });
return true;
} catch (err) {
toasts.error((err as Error).message);
return false;
} finally {
busy.value = false;
}
}
const linkFor = (role: 'viewer' | 'editor') => info.value?.links.find((l) => l.role === role) ?? null;
const urlFor = (token: string) => `${location.origin}/d/${current.value?.id}#k=${token}`;
async function copy(token: string) {
try {
await navigator.clipboard.writeText(urlFor(token));
toasts.success('Link kopiert.');
} catch {
toasts.error('Kopieren nicht möglich – bitte den Link markieren und kopieren.');
}
}
async function newLink(role: 'viewer' | 'editor', replace: boolean) {
if (replace && !confirm('Neuen Link erzeugen? Der bisherige funktioniert dann nicht mehr.')) return;
if (await call('POST', '/links', { role })) {
const l = linkFor(role);
if (l) await copy(l.token);
}
}
async function revoke(id: number) {
if (confirm('Link deaktivieren? Wer das Dokument nur über diesen Link hatte, verliert den Zugriff.')) {
await call('DELETE', `/links/${id}`);
}
}
async function invite() {
const email = inviteEmail.value.trim();
if (!email) return;
if (await call('POST', '/invitations', { email, role: inviteRole.value })) {
toasts.success(`Einladung an ${email} verschickt.`);
inviteEmail.value = '';
}
}
async function setRole(userId: number, role: string) {
await call('PATCH', `/members/${userId}`, { role });
}
async function removeMember(userId: number, name: string) {
if (confirm(`${name} den Zugriff entziehen?`)) await call('DELETE', `/members/${userId}`);
}
async function uploadAndShare() {
const id = current.value?.id;
if (!id) return;
busy.value = true;
try {
await library.upload([id]);
} finally {
busy.value = false;
}
}
const LINK_ROWS = [
{ role: 'viewer', label: 'Jeder mit dem Link kann ansehen' },
{ role: 'editor', label: 'Jeder mit dem Link kann bearbeiten' },
] as const;
</script>
<template>
<BaseModal :open="open" :title="`Teilen · ${library.currentTitle.value}`" wide @close="emit('close')">
<!-- local document -->
<template v-if="!isServer">
<p v-if="!session.signedIn.value" class="lead">
Dieses Dokument liegt nur in diesem Browser. Zum Teilen brauchst du ein Konto – danach wird es in dein
Konto hochgeladen.
</p>
<p v-else class="lead">
Dieses Dokument liegt nur in diesem Browser. Zum Teilen wird es in dein Konto hochgeladen (und hier aus
dem Browser entfernt).
</p>
</template>
<!-- server document, not the owner -->
<p v-else-if="!isOwner" class="lead">
Dieses Dokument gehört {{ library.state.server?.owner_name }}. Nur der Besitzer kann es teilen.
</p>
<!-- owner -->
<template v-else-if="info">
<h3>Link</h3>
<ul class="rows">
<li v-for="r in LINK_ROWS" :key="r.role" class="link-row">
<span class="label">{{ r.label }}</span>
<template v-if="linkFor(r.role)">
<input class="url" readonly :value="urlFor(linkFor(r.role)!.token)" @focus="($event.target as HTMLInputElement).select()" />
<span class="actions">
<button class="mini" @click="copy(linkFor(r.role)!.token)">Kopieren</button>
<button class="mini" :disabled="busy" @click="newLink(r.role, true)">Neu</button>
<button class="mini danger" :disabled="busy" @click="revoke(linkFor(r.role)!.id)">Deaktivieren</button>
</span>
</template>
<span v-else class="actions">
<button class="mini" :disabled="busy" @click="newLink(r.role, false)">Link erstellen</button>
</span>
</li>
</ul>
<p class="hint">Links funktionieren auch ohne Konto.</p>
<h3>Personen</h3>
<form class="invite" @submit.prevent="invite">
<input v-model="inviteEmail" type="email" placeholder="E-Mail-Adresse" required />
<select v-model="inviteRole">
<option value="editor">kann bearbeiten</option>
<option value="viewer">kann ansehen</option>
</select>
<button class="primary" type="submit" :disabled="busy || !inviteEmail.trim()">Einladen</button>
</form>
<ul class="rows">
<li class="person">
<span class="who">
<strong>{{ session.state.user?.name }}</strong>
<span class="dim">{{ session.state.user?.email }}</span>
</span>
<span class="dim">Besitzer</span>
</li>
<li v-for="m in info.members" :key="m.user_id" class="person">
<span class="who">
<strong>{{ m.name }}</strong>
<span class="dim">{{ m.email }}{{ m.source === 'link' ? ' · über Link' : '' }}</span>
</span>
<select :value="m.role" :disabled="busy" @change="setRole(m.user_id, ($event.target as HTMLSelectElement).value)">
<option value="editor">kann bearbeiten</option>
<option value="viewer">kann ansehen</option>
</select>
<button class="mini danger" :disabled="busy" @click="removeMember(m.user_id, m.name)">Entfernen</button>
</li>
<li v-for="i in info.invitations" :key="i.email + i.expires_at" class="person">
<span class="who">
<strong>{{ i.email }}</strong>
<span class="dim">eingeladen · {{ i.role === 'editor' ? 'kann bearbeiten' : 'kann ansehen' }}</span>
</span>
</li>
</ul>
</template>
<p v-else class="lead">Lade …</p>
<template #footer>
<button @click="emit('close')">Schließen</button>
<button v-if="!isServer && !session.signedIn.value" class="primary" @click="emit('login')">Anmelden</button>
<button v-else-if="!isServer" class="primary" :disabled="busy" @click="uploadAndShare">
Hochladen und teilen
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
h3 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
h3:not(:first-child) {
margin-top: 18px;
}
.hint {
margin: 6px 0 0;
font-size: 12px;
color: var(--text-dim);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.link-row,
.person {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
background: var(--surface-2);
font-size: 13px;
}
.label {
flex: 1 1 200px;
}
.url {
flex: 1 1 220px;
min-width: 0;
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 7px;
font: inherit;
font-size: 12px;
background: var(--surface);
color: var(--text-dim);
}
.actions {
display: flex;
gap: 6px;
margin-left: auto;
}
.who {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.dim {
color: var(--text-dim);
font-size: 12px;
}
.invite {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.invite input {
flex: 1 1 200px;
}
.invite input,
select {
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 8px;
font: inherit;
font-size: 13px;
background: var(--surface-2);
color: var(--text);
}
.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>