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,944 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import CustomFunctionsModal from './CustomFunctionsModal.vue';
|
||||
import NamedRangesModal from './NamedRangesModal.vue';
|
||||
import MacrosModal from './MacrosModal.vue';
|
||||
import DocumentsModal from './DocumentsModal.vue';
|
||||
import LoginModal from './LoginModal.vue';
|
||||
import ShareModal from './ShareModal.vue';
|
||||
import UploadModal from './UploadModal.vue';
|
||||
import { useLibrary } from '../store/library';
|
||||
import { useSession } from '../store/session';
|
||||
import { parseDocument } from '../io/json';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
import ToolbarIcon, { type ToolbarIconName } from './ToolbarIcon.vue';
|
||||
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
||||
import {
|
||||
exportJson,
|
||||
exportNum,
|
||||
readNumFile,
|
||||
exportActiveTableCsv,
|
||||
exportXlsx,
|
||||
importJsonReplace,
|
||||
importJsonAppend,
|
||||
importCsv,
|
||||
importXlsxFile,
|
||||
readAsText,
|
||||
readAsArrayBuffer,
|
||||
extensionOf,
|
||||
type CsvImportConfig,
|
||||
} from '../io/operations';
|
||||
import type { Delimiter } from '../io/csv';
|
||||
|
||||
const store = useDocument();
|
||||
const library = useLibrary();
|
||||
const session = useSession();
|
||||
const { mode: themeMode, cycle: cycleTheme } = useTheme();
|
||||
const toasts = useToasts();
|
||||
|
||||
const fileMenuOpen = ref(false);
|
||||
|
||||
// ---- wordmark only when there's room ----------------------------------
|
||||
// "numolus" + "wicom" next to the logo are shown only while the whole
|
||||
// toolbar still fits on one line with them; otherwise just the logo. Measured
|
||||
// rather than a fixed breakpoint, since what fits depends on the toolbar's
|
||||
// actual contents. The wordmark's own width is remembered from the last time
|
||||
// it was visible, so hiding it can't make it look like it would fit again
|
||||
// (no flip-flopping at the threshold).
|
||||
const toolbarEl = ref<HTMLElement | null>(null);
|
||||
const brandExtra = ref<HTMLElement | null>(null);
|
||||
const showWordmark = ref(true);
|
||||
let wordmarkWidth = 0;
|
||||
|
||||
function fitWordmark() {
|
||||
const bar = toolbarEl.value;
|
||||
if (!bar) return;
|
||||
const cs = getComputedStyle(bar);
|
||||
const avail = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
const gap = parseFloat(cs.columnGap) || 0;
|
||||
let need = 0;
|
||||
let items = 0;
|
||||
for (const child of Array.from(bar.children) as HTMLElement[]) {
|
||||
if (getComputedStyle(child).display === 'none') continue;
|
||||
items++;
|
||||
if (!child.classList.contains('spacer')) need += child.getBoundingClientRect().width;
|
||||
}
|
||||
need += gap * Math.max(0, items - 1);
|
||||
if (showWordmark.value && brandExtra.value) {
|
||||
// plus the gap to the logo, which goes away with it
|
||||
const brandGap = parseFloat(getComputedStyle(brandExtra.value.parentElement!).columnGap) || 0;
|
||||
wordmarkWidth = brandExtra.value.getBoundingClientRect().width + brandGap;
|
||||
} else {
|
||||
need += wordmarkWidth;
|
||||
}
|
||||
showWordmark.value = need <= avail + 0.5;
|
||||
}
|
||||
|
||||
let resizeObs: ResizeObserver | null = null;
|
||||
onMounted(() => {
|
||||
resizeObs = new ResizeObserver(() => fitWordmark());
|
||||
if (toolbarEl.value) resizeObs.observe(toolbarEl.value);
|
||||
fitWordmark();
|
||||
// web fonts arriving late change the measured widths
|
||||
void document.fonts?.ready.then(fitWordmark);
|
||||
});
|
||||
onBeforeUnmount(() => resizeObs?.disconnect());
|
||||
const fileMenuWrap = ref<HTMLElement | null>(null);
|
||||
|
||||
// Close the Datei menu on Escape or on a press anywhere outside it. Capture
|
||||
// phase, so canvas/grid handlers that stop propagation can't swallow it.
|
||||
function onOutsidePointer(e: PointerEvent) {
|
||||
if (!fileMenuWrap.value?.contains(e.target as Node)) fileMenuOpen.value = false;
|
||||
}
|
||||
function onMenuKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') fileMenuOpen.value = false;
|
||||
}
|
||||
function detachMenuListeners() {
|
||||
document.removeEventListener('pointerdown', onOutsidePointer, true);
|
||||
document.removeEventListener('keydown', onMenuKey, true);
|
||||
}
|
||||
watch(fileMenuOpen, (open) => {
|
||||
if (open) {
|
||||
document.addEventListener('pointerdown', onOutsidePointer, true);
|
||||
document.addEventListener('keydown', onMenuKey, true);
|
||||
} else {
|
||||
detachMenuListeners();
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(detachMenuListeners);
|
||||
const aboutOpen = ref(false);
|
||||
const functionsOpen = ref(false);
|
||||
const namedRangesOpen = ref(false);
|
||||
const macrosOpen = ref(false);
|
||||
const documentsOpen = ref(false);
|
||||
const shareOpen = ref(false);
|
||||
const uploadOpen = ref(false);
|
||||
const loginOpen = ref(false);
|
||||
const loginReason = ref<string | undefined>();
|
||||
const accountMenuOpen = ref(false);
|
||||
|
||||
function openLogin(reason?: string) {
|
||||
loginReason.value = reason;
|
||||
documentsOpen.value = false;
|
||||
shareOpen.value = false;
|
||||
loginOpen.value = true;
|
||||
}
|
||||
|
||||
// after signing in: offer once (per account, per browser) to move this
|
||||
// browser's documents into the account
|
||||
onMounted(() => {
|
||||
const user = session.state.user;
|
||||
if (!user || !library.state.docs.length) return;
|
||||
const key = `numolus:upload-asked:${user.id}`;
|
||||
try {
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, '1');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
uploadOpen.value = true;
|
||||
});
|
||||
|
||||
const statusNote = computed(() => {
|
||||
if (library.state.syncState === 'offline') return 'Offline – wird später gespeichert';
|
||||
if (library.state.syncState === 'saving') return 'Speichert …';
|
||||
return '';
|
||||
});
|
||||
|
||||
// ---- document title (inline rename) ------------------------------------
|
||||
const titleDraft = ref(library.currentTitle.value);
|
||||
watch(library.currentTitle, (t) => (titleDraft.value = t));
|
||||
function commitTitle() {
|
||||
const ref = library.state.current;
|
||||
const t = titleDraft.value.trim();
|
||||
if (!ref || !t) titleDraft.value = library.currentTitle.value;
|
||||
else if (t !== library.currentTitle.value) void library.rename(ref, t);
|
||||
}
|
||||
const busy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const addr = `${WICOM.address.street}, ${WICOM.address.city}, ${WICOM.address.country}`;
|
||||
|
||||
const themeLabel = computed(
|
||||
() => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value],
|
||||
);
|
||||
const themeIcon = computed(
|
||||
() => ({ system: 'auto', light: 'sun', dark: 'moon' })[themeMode.value] as ToolbarIconName,
|
||||
);
|
||||
|
||||
// ---- insert objects ------------------------------------------------
|
||||
function insertTable() {
|
||||
store.addTable(store.doc.activeSheetId);
|
||||
toasts.info('Tabelle eingefügt.');
|
||||
}
|
||||
function insertChart() {
|
||||
const id = store.addChart(store.doc.activeSheetId);
|
||||
if (!id) toasts.error('Diagramm konnte nicht eingefügt werden.');
|
||||
}
|
||||
function insertText() {
|
||||
store.addText(store.doc.activeSheetId);
|
||||
toasts.info('Textfeld eingefügt.');
|
||||
}
|
||||
function insertButton() {
|
||||
store.addButton(store.doc.activeSheetId);
|
||||
toasts.info('Button eingefügt.');
|
||||
}
|
||||
|
||||
// ---- CSV export dialog -------------------------------------------
|
||||
const csvExportOpen = ref(false);
|
||||
const csvExport = ref<{ delimiter: Delimiter; bom: boolean; crlf: boolean }>({
|
||||
delimiter: ',',
|
||||
bom: false,
|
||||
crlf: true,
|
||||
});
|
||||
function openCsvExport() {
|
||||
csvExport.value.delimiter = store.doc.csvDelimiter; // start from the document default
|
||||
csvExportOpen.value = true;
|
||||
}
|
||||
function runCsvExport() {
|
||||
try {
|
||||
exportActiveTableCsv(store, csvExport.value);
|
||||
csvExportOpen.value = false;
|
||||
toasts.success('CSV exportiert.');
|
||||
} catch (err) {
|
||||
toasts.error((err as Error).message);
|
||||
}
|
||||
}
|
||||
async function runNumExport() {
|
||||
try {
|
||||
await exportNum(store, library.currentTitle.value);
|
||||
toasts.success('Dokument als .num gespeichert.');
|
||||
} catch (err) {
|
||||
toasts.error(`Speichern fehlgeschlagen: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runJsonExport() {
|
||||
exportJson(store, library.currentTitle.value);
|
||||
toasts.success('Dokument als JSON exportiert.');
|
||||
}
|
||||
async function runXlsxExport() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await exportXlsx(store, library.currentTitle.value);
|
||||
toasts.success('Arbeitsmappe als XLSX exportiert.');
|
||||
} catch (err) {
|
||||
toasts.error(`XLSX-Export fehlgeschlagen: ${(err as Error).message}`);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
function exportSelectedChartPng() {
|
||||
const o = store.activeObject.value;
|
||||
if (o?.kind !== 'chart') {
|
||||
toasts.error('Kein Diagramm ausgewählt — Diagramm anklicken, dann exportieren.');
|
||||
return;
|
||||
}
|
||||
// ChartView listens for this via a window event keyed by object id
|
||||
window.dispatchEvent(new CustomEvent('export-chart-png', { detail: o.id }));
|
||||
}
|
||||
|
||||
// ---- import -----------------------------------------------------
|
||||
type ImportCtx =
|
||||
| { kind: 'json'; text: string; fileName: string }
|
||||
| ({ kind: 'csv'; text: string; fileName: string } & CsvImportConfig)
|
||||
| { kind: 'xlsx'; buffer: ArrayBuffer; fileName: string };
|
||||
|
||||
const importCtx = ref<ImportCtx | null>(null);
|
||||
const importOpen = ref(false);
|
||||
|
||||
function pickFile() {
|
||||
fileMenuOpen.value = false;
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
async function onFileChosen(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file) await handleFile(file);
|
||||
}
|
||||
|
||||
/** Also called from the canvas drag & drop. */
|
||||
async function handleFile(file: File) {
|
||||
const ext = extensionOf(file.name);
|
||||
try {
|
||||
if (ext === 'json') {
|
||||
importCtx.value = { kind: 'json', text: await readAsText(file), fileName: file.name };
|
||||
} else if (ext === 'num') {
|
||||
// a zipped JSON document -- unpacked, then the same replace/append choice
|
||||
importCtx.value = { kind: 'json', text: await readNumFile(await readAsArrayBuffer(file)), fileName: file.name };
|
||||
} else if (ext === 'csv') {
|
||||
importCtx.value = {
|
||||
kind: 'csv',
|
||||
text: await readAsText(file),
|
||||
fileName: file.name,
|
||||
mode: 'new-table',
|
||||
delimiter: 'auto',
|
||||
detectNumbers: true,
|
||||
name: file.name.replace(/\.csv$/i, ''),
|
||||
};
|
||||
} else if (ext === 'xlsx') {
|
||||
importCtx.value = {
|
||||
kind: 'xlsx',
|
||||
buffer: await readAsArrayBuffer(file),
|
||||
fileName: file.name,
|
||||
};
|
||||
} else {
|
||||
toasts.error(`Nicht unterstützter Dateityp: .${ext}`);
|
||||
return;
|
||||
}
|
||||
importOpen.value = true;
|
||||
} catch (err) {
|
||||
toasts.error((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ handleFile });
|
||||
|
||||
async function doImport(action: 'new' | 'replace' | 'append' | 'confirm') {
|
||||
const ctx = importCtx.value;
|
||||
if (!ctx) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
if (ctx.kind === 'json') {
|
||||
if (action === 'new') {
|
||||
await library.create(parseDocument(ctx.text), ctx.fileName.replace(/\.(num|json)$/i, ''));
|
||||
} else if (action === 'replace') {
|
||||
if (!confirm('Aktuelles Dokument vollständig ersetzen?')) return;
|
||||
importJsonReplace(store, ctx.text);
|
||||
} else {
|
||||
importJsonAppend(store, ctx.text);
|
||||
}
|
||||
} else if (ctx.kind === 'csv') {
|
||||
await importCsv(store, ctx.text, {
|
||||
mode: ctx.mode,
|
||||
delimiter: ctx.delimiter,
|
||||
detectNumbers: ctx.detectNumbers,
|
||||
name: ctx.name,
|
||||
});
|
||||
} else {
|
||||
await importXlsxFile(store, ctx.buffer);
|
||||
}
|
||||
toasts.success('Import erfolgreich.');
|
||||
importOpen.value = false;
|
||||
importCtx.value = null;
|
||||
} catch (err) {
|
||||
toasts.error(`Import fehlgeschlagen: ${(err as Error).message}`);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onNew() {
|
||||
await library.createBlank();
|
||||
toasts.info('Neues Dokument angelegt.');
|
||||
}
|
||||
|
||||
async function onSample() {
|
||||
await library.createSample();
|
||||
toasts.info('Musterdokument als neues Dokument angelegt.');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header ref="toolbarEl" class="toolbar">
|
||||
<div class="brand" title="Numolus — wicom, Vaduz (FL)">
|
||||
<TheLogo class="brand-mark" />
|
||||
<span v-show="showWordmark" ref="brandExtra" class="brand-extra">
|
||||
<span class="wordmark">numolus</span>
|
||||
<button class="by" type="button" @click="aboutOpen = true">wicom</button>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="titleDraft"
|
||||
class="doc-title"
|
||||
type="text"
|
||||
title="Dokumentname (klicken zum Umbenennen)"
|
||||
aria-label="Dokumentname"
|
||||
:size="Math.max(8, Math.min(28, titleDraft.length + 1))"
|
||||
:readonly="library.currentRole.value === 'viewer'"
|
||||
@keydown.enter.prevent="($event.target as HTMLInputElement).blur()"
|
||||
@keydown.esc.prevent="titleDraft = library.currentTitle.value; ($event.target as HTMLInputElement).blur()"
|
||||
@blur="commitTitle()"
|
||||
/>
|
||||
<button
|
||||
class="gear"
|
||||
:class="{ on: store.ui.settingsOpen }"
|
||||
title="Einstellungen"
|
||||
aria-label="Einstellungen"
|
||||
@click="store.ui.settingsOpen = !store.ui.settingsOpen"
|
||||
>
|
||||
<ToolbarIcon name="settings" />
|
||||
</button>
|
||||
|
||||
<div class="tgroup">
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Rückgängig (Strg/Cmd+Z)"
|
||||
aria-label="Rückgängig"
|
||||
:disabled="!store.canUndo.value"
|
||||
@click="store.undo()"
|
||||
>
|
||||
<ToolbarIcon name="undo" />
|
||||
</button>
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Wiederholen (Strg/Cmd+Y)"
|
||||
aria-label="Wiederholen"
|
||||
:disabled="!store.canRedo.value"
|
||||
@click="store.redo()"
|
||||
>
|
||||
<ToolbarIcon name="redo" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tgroup accent">
|
||||
<button class="ibtn" title="Tabelle einfügen" aria-label="Tabelle einfügen" @click="insertTable">
|
||||
<ToolbarIcon name="table" />
|
||||
</button>
|
||||
<button class="ibtn" title="Diagramm einfügen" aria-label="Diagramm einfügen" @click="insertChart">
|
||||
<ToolbarIcon name="chart" />
|
||||
</button>
|
||||
<button class="ibtn" title="Text einfügen" aria-label="Text einfügen" @click="insertText">
|
||||
<ToolbarIcon name="text" />
|
||||
</button>
|
||||
<button class="ibtn" title="Button einfügen" aria-label="Button einfügen" @click="insertButton">
|
||||
<ToolbarIcon name="button" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tgroup accent">
|
||||
<button
|
||||
class="ibtn fx"
|
||||
title="Benutzerdefinierte Funktionen"
|
||||
aria-label="Benutzerdefinierte Funktionen"
|
||||
@click="functionsOpen = true"
|
||||
>
|
||||
<em>fx</em>
|
||||
</button>
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Benannte Bereiche"
|
||||
aria-label="Benannte Bereiche"
|
||||
@click="namedRangesOpen = true"
|
||||
>
|
||||
<ToolbarIcon name="tag" />
|
||||
</button>
|
||||
<button
|
||||
class="ibtn"
|
||||
title="Makros"
|
||||
aria-label="Makros"
|
||||
@click="macrosOpen = true"
|
||||
>
|
||||
<ToolbarIcon name="macro" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span v-if="store.ui.readOnly" class="ro-badge" :title="store.ui.readOnly">
|
||||
{{ library.state.lockedBy ? `${library.state.lockedBy} bearbeitet` : 'Nur Lesen' }}
|
||||
</span>
|
||||
<span v-else-if="statusNote" class="status-note">{{ statusNote }}</span>
|
||||
|
||||
<div class="spacer" />
|
||||
|
||||
<button class="btn share" title="Dokument teilen" @click="shareOpen = true">Teilen</button>
|
||||
|
||||
<div ref="fileMenuWrap" class="menu-wrap">
|
||||
<button class="btn" @click="fileMenuOpen = !fileMenuOpen">Datei ▾</button>
|
||||
<ul v-if="fileMenuOpen" class="menu" @click="fileMenuOpen = false">
|
||||
<li><button @click="documentsOpen = true">Dokumente …</button></li>
|
||||
<li><button @click="onNew">Neues Dokument</button></li>
|
||||
<li><button @click="onSample">Muster · Umsatzanalyse</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="pickFile">Öffnen / Importieren … (.num .json .csv .xlsx)</button></li>
|
||||
<li><button @click="runNumExport">Speichern · Dokument (.num)</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="runJsonExport">Export · Dokument (.json)</button></li>
|
||||
<li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li>
|
||||
<li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li>
|
||||
<li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="aboutOpen = true">Über Numolus …</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="ibtn"
|
||||
:title="`Theme: ${themeLabel} (klicken zum Wechseln)`"
|
||||
:aria-label="`Theme: ${themeLabel}`"
|
||||
@click="cycleTheme"
|
||||
>
|
||||
<ToolbarIcon :name="themeIcon" />
|
||||
</button>
|
||||
|
||||
<div class="menu-wrap">
|
||||
<button
|
||||
v-if="session.state.user"
|
||||
class="btn account"
|
||||
:title="session.state.user.email"
|
||||
@click="accountMenuOpen = !accountMenuOpen"
|
||||
@blur="accountMenuOpen = false"
|
||||
>
|
||||
<span class="avatar" :style="{ background: session.state.user.color }">{{ session.state.user.name.slice(0, 1).toUpperCase() }}</span>
|
||||
{{ session.state.user.name }} ▾
|
||||
</button>
|
||||
<button v-else class="btn" @click="openLogin()">Anmelden</button>
|
||||
<ul v-if="accountMenuOpen" class="menu" @mousedown.prevent>
|
||||
<li class="menu-note">{{ session.state.user?.email }}</li>
|
||||
<li><button @click="accountMenuOpen = false; documentsOpen = true">Dokumente …</button></li>
|
||||
<li class="sep" />
|
||||
<li><button @click="session.logout()">Abmelden</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" accept=".num,.json,.csv,.xlsx" hidden @change="onFileChosen" />
|
||||
<span v-if="busy" class="busy">Verarbeite …</span>
|
||||
</header>
|
||||
|
||||
<!-- CSV export options -->
|
||||
<BaseModal :open="csvExportOpen" title="CSV exportieren" @close="csvExportOpen = false">
|
||||
<label class="row">
|
||||
Trennzeichen
|
||||
<select v-model="csvExport.delimiter">
|
||||
<option value=",">Komma ,</option>
|
||||
<option value=";">Semikolon ;</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row"><input v-model="csvExport.crlf" type="checkbox" /> Zeilenende CRLF (\r\n)</label>
|
||||
<label class="row"><input v-model="csvExport.bom" type="checkbox" /> UTF-8 BOM (für Excel)</label>
|
||||
<template #footer>
|
||||
<button @click="csvExportOpen = false">Abbrechen</button>
|
||||
<button class="primary" @click="runCsvExport">Exportieren</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Import dialog -->
|
||||
<BaseModal
|
||||
:open="importOpen"
|
||||
:title="`Import · ${importCtx?.fileName ?? ''}`"
|
||||
@close="importOpen = false"
|
||||
>
|
||||
<template v-if="importCtx?.kind === 'json'">
|
||||
<p>Dokument erkannt. Als eigenes Dokument öffnen oder in das aktuelle übernehmen?</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="importCtx?.kind === 'csv'">
|
||||
<label class="row">
|
||||
Ziel
|
||||
<select v-model="importCtx.mode">
|
||||
<option value="new-table">Als neue Tabelle (aktuelles Blatt)</option>
|
||||
<option value="replace-active">Aktive Tabelle ab A1 ersetzen</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="importCtx.mode === 'new-table'" class="row">
|
||||
Name <input v-model="importCtx.name" type="text" />
|
||||
</label>
|
||||
<label class="row">
|
||||
Trennzeichen
|
||||
<select v-model="importCtx.delimiter">
|
||||
<option value="auto">Automatisch erkennen</option>
|
||||
<option value=",">Komma ,</option>
|
||||
<option value=";">Semikolon ;</option>
|
||||
<option :value="'\t'">Tab</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="row">
|
||||
<input v-model="importCtx.detectNumbers" type="checkbox" />
|
||||
Zahlen-/Locale-Erkennung (1.234,56 ↔ 1,234.56)
|
||||
</label>
|
||||
<p class="hint">Werte mit führendem „=" werden als Formel übernommen.</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="importCtx?.kind === 'xlsx'">
|
||||
<p>Arbeitsmappe wird als neue Blätter angehängt – je Worksheet ein Blatt mit einer Tabelle.</p>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<button @click="importOpen = false">Abbrechen</button>
|
||||
<template v-if="importCtx?.kind === 'json'">
|
||||
<button @click="doImport('append')">Anhängen</button>
|
||||
<button @click="doImport('replace')">Ersetzen</button>
|
||||
<button class="primary" @click="doImport('new')">Als neues Dokument</button>
|
||||
</template>
|
||||
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Benutzerdefinierte Funktionen -->
|
||||
<CustomFunctionsModal :open="functionsOpen" @close="functionsOpen = false" />
|
||||
<NamedRangesModal :open="namedRangesOpen" @close="namedRangesOpen = false" />
|
||||
<MacrosModal :open="macrosOpen" @close="macrosOpen = false" />
|
||||
<DocumentsModal :open="documentsOpen" @close="documentsOpen = false" @login="openLogin()" />
|
||||
<ShareModal
|
||||
:open="shareOpen"
|
||||
@close="shareOpen = false"
|
||||
@login="openLogin('Zum Teilen brauchst du ein Konto.')"
|
||||
/>
|
||||
<LoginModal :open="loginOpen" :reason="loginReason" @close="loginOpen = false" />
|
||||
<UploadModal :open="uploadOpen" @close="uploadOpen = false" />
|
||||
|
||||
<!-- Über / Impressum -->
|
||||
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
|
||||
<p class="about-lead">
|
||||
<strong>Numolus</strong> — browserbasierte Tabellenkalkulation nach dem Vorbild von
|
||||
Apple Numbers, mit selbst geschriebener Formel-Engine.
|
||||
</p>
|
||||
|
||||
<dl class="about">
|
||||
<dt>Herausgeber</dt>
|
||||
<dd>
|
||||
<a :href="WICOM.url" target="_blank" rel="noopener noreferrer">{{ WICOM.name }}</a>
|
||||
— {{ WICOM.descriptor }}<br />
|
||||
<span class="dim">„{{ WICOM.tagline }}" · seit {{ WICOM.since }}</span>
|
||||
</dd>
|
||||
|
||||
<dt>Anschrift</dt>
|
||||
<dd>{{ addr }}</dd>
|
||||
|
||||
<dt>Kontakt</dt>
|
||||
<dd>
|
||||
<a :href="`mailto:${WICOM.email}`">{{ WICOM.email }}</a><br />
|
||||
<a :href="WICOM.phoneHref">{{ WICOM.phone }}</a>
|
||||
</dd>
|
||||
|
||||
<dt>Autor</dt>
|
||||
<dd>{{ WICOM.author }}</dd>
|
||||
|
||||
<dt>Social Media</dt>
|
||||
<dd>
|
||||
<template v-if="WICOM_SOCIAL.length">
|
||||
<a
|
||||
v-for="s in WICOM_SOCIAL"
|
||||
:key="s.url"
|
||||
class="social"
|
||||
:href="s.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{ s.label }}</a>
|
||||
</template>
|
||||
<span v-else class="dim">wicom.li führt keine Social-Media-Profile.</span>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<template #footer>
|
||||
<a class="btn-link" :href="WICOM.url" target="_blank" rel="noopener noreferrer">wicom.li ↗</a>
|
||||
<button class="primary" @click="aboutOpen = false">Schließen</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 9px 16px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
.toolbar {
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-right: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.brand-mark {
|
||||
height: 24px;
|
||||
flex: none;
|
||||
}
|
||||
.brand-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.wordmark {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
.by {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.by span {
|
||||
font-weight: 600;
|
||||
}
|
||||
.by:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- Über-Dialog --- */
|
||||
.about-lead {
|
||||
margin: 0 0 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.about {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 6px 14px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.about dt {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.about dd {
|
||||
margin: 0;
|
||||
}
|
||||
.about a {
|
||||
color: var(--accent);
|
||||
}
|
||||
.about .dim {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.about .social {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.btn-link {
|
||||
margin-right: auto;
|
||||
align-self: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ro-badge,
|
||||
.status-note {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ro-badge {
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
cursor: help;
|
||||
}
|
||||
.status-note {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.account {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.menu-note {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.doc-title {
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
padding: 4px 7px;
|
||||
font: inherit;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-title:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.doc-title:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.gear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 9px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.gear:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.gear.on {
|
||||
background: var(--accent-weak);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- grouped icon-button clusters (segmented-control look) --- */
|
||||
.tgroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ibtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.tgroup.accent .ibtn {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ibtn:hover {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.tgroup.accent .ibtn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.ibtn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
.ibtn.fx em {
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
.menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 50;
|
||||
min-width: 300px;
|
||||
margin: 0;
|
||||
padding: 6px;
|
||||
list-style: none;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.menu li.sep {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.menu button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.menu button:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.busy {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.row select,
|
||||
.row input[type='text'] {
|
||||
flex: 1;
|
||||
max-width: 220px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user