974 lines
28 KiB
Vue
974 lines
28 KiB
Vue
<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(() =>
|
||
library.state.current?.source === 'server' && !library.online.value
|
||
? 'Offline – Änderungen werden übertragen, sobald die Verbindung zurück ist'
|
||
: '',
|
||
);
|
||
|
||
// ---- 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">Nur Lesen</span>
|
||
<span v-if="statusNote" class="status-note" :title="statusNote">Offline</span>
|
||
<span v-if="library.peers.value.length" class="peers" :title="library.peers.value.map((p) => p.name).join(', ')">
|
||
<span
|
||
v-for="p in library.peers.value.slice(0, 5)"
|
||
:key="p.clientId"
|
||
class="avatar peer"
|
||
:style="{ background: p.color }"
|
||
>{{ p.name.slice(0, 1).toUpperCase() }}</span>
|
||
<span v-if="library.peers.value.length > 5" class="more">+{{ library.peers.value.length - 5 }}</span>
|
||
</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 {
|
||
padding: 3px 9px;
|
||
border-radius: 999px;
|
||
background: #fef3c7;
|
||
color: #92400e;
|
||
font-weight: 600;
|
||
cursor: help;
|
||
}
|
||
.peers {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
}
|
||
.peer {
|
||
margin-left: -5px;
|
||
box-shadow: 0 0 0 2px var(--surface);
|
||
cursor: help;
|
||
}
|
||
.peer:first-child {
|
||
margin-left: 0;
|
||
}
|
||
.more {
|
||
margin-left: 4px;
|
||
font-size: 12px;
|
||
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>
|