components_version_history_dialog.js
import { ConfirmDialog } from "./confirm_dialog.js";
/**
* Modal dialog for browsing, creating, and restoring project version snapshots.
*/
export class VersionHistoryDialog {
/**
* @param {string} projectId - ID of the project whose versions are displayed.
* @param {string} accessLevel - 'viewer' | 'editor' | 'owner'
* @param {object} server - Server instance
* @param {object} callbacks - Callback functions for version actions.
* @param {function} callbacks.onRevertCurrent - Called with the full version object to revert in place
* @param {function} callbacks.onOpenAsNew - Called with the full version object to open as a new project
* @param {function} [callbacks.onSaveVersion] - Called with label string to save a named version; if omitted the Save button is hidden
*/
constructor(projectId, accessLevel, server, { onRevertCurrent, onOpenAsNew, onSaveVersion } = {}) {
this.projectId = projectId;
this.accessLevel = accessLevel;
this.server = server;
this._onRevertCurrent = onRevertCurrent ?? (() => {});
this._onOpenAsNew = onOpenAsNew ?? (() => {});
this._onSaveVersion = onSaveVersion ?? null;
this._canEdit = accessLevel === 'editor' || accessLevel === 'owner';
this._buildDOM();
document.body.appendChild(this.scrim);
document.body.appendChild(this.root);
this._load();
}
/** Removes the dialog and backdrop from the DOM. */
close() {
this.scrim.remove();
this.root.remove();
}
// ── DOM ──────────────────────────────────────────────────────────────────
/** Builds and attaches the dialog's DOM structure. */
_buildDOM() {
this.scrim = document.createElement('div');
this.scrim.className = 'share-scrim';
this.scrim.addEventListener('click', () => this.close());
this.root = document.createElement('div');
this.root.className = 'share-dialog';
this.root.style.cssText = 'width:520px;max-height:70vh;display:flex;flex-direction:column;';
// Header
const header = document.createElement('div');
header.className = 'share-dialog-header';
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;gap:0.5rem;flex-shrink:0;';
const title = document.createElement('h2');
title.className = 'share-dialog-title';
title.textContent = 'Version History';
header.appendChild(title);
const headerActions = document.createElement('div');
headerActions.style.cssText = 'display:flex;align-items:center;gap:0.5rem;';
if (this._canEdit && this._onSaveVersion) {
this._saveBtn = document.createElement('button');
this._saveBtn.className = 'sample-btn';
this._saveBtn.textContent = 'Save Version';
this._saveBtn.addEventListener('click', () => this._openSaveVersionUI());
headerActions.appendChild(this._saveBtn);
}
const closeBtn = document.createElement('button');
closeBtn.className = 'share-close-btn';
closeBtn.title = 'Close';
closeBtn.innerHTML = '✕';
closeBtn.addEventListener('click', () => this.close());
headerActions.appendChild(closeBtn);
header.appendChild(headerActions);
this.root.appendChild(header);
// Save version inline form (hidden by default)
this._saveForm = document.createElement('div');
this._saveForm.className = 'share-dialog-body';
this._saveForm.style.cssText = 'display:none;flex-shrink:0;padding:0.75rem 1.25rem;border-bottom:1px solid var(--border);';
this._saveForm.innerHTML = `
<div style="display:flex;gap:0.5rem;align-items:center;">
<input class="project-title-input" id="versionLabelInput" placeholder="Version label (optional)"
style="flex:1;display:inline-block;" />
<button class="sample-btn" id="versionSaveConfirm">Save</button>
<button class="sample-btn" id="versionSaveCancel">Cancel</button>
</div>`;
this.root.appendChild(this._saveForm);
// Version list
this._list = document.createElement('div');
this._list.className = 'share-dialog-body';
this._list.style.cssText = 'flex:1;overflow-y:auto;padding:0;';
this.root.appendChild(this._list);
}
// ── Loading ───────────────────────────────────────────────────────────────
/** Fetches the version list from the server and renders it. */
async _load() {
this._list.innerHTML = '<div style="padding:1.25rem;color:var(--text-muted);font-size:var(--fs-caption);">Loading…</div>';
try {
const versions = await this.server.listVersions(this.projectId);
this._renderList(versions);
} catch (e) {
this._list.innerHTML = `<div style="padding:1.25rem;color:var(--danger);">${e.message}</div>`;
}
}
/** @param {object[]} versions - Array of version index entries to render. */
_renderList(versions) {
this._list.innerHTML = '';
if (!versions.length) {
this._list.innerHTML = '<div style="padding:1.25rem;color:var(--text-muted);font-size:var(--fs-caption);">No versions saved yet.</div>';
return;
}
for (const v of versions) {
this._list.appendChild(this._buildVersionRow(v));
}
}
/**
* @param {object} v - Version index entry to render as a list row.
* @returns {HTMLElement} The constructed row element.
*/
_buildVersionRow(v) {
const row = document.createElement('div');
row.style.cssText = 'padding:0.75rem 1.25rem;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:0.3rem;';
const topLine = document.createElement('div');
topLine.style.cssText = 'display:flex;align-items:baseline;gap:0.5rem;';
const labelEl = document.createElement('span');
labelEl.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-label);font-weight:600;';
labelEl.textContent = v.label ?? 'Auto';
topLine.appendChild(labelEl);
const dateEl = document.createElement('span');
dateEl.style.cssText = 'font-size:var(--fs-caption);color:var(--text-muted);';
dateEl.textContent = new Date(v.created_at).toLocaleString();
topLine.appendChild(dateEl);
row.appendChild(topLine);
const byEl = document.createElement('div');
byEl.style.cssText = 'font-size:var(--fs-caption);color:var(--text-muted);';
byEl.textContent = v.created_by?.name ?? '';
row.appendChild(byEl);
if (this._canEdit) {
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:0.4rem;margin-top:0.25rem;';
const openBtn = document.createElement('button');
openBtn.className = 'sample-btn';
openBtn.textContent = 'Open as New Project';
openBtn.addEventListener('click', () => this._openAsNew(v.id, openBtn));
actions.appendChild(openBtn);
const revertBtn = document.createElement('button');
revertBtn.className = 'sample-btn';
revertBtn.textContent = 'Revert Current';
revertBtn.addEventListener('click', () => this._revertCurrent(v.id, revertBtn));
actions.appendChild(revertBtn);
if (v.label !== null && v.label !== undefined) {
const delBtn = document.createElement('button');
delBtn.className = 'sample-btn';
delBtn.style.cssText = 'color:var(--danger-muted);border-color:var(--danger-border);';
delBtn.textContent = 'Delete';
delBtn.addEventListener('click', () => this._deleteVersion(v.id, row));
actions.appendChild(delBtn);
}
row.appendChild(actions);
} else {
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:0.4rem;margin-top:0.25rem;';
const openBtn = document.createElement('button');
openBtn.className = 'sample-btn';
openBtn.textContent = 'Open as New Project';
openBtn.addEventListener('click', () => this._openAsNew(v.id, openBtn));
actions.appendChild(openBtn);
row.appendChild(actions);
}
return row;
}
// ── Actions ───────────────────────────────────────────────────────────────
/** Shows the inline save-version form and wires up its confirm/cancel handlers. */
_openSaveVersionUI() {
this._saveForm.style.display = '';
this._saveBtn.disabled = true;
const input = this._saveForm.querySelector('#versionLabelInput');
const confirmBtn = this._saveForm.querySelector('#versionSaveConfirm');
const cancelBtn = this._saveForm.querySelector('#versionSaveCancel');
input.value = '';
input.focus();
const cleanup = () => {
this._saveForm.style.display = 'none';
this._saveBtn.disabled = false;
};
const doSave = async () => {
confirmBtn.disabled = true;
confirmBtn.textContent = '…';
try {
await this._onSaveVersion(input.value.trim() || null);
cleanup();
this._load();
} catch (e) {
confirmBtn.textContent = 'Save';
confirmBtn.disabled = false;
alert(`Could not save version: ${e.message}`);
}
};
confirmBtn.onclick = doSave;
input.onkeydown = (e) => { if (e.key === 'Enter') doSave(); if (e.key === 'Escape') cleanup(); };
cancelBtn.onclick = cleanup;
}
/**
* @param {string} versionId - ID of the version to open.
* @param {HTMLButtonElement} btn - Button element to show loading state on.
*/
async _openAsNew(versionId, btn) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
try {
const version = await this.server.getVersion(this.projectId, versionId);
this.close();
await this._onOpenAsNew(version);
} catch (e) {
btn.textContent = orig;
btn.disabled = false;
alert(`Could not open version: ${e.message}`);
}
}
/**
* @param {string} versionId - ID of the version to revert to.
* @param {HTMLButtonElement} btn - Button element to show loading state on.
*/
async _revertCurrent(versionId, btn) {
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = '…';
try {
const version = await this.server.getVersion(this.projectId, versionId);
this.close();
this._onRevertCurrent(version);
} catch (e) {
btn.textContent = orig;
btn.disabled = false;
alert(`Could not load version: ${e.message}`);
}
}
/**
* @param {string} versionId - ID of the version to delete.
* @param {HTMLElement} row - Row element to remove on success.
*/
async _deleteVersion(versionId, row) {
new ConfirmDialog(
'Delete this version?',
{
onConfirm: async () => {
try {
await this.server.deleteVersion(this.projectId, versionId);
row.remove();
} catch (e) {
alert(`Could not delete version: ${e.message}`);
}
},
},
'This cannot be undone.',
'Delete',
);
}
}