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,263 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user