Add Docker deployment and per-player secret-link viewers.

Each player gets an isolated SQLite viewer via a unique URL without login, with landing page warnings to save the link and compose-based hosting for sharing with others.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-19 16:06:13 +02:00
parent fbc2deec45
commit f51f166fa1
14 changed files with 589 additions and 53 deletions
+42 -6
View File
@@ -38,15 +38,51 @@ const CATEGORY_I18N_KEYS = {
document.addEventListener("DOMContentLoaded", init);
function apiBase() {
const vid = window.VIEWER_ID;
return vid ? `/v/${vid}/api` : "/api";
}
function viewerPageUrl() {
const vid = window.VIEWER_ID;
if (!vid) return window.location.href;
return `${window.location.origin}/v/${vid}/`;
}
async function init() {
await I18n.init();
applyStaticI18n();
setupLanguage();
setupViewerBanner();
setupNav();
setupUpload();
await loadData();
}
function setupViewerBanner() {
const vid = window.VIEWER_ID;
if (!vid || vid === "local") return;
const banner = document.getElementById("viewer-link-banner");
const urlEl = document.getElementById("viewer-link-url");
const copyBtn = document.getElementById("viewer-copy-link");
const url = viewerPageUrl();
banner.hidden = false;
urlEl.textContent = url;
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(url);
const prev = copyBtn.textContent;
copyBtn.textContent = t("viewer.copied");
setTimeout(() => { copyBtn.textContent = prev; }, 2000);
} catch {
window.prompt(t("viewer.copyPrompt"), url);
}
});
}
function categoryLabel(cat) {
const key = CATEGORY_I18N_KEYS[cat];
return key ? t(key) : cat;
@@ -96,7 +132,7 @@ function setupUpload() {
if (!file) return;
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/import", { method: "POST", body: fd });
const res = await fetch(`${apiBase()}/import`, { method: "POST", body: fd });
const result = await res.json();
if (!res.ok || result.error) {
showImportFailure(result);
@@ -185,9 +221,9 @@ function renderImportReport(meta) {
async function loadData() {
try {
const res = await fetch("/api/snapshot/latest");
const res = await fetch(`${apiBase()}/snapshot/latest`);
if (!res.ok) {
showEmpty(t("empty.noSave"));
showEmpty(window.VIEWER_ID ? t("empty.noSaveWeb") : t("empty.noSave"));
return;
}
state.data = await res.json();
@@ -629,8 +665,8 @@ async function loadHistoryTab() {
panel.innerHTML = `<p class='loading'>${esc(t("history.loading"))}</p>`;
const [snapRes, tlRes] = await Promise.all([
fetch("/api/snapshots"),
fetch("/api/timeline"),
fetch(`${apiBase()}/snapshots`),
fetch(`${apiBase()}/timeline`),
]);
state.snapshots = await snapRes.json();
state.timeline = await tlRes.json();
@@ -756,7 +792,7 @@ async function runDiff() {
}
const older = Math.min(h.olderId, h.newerId);
const newer = Math.max(h.olderId, h.newerId);
const res = await fetch(`/api/snapshots/${older}/diff/${newer}`);
const res = await fetch(`${apiBase()}/snapshots/${older}/diff/${newer}`);
const diff = await res.json();
if (diff.error) {
el.innerHTML = `<p class='empty-state'>${esc(diff.error)}</p>`;