Files
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

264 lines
7.2 KiB
Vue

<script setup lang="ts">
/**
* One chart object. Chart.js (bar / line) over data derived live from its
* source table: categories = header row (from column B), one series per region
* (column A). Any grid edit that touches those cells re-renders immediately.
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import Chart from 'chart.js/auto';
import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme';
import { downloadDataUrl } from '../io/download';
import { isNumeric, formatValue } from '../store/format';
const props = defineProps<{ objId: string }>();
const store = useDocument();
const { isDarkNow, mode: themeMode } = useTheme();
const obj = computed(() => {
const o = store.objectById(props.objId);
return o && o.kind === 'chart' ? o : undefined;
});
const canvas = ref<HTMLCanvasElement | null>(null);
let chart: Chart | null = null;
function cssVar(n: string) {
return getComputedStyle(document.documentElement).getPropertyValue(n).trim();
}
const palette = () => [
cssVar('--series-1'), cssVar('--series-2'), cssVar('--series-3'),
cssVar('--series-4'), cssVar('--series-5'),
];
const model = computed(() => {
void store.recalcTick.value;
const src = obj.value?.sourceTableId ? store.tableById(obj.value.sourceTableId) : undefined;
if (!src) return { quarters: [] as string[], regions: [] as string[], rows: [] as number[][] };
const quarters: string[] = [];
for (let c = 1; c < src.cols; c++) {
const v = store.cellValue(src.id, c, 0);
if (v === null || v === '') break;
quarters.push(String(v));
}
const regions: string[] = [];
const rows: number[][] = [];
for (let r = 1; r < src.rows; r++) {
const name = store.cellValue(src.id, 0, r);
if (name === null || name === '') break;
regions.push(String(name));
rows.push(
quarters.map((_, i) => {
const cv = store.cellValue(src.id, i + 1, r);
return isNumeric(cv) ? (cv as number) : 0;
}),
);
}
return { quarters, regions, rows };
});
const chartLocale = computed(() => store.effectiveLocale(props.objId));
function valueLabelPlugin(color: string): import('chart.js').Plugin {
return {
id: 'valueLabels',
afterDatasetsDraw(c) {
const { ctx } = c;
ctx.save();
ctx.font = '10px -apple-system, sans-serif';
ctx.fillStyle = color;
ctx.textAlign = 'center';
c.data.datasets.forEach((ds, di) => {
const meta = c.getDatasetMeta(di);
if (meta.hidden) return;
meta.data.forEach((pt, i) => {
const raw = ds.data[i];
if (typeof raw !== 'number') return;
ctx.fillText(formatValue(raw, chartLocale.value), pt.x, pt.y - 6);
});
});
ctx.restore();
},
};
}
function render() {
if (!canvas.value || !obj.value) return;
chart?.destroy();
const { quarters, regions, rows } = model.value;
const colors = palette();
const tick = obj.value.fg || (isDarkNow() ? '#a1a1a6' : '#6e6e73');
const grid = isDarkNow() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
const view = obj.value.view;
chart = new Chart(canvas.value, {
type: view,
data: {
labels: quarters,
datasets: regions.map((region, i) => ({
label: region,
data: rows[i],
backgroundColor: view === 'bar' ? colors[i % colors.length] : colors[i % colors.length] + '33',
borderColor: colors[i % colors.length],
borderWidth: 2,
pointRadius: view === 'line' ? 3 : 0,
tension: 0.3,
fill: false,
})),
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 200 },
plugins: {
legend: { position: 'bottom', labels: { color: tick, boxWidth: 12 } },
tooltip: { enabled: true },
},
scales: {
x: { ticks: { color: tick }, grid: { color: grid } },
y: { ticks: { color: tick }, grid: { color: grid }, beginAtZero: true },
},
},
plugins: [valueLabelPlugin(tick)],
});
}
onMounted(render);
onBeforeUnmount(() => chart?.destroy());
watch(
[
model,
() => obj.value?.view,
() => obj.value?.width,
() => obj.value?.height,
() => obj.value?.fg,
themeMode,
chartLocale,
],
() => {
// resize needs a re-render because maintainAspectRatio is false
render();
},
{ deep: true },
);
const panelStyle = computed(() => (obj.value?.bg ? { background: obj.value.bg } : {}));
function exportPng() {
if (chart) downloadDataUrl(`${obj.value?.name ?? 'diagramm'}.png`, chart.toBase64Image());
}
defineExpose({ exportPng });
// allow the toolbar's "Export gewähltes Diagramm" to reach the active chart
function onExportEvent(e: Event) {
if ((e as CustomEvent).detail === props.objId) exportPng();
}
onMounted(() => window.addEventListener('export-chart-png', onExportEvent));
onBeforeUnmount(() => window.removeEventListener('export-chart-png', onExportEvent));
const tableOptions = computed(() =>
store.doc.sheets.flatMap((s) => s.objects)
.filter((o) => o.kind === 'table')
.map((o) => ({ id: o.id, name: o.name })),
);
</script>
<template>
<div v-if="obj" class="chart-view" :style="panelStyle">
<div class="bar">
<div class="seg">
<button :class="{ on: obj.view === 'bar' }" @click.stop="store.setChartView(objId, 'bar')">Balken</button>
<button :class="{ on: obj.view === 'line' }" @click.stop="store.setChartView(objId, 'line')">Linie</button>
</div>
<select
class="src"
:value="obj.sourceTableId ?? ''"
@change="store.setChartSource(objId, ($event.target as HTMLSelectElement).value)"
@mousedown.stop
>
<option v-for="o in tableOptions" :key="o.id" :value="o.id">{{ o.name }}</option>
</select>
<span class="sp" />
<button class="ghost" @click.stop="exportPng">PNG</button>
</div>
<div class="body">
<canvas ref="canvas" />
<p v-if="model.regions.length === 0" class="empty">Keine Regions-/Quartalsdaten in der Quelltabelle.</p>
</div>
</div>
</template>
<style scoped>
.chart-view {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--surface);
}
.bar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-bottom: 1px solid var(--border);
}
.sp {
flex: 1;
}
.seg {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.seg button {
border: 0;
background: var(--surface);
padding: 3px 10px;
font-size: 11px;
cursor: pointer;
}
.seg button.on {
background: var(--accent);
color: #fff;
}
.src {
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 6px;
background: var(--surface);
color: var(--text);
font-size: 11px;
max-width: 130px;
}
.ghost {
border: 1px solid var(--border);
background: var(--surface);
border-radius: 6px;
padding: 3px 9px;
font-size: 11px;
cursor: pointer;
}
.ghost:hover {
border-color: var(--accent);
color: var(--accent);
}
.body {
position: relative;
flex: 1;
min-height: 0;
padding: 10px;
}
.empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--text-dim);
font-size: 12px;
text-align: center;
padding: 12px;
}
</style>