import { generateId } from "./utilities/tools.js"
import { initTheme, setTheme, getTheme, getCustomThemes } from "./utilities/theme.js"
import { LOCAL_MODE } from "./utilities/constants.js"
import { Server } from "./server.js"
import { ProjectContextMenu } from "./components/project_context_menu.js"
import { ShareDialog } from "./components/share_dialog.js"
import { ConfirmDialog } from "./components/confirm_dialog.js"
import { Workspace } from "./workspace.js"
import { Project } from "./project.js"
import { LoginDialog } from "./components/login_dialog.js"
import { firebaseAuth, onAuthStateChanged, signOut } from "./firebase.js"
import "./components/info_widget.js"
// ── Account settings drawer ────────────────────────────────────────────────
let _drawerLoaded = false;
/**
* Opens the account settings drawer, lazy-loading the iframe on first open.
* @param {string|null} [tab=null] - Optional tab name to switch to (e.g. 'subscription').
*/
function openAccountDrawer(tab = null) {
const overlay = document.getElementById('accountDrawerOverlay');
const frame = document.getElementById('accountDrawerFrame');
if (!_drawerLoaded) {
frame.src = tab ? `/account?tab=${tab}` : '/account';
_drawerLoaded = true;
} else if (tab) {
frame.contentWindow?.postMessage({ type: 'switch-tab', tab }, '*');
}
overlay.classList.add('open');
}
window.openAccountDrawer = openAccountDrawer;
window.closeAccountDrawer = function() {
document.getElementById('accountDrawerOverlay').classList.remove('open');
window.dispatchEvent(new CustomEvent('account-drawer-closed'));
};
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('accountDrawerScrim').addEventListener('click', window.closeAccountDrawer);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') window.closeAccountDrawer();
});
});
// Re-apply theme when the iframe changes any theme-related localStorage key
window.addEventListener('storage', (e) => {
if (e.key === 'theme' || e.key === 'custom-themes' ||
e.key?.startsWith('custom-colors-')) {
setTheme(getTheme());
}
});
/**
* @class Main app; handles project management
*
* @prop {object} serverProjects - A list of projects that are saved on the server
* @prop {object} localProjects - A list of projects that are available locally and not saved to the server
*
* @prop {Workspace} workspace - The app workspace which handles project editing
* @prop {LoginDialog} loginDialog - The login dialog
* @prop {ProjectContextMenu} activeCtxMenu - The currently open Context Menu, if there is one
*
* @prop {bool} sidebarCollapsed - True, if the sidebar is in a collapsed state
*/
class App {
server = null;
serverProjects = {};
sharedProjects = {};
localProjects = {};
currentFolderPath = ''; // path within server projects the user is browsing
currentFolderFolders = []; // subfolders at currentFolderPath (fetched from API)
sharedFolderPath = ''; // path within shared section the user is browsing
sharedFolderFolderItems = []; // subfolders at sharedFolderPath
sharedFolderProjects = {}; // projects at sharedFolderPath (when non-root)
myFilesCollapsed = false;
sharedCollapsed = false;
#autoSaveTimers = new Map();
_hasAutoOpened = false;
/** Initializes the App, wiring up the server and workspace. */
constructor() {
this.sidebarCollapsed = false;
this.activeCtxMenu = null;
this.uploadingProjects = new Set();
this.uploadProgress = new Map(); // project → upload fraction 0–1
this.uploadControllers = new Map(); // project → AbortController
this.serverProjectsLoading = false;
this.sharedProjectsLoading = false;
this.server = new Server({
onConnect: async () => {
this.loginDialog?.close();
// Restore startup_behavior from server preferences into localStorage
const prefs = this.server.backendUser?.preferences || {};
if (prefs.startup_behavior) {
localStorage.setItem('startup-behavior', prefs.startup_behavior);
}
if (prefs.autosave !== undefined) {
localStorage.setItem('autosave', prefs.autosave ? 'true' : 'false');
}
if (prefs.undo_queue_size !== undefined) {
localStorage.setItem('undo-queue-size', prefs.undo_queue_size);
}
await this.refreshServerProjects();
if (!LOCAL_MODE) {
this.refreshSharedProjects();
this.refreshSharedFolderItems();
}
this._updateUserDisplay();
// Auto-reopen the last open project on first connect
if (!this._hasAutoOpened) {
this._hasAutoOpened = true;
this._maybeReopenLastProject();
}
},
onDisconnect: () => {
this.serverProjects = {};
this.sharedProjects = {};
this.sharedFolderPath = '';
this.sharedFolderFolderItems = [];
this.sharedFolderProjects = {};
this.currentFolderPath = '';
this.currentFolderFolders = [];
this.workspace.clearWorkspace();
this.renderSidebar();
this._updateUserDisplay();
},
onStatusChanged: () => {},
});
this.workspace = new Workspace({
onNewProject: async () => {
const id = await this.createProject("Untitled Project");
this.openProject(this.getProject(id));
},
onOpenProject: () => {
this.openPackagedProject();
},
onUpload: (project) => {
this.pushProjectToServer(project);
},
onProjectModified: (project) => {
this.#scheduleAutoSave(project);
},
isServerConnected: () => this.server.isConnected,
getToken: () => this.server.getToken(),
onPresentation: (project) => {
if (!project?.projectId) return;
new ShareDialog(project.projectId, this.server);
},
onRenderSidebar: () => this.renderSidebar(),
});
this.loginDialog = LOCAL_MODE ? null : new LoginDialog();
this.#getElements();
this.#setupListeners();
this.renderSidebar();
}
/**
* Initializes member variables for all relevant DOM elements
* @ignore
*/
#getElements() {
this.root = document;
this.sidebar = this.root.querySelector("#sidebar");
this.sidebarToggle = this.root.querySelector("#sidebarToggle");
this.sidebarResizeHandle = this.root.querySelector("#sidebarResizeHandle");
this.themeBtns = this.root.querySelectorAll(".theme-btn");
this.sidebarList = this.root.querySelector("#sidebarList");
this.sidebarEmpty = this.root.querySelector("#sidebarEmpty");
this.sidebarMyFilesSection = this.root.querySelector("#sidebarMyFilesSection");
this.sidebarBreadcrumb = this.root.querySelector("#sidebarBreadcrumb");
this.sidebarFolderItems = this.root.querySelector("#sidebarFolderItems");
this.sidebarServerItems = this.root.querySelector("#sidebarServerItems");
this.sidebarLocalSection = this.root.querySelector("#sidebarLocalSection");
this.newProjectBtn = this.root.querySelector("#newProjectBtn");
this.newFolderBtn = this.root.querySelector("#newFolderBtn");
this.openProjectBtn = this.root.querySelector("#openProjectBtn");
this.sidebarHomeBtn = this.root.querySelector("#sidebarHomeBtn");
this.openDocsButton = this.root.querySelector("#openDocsButton");
this.sidebarShortcutsBtn = this.root.querySelector("#sidebarShortcutsBtn");
this.sidebarThemeBtn = this.root.querySelector("#sidebarThemeBtn");
this.sidebarThemePopup = this.root.querySelector("#sidebarThemePopup");
this.kbdLegend = this.root.querySelector("#kbdLegend");
this.userAvatarBtn = this.root.querySelector("#userAvatarBtn");
this.userAvatarImg = this.root.querySelector("#userAvatarImg");
this.userNameDisplay = this.root.querySelector("#userNameDisplay");
this.userEmailDisplay = this.root.querySelector("#userEmailDisplay");
this.userDropdown = this.root.querySelector("#userDropdown");
this.userDropdownName = this.root.querySelector("#userDropdownName");
this.userDropdownDivider = this.root.querySelector("#userDropdownDivider");
this.userDropdownLogin = this.root.querySelector("#userDropdownLogin");
this.userDropdownAccountSettings = this.root.querySelector("#userDropdownAccountSettings");
this.userDropdownLogout = this.root.querySelector("#userDropdownLogout");
this.myFilesContent = this.root.querySelector("#myFilesContent");
this.sidebarSharedSection = this.root.querySelector("#sidebarSharedSection");
this.sidebarSharedBreadcrumb = this.root.querySelector("#sidebarSharedBreadcrumb");
this.sharedContent = this.root.querySelector("#sharedContent");
this.sidebarSharedItems = this.root.querySelector("#sidebarSharedItems");
}
/**
* Sets up any listeners for pre-rendered DOM elements
* @ignore
*/
#setupListeners() {
window.addEventListener('beforeunload', (e) => {
if (this.workspace.activeProject.isDirty()) {
e.preventDefault();
}
});
this.root.querySelector('#sidebarFooterSettingsBtn').addEventListener('click', () => {
openAccountDrawer();
});
this.sidebarHomeBtn.addEventListener('click', () => {
this.closeActiveProject({ onClosed: () => {} });
});
this.openDocsButton.addEventListener("click", () => {
window.open("/docs", "_blank");
});
// Shortcuts toggle
this.sidebarShortcutsBtn.classList.add('active');
this.sidebarShortcutsBtn.addEventListener('click', () => {
this.kbdLegend.classList.toggle('hidden');
this.sidebarShortcutsBtn.classList.toggle('active', !this.kbdLegend.classList.contains('hidden'));
});
// Theme popup
this.sidebarThemeBtn.addEventListener('click', (e) => {
e.stopPropagation();
const visible = this.sidebarThemePopup.style.display !== 'none';
this.sidebarThemePopup.style.display = visible ? 'none' : 'flex';
});
document.addEventListener('click', () => {
this.sidebarThemePopup.style.display = 'none';
});
this.sidebarThemePopup.addEventListener('click', (e) => e.stopPropagation());
// Theme toggle buttons
this.themeBtns.forEach(btn => {
btn.addEventListener('click', () => {
setTheme(btn.dataset.themeValue);
this.sidebarThemePopup.style.display = 'none';
this.#updateThemeBtns();
this._syncThemeToServer();
});
});
this.#updateThemeBtns();
if (LOCAL_MODE) {
this.root.querySelector('#sidebarUser').style.display = 'none';
}
// Add a click trigger for collapsing and expanding the sidebar
this.sidebarToggle.addEventListener('click', () => {
this.sidebarCollapsed = !this.sidebarCollapsed;
const sidebar = this.sidebar;
const toggle = this.sidebarToggle;
sidebar.classList.toggle('collapsed', this.sidebarCollapsed);
toggle.classList.toggle('collapsed', this.sidebarCollapsed);
toggle.textContent = this.sidebarCollapsed ? '▶' : '◀';
if (this.sidebarCollapsed) {
this.sidebarWidth = sidebar.offsetWidth;
sidebar.style.width = '';
toggle.style.left = '';
} else {
if (this.sidebarWidth) {
sidebar.style.width = `${this.sidebarWidth}px`;
toggle.style.left = `${this.sidebarWidth}px`;
}
}
});
// Drag-to-resize sidebar
const SIDEBAR_MIN_WIDTH = 240;
this.sidebarResizeHandle.addEventListener('mousedown', (e) => {
e.preventDefault();
this.sidebarResizeHandle.classList.add('dragging');
this.sidebar.style.transition = 'none';
this.sidebarToggle.style.transition = 'none';
const onMouseMove = (e) => {
const newWidth = Math.max(SIDEBAR_MIN_WIDTH, e.clientX);
this.sidebar.style.width = `${newWidth}px`;
this.sidebarToggle.style.left = `${newWidth}px`;
};
const onMouseUp = () => {
this.sidebarResizeHandle.classList.remove('dragging');
this.sidebar.style.transition = '';
this.sidebarToggle.style.transition = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
// New folder button
this.newFolderBtn.addEventListener('click', () => {
this.createServerFolder();
});
// Add a click trigger for creating a new project
this.newProjectBtn.addEventListener('click', async () => {
const id = await this.createProject('Untitled Project');
if (!this.workspace.activeProject) {
this.openProject(this.getProject(id));
}
});
// Add a click trigger for opening a packaged project
this.openProjectBtn.addEventListener('click', () => {
this.openPackagedProject();
});
// open custom context menu on right click
this.sidebarList.addEventListener('contextmenu', (e) => {
e.stopPropagation();
e.preventDefault();
this.openProjectCtxMenu(e.clientX, e.clientY);
});
// Shared section breadcrumb is built dynamically in renderSidebar
// User avatar dropdown
this.userAvatarBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = this.userDropdown.style.display !== 'none';
this.userDropdown.style.display = isOpen ? 'none' : 'block';
});
document.addEventListener('click', () => {
this.userDropdown.style.display = 'none';
});
this.userDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
this.userDropdownLogin.addEventListener('click', () => {
this.userDropdown.style.display = 'none';
this.loginDialog.open();
});
this.userDropdownAccountSettings.addEventListener('click', () => {
this.userDropdown.style.display = 'none';
openAccountDrawer();
});
this.userDropdownLogout.addEventListener('click', async () => {
this.userDropdown.style.display = 'none';
await this.server.disconnectFromServer();
});
this._updateUserDisplay();
}
/** Syncs the current theme selection to the server, merging into existing preferences. */
async _syncThemeToServer() {
if (LOCAL_MODE) return;
const token = await this.server.getToken();
if (!token) return;
const base = this.server.backendUser?.preferences || {};
const patch = { theme: localStorage.getItem('theme') || 'auto', custom_themes: getCustomThemes() };
try { patch.custom_colors_light = JSON.parse(localStorage.getItem('custom-colors-light') || '{}'); } catch { patch.custom_colors_light = {}; }
try { patch.custom_colors_dark = JSON.parse(localStorage.getItem('custom-colors-dark') || '{}'); } catch { patch.custom_colors_dark = {}; }
try {
await fetch('/api/users/me/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token },
body: JSON.stringify({ preferences: { ...base, ...patch } }),
});
} catch {}
}
/**
* Updates the sidebar user section to reflect the current login state.
* Shows name, avatar, and appropriate dropdown items.
*/
_updateUserDisplay() {
if (LOCAL_MODE) return;
const user = this.server.backendUser;
const fbUser = this.server.firebaseUser;
if (user || fbUser) {
const name = user?.display_name || fbUser?.displayName || '';
const email = user?.email || fbUser?.email || '';
this.userNameDisplay.textContent = name || email || 'User';
if (name && email) {
this.userEmailDisplay.textContent = email;
this.userEmailDisplay.style.display = '';
} else {
this.userEmailDisplay.style.display = 'none';
}
const photoURL = fbUser?.photoURL;
if (photoURL) {
this.userAvatarImg.src = photoURL;
} else {
this.userAvatarImg.src = '/static/media/user_icon.svg';
}
const fullName = user?.display_name || fbUser?.displayName || '';
const label = fullName || email;
if (label) {
this.userDropdownName.textContent = label;
this.userDropdownName.style.display = 'block';
this.userDropdownDivider.style.display = 'block';
} else {
this.userDropdownName.style.display = 'none';
this.userDropdownDivider.style.display = 'none';
}
this.userDropdownLogin.style.display = 'none';
this.userDropdownAccountSettings.style.display = 'block';
this.userDropdownLogout.style.display = 'block';
} else {
this.userNameDisplay.textContent = 'Guest';
this.userEmailDisplay.style.display = 'none';
this.userAvatarImg.src = '/static/media/user_icon.svg';
this.userDropdownName.style.display = 'none';
this.userDropdownDivider.style.display = 'none';
this.userDropdownLogin.style.display = 'block';
this.userDropdownAccountSettings.style.display = 'none';
this.userDropdownLogout.style.display = 'none';
}
}
// ----- PROJECT SERVER ACCESS ----- //
/**
* Fetches the full project list from the server and rebuilds the server
* project registry. Updates the sidebar when complete.
*/
async refreshServerProjects() {
this.serverProjectsLoading = true;
this.renderSidebar();
try {
// Get list of available projects from the server
const projectList = await this.server.getProjectList();
// Re-initialize the projects dictionary
this.serverProjects = {};
const buildProject = (projectData) => {
let project = new Project(projectData.id, projectData.name, {
onStateChange: () => {
this.renderSidebar(projectData.id);
},
server: this.server,
});
project.createdDate = projectData.created;
project.modifiedDate = projectData.modified;
project.hasTranscript = projectData.has_transcript;
project.folderPath = projectData.folder_path || '';
project.ownerName = projectData.owner_name || null;
project.isPublic = projectData.public || false;
project.accessLevel = 'owner';
project.localOnly = false;
project.synced = true;
return project;
}
// convert to a list of Project objects
let projects = projectList.map(buildProject);
// put the project objects into the appstate server projects dictionary
projects.forEach(project => this.serverProjects[project.projectId] = project);
// Fetch subfolders for the current folder view
await this.refreshCurrentFolderFolders();
} catch(e) {
console.error('Failed to load projects', e);
} finally {
this.serverProjectsLoading = false;
this.renderSidebar();
}
}
/**
* Fetches the list of projects shared with the current user and updates the sidebar.
*/
async refreshSharedProjects() {
this.sharedProjectsLoading = true;
this.renderSidebar();
try {
const projectList = await this.server.getSharedProjects();
this.sharedProjects = {};
projectList.forEach(projectData => {
const project = new Project(projectData.id, projectData.name, {
onStateChange: () => { this.renderSidebar(); },
server: this.server,
});
project.createdDate = projectData.created;
project.modifiedDate = projectData.modified;
project.hasTranscript = projectData.has_transcript;
project.folderPath = '';
project.ownerName = projectData.owner_name || null;
project.isPublic = projectData.public || false;
project.accessLevel = projectData.access_level || 'viewer';
project.readOnly = project.accessLevel === 'viewer';
project.localOnly = false;
project.synced = true;
this.sharedProjects[project.projectId] = project;
});
} catch(e) {
console.error('Failed to load shared projects', e);
} finally {
this.sharedProjectsLoading = false;
this.renderSidebar();
}
}
/**
* Fetches root-level folders accessible in the Shared section.
* When sharedFolderPath is non-empty, fetches that folder's contents instead.
*/
async refreshSharedFolderItems() {
if (!this.server.isConnected) return;
try {
if (this.sharedFolderPath) {
const contents = await this.server.listDirectory(this.sharedFolderPath);
this.sharedFolderFolderItems = contents.folders || [];
this.sharedFolderProjects = {};
(contents.projects || []).forEach(projectData => {
const project = new Project(projectData.id, projectData.name, {
onStateChange: () => { this.renderSidebar(); },
server: this.server,
});
project.createdDate = projectData.created;
project.modifiedDate = projectData.modified;
project.hasTranscript = projectData.has_transcript;
project.folderPath = projectData.folder_path || '';
project.ownerName = projectData.owner_name || null;
project.isPublic = projectData.public || false;
project.accessLevel = projectData.access_level || 'viewer';
project.readOnly = project.accessLevel !== 'editor';
project.localOnly = false;
project.synced = true;
this.sharedFolderProjects[project.projectId] = project;
});
} else {
const result = await this.server.getSharedFolders();
this.sharedFolderFolderItems = result.folders || [];
this.sharedFolderProjects = {};
}
} catch(e) {
console.error('Failed to load shared folder items:', e);
} finally {
this.renderSidebar();
}
}
/**
* Navigates the shared section to the given folder path.
* @param {string} folderPath - relative folder path ('' for root)
*/
async navigateToSharedFolder(folderPath) {
this.sharedFolderPath = folderPath;
await this.refreshSharedFolderItems();
}
/**
* Uploads a project to the server.
* - First upload (localOnly): creates a new server project with all data, then migrates
* the project from localProjects to serverProjects using the server-assigned id.
* - Subsequent uploads: only dirty sections are sent; project metadata is always
* included when any section is dirty (speakers excluded from metadata payload).
* After a successful upload, the project is marked clean and localOnly is set to false.
* @param {Project} project - the project to upload
*/
// ----- FOLDER NAVIGATION ----- //
/**
* Fetches and stores the list of immediate subfolders at currentFolderPath.
*/
async refreshCurrentFolderFolders() {
if (!this.server.isConnected) {
this.currentFolderFolders = [];
return;
}
try {
const contents = await this.server.listDirectory(this.currentFolderPath);
this.currentFolderFolders = contents.folders || [];
} catch(e) {
this.currentFolderFolders = [];
console.error('Failed to list folder:', e);
}
}
/**
* Navigates the server section to the given folder path.
* @param {string} folderPath - relative folder path ('' for root)
*/
async navigateToFolder(folderPath) {
this.currentFolderPath = folderPath;
await this.refreshCurrentFolderFolders();
this.renderSidebar();
}
/**
* Prompts the user for a name and creates a new server folder at the current path.
*/
createServerFolder() {
if (!this.server.isConnected) {
alert('Connect to a server to create folders.');
return;
}
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Folder name';
input.style.cssText = 'width:100%;box-sizing:border-box;background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:0.35rem 0.5rem;font-family:var(--font-mono);font-size:var(--fs-caption);';
new ConfirmDialog('New folder name', {
onConfirm: async () => {
const name = input.value;
if (!name || !name.trim()) return;
try {
await this.server.createFolder(this.currentFolderPath, name.trim());
await this.refreshCurrentFolderFolders();
this.renderSidebar();
} catch(e) {
alert(`Could not create folder: ${e.message}`);
}
}
}, '', 'Create', 'Cancel', input);
setTimeout(() => input.focus(), 0);
}
/**
* Activates inline rename editing for a folder item in the sidebar.
* @param {object} folder - folder descriptor {name, path}
* @param {HTMLElement} item - the folder's DOM element
*/
startFolderRename(folder, item) {
const nameEl = item.querySelector('.sidebar-item-name');
if (!nameEl) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'sidebar-item-name-input';
input.value = folder.name;
const commit = async () => {
const newName = input.value.trim();
if (!newName || newName === folder.name) {
this.renderSidebar();
return;
}
try {
await this.server.renameFolder(folder.path, newName);
// If we were inside this folder, navigate to the new path
if (this.currentFolderPath === folder.path) {
const parent = folder.path.split('/').slice(0, -1).join('/');
const newPath = parent ? `${parent}/${newName}` : newName;
this.currentFolderPath = newPath;
}
await this.refreshCurrentFolderFolders();
this.renderSidebar();
} catch(e) {
alert(`Could not rename folder: ${e.message}`);
this.renderSidebar();
}
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') {
input.removeEventListener('blur', commit);
this.renderSidebar();
}
e.stopPropagation();
});
input.addEventListener('click', (e) => e.stopPropagation());
nameEl.replaceWith(input);
input.focus();
input.select();
}
/**
* Deletes a server folder after confirmation. Shows a warning with the count
* of contents and offers the option to merge contents into the parent instead.
* @param {object} folder - folder descriptor {name, path}
*/
async deleteFolder(folder) {
// Fetch count for the warning message
let count = { projects: 0, folders: 0 };
try {
count = await this.server.getFolderCount(folder.path);
} catch(e) { /* best-effort */ }
// Build the warning overlay manually so we can have 3 action buttons
const overlay = document.createElement('div');
overlay.className = 'confirm-dialog-overlay';
const modal = document.createElement('div');
modal.className = 'confirm-dialog-modal';
modal.style.cssText = 'max-height:none;width:420px;padding:0;';
const header = document.createElement('div');
header.className = 'confirm-dialog-header';
header.innerHTML = `<span>Delete Folder</span>`;
modal.appendChild(header);
const body = document.createElement('div');
body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.85rem;';
const msg = document.createElement('div');
msg.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-caption);color:var(--text);line-height:1.6;';
const contentDesc = [];
if (count.projects > 0) contentDesc.push(`${count.projects} project${count.projects !== 1 ? 's' : ''}`);
if (count.folders > 0) contentDesc.push(`${count.folders} subfolder${count.folders !== 1 ? 's' : ''}`);
msg.textContent = contentDesc.length
? `"${folder.name}" contains ${contentDesc.join(' and ')}. Choose how to proceed:`
: `Delete the empty folder "${folder.name}"?`;
body.appendChild(msg);
const actions = document.createElement('div');
actions.style.cssText = 'display:flex;gap:0.5rem;justify-content:flex-end;flex-wrap:wrap;margin-top:0.25rem;';
const cancel = document.createElement('button');
cancel.className = 'sample-btn';
cancel.textContent = 'Cancel';
cancel.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
cancel.addEventListener('click', () => overlay.remove());
actions.appendChild(cancel);
if (contentDesc.length) {
const merge = document.createElement('button');
merge.className = 'sample-btn';
merge.textContent = 'Move contents up';
merge.title = 'Move all projects and subfolders to the parent, then delete this folder';
merge.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
merge.addEventListener('click', async () => {
overlay.remove();
await this.#doDeleteFolder(folder, true);
});
actions.appendChild(merge);
}
const deleteAll = document.createElement('button');
deleteAll.className = 'sample-btn';
deleteAll.textContent = contentDesc.length ? 'Delete all' : 'Delete';
deleteAll.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);border-color:var(--danger-border);color:var(--danger-muted);';
deleteAll.onmouseenter = () => { deleteAll.style.background = 'var(--danger-faint)'; deleteAll.style.borderColor = 'var(--danger)'; };
deleteAll.onmouseleave = () => { deleteAll.style.background = ''; deleteAll.style.borderColor = 'var(--danger-border)'; };
deleteAll.addEventListener('click', async () => {
overlay.remove();
await this.#doDeleteFolder(folder, false);
});
actions.appendChild(deleteAll);
body.appendChild(actions);
modal.appendChild(body);
overlay.appendChild(modal);
let _md1 = false;
overlay.addEventListener('mousedown', (e) => { _md1 = e.target === overlay; });
overlay.addEventListener('click', (e) => { if (e.target === overlay && _md1) overlay.remove(); });
document.body.appendChild(overlay);
}
/**
* Performs the actual folder deletion after user confirmation.
* @param {object} folder - folder descriptor {name, path}
* @param {boolean} merge - if true, merge contents into parent first
* @ignore
*/
async #doDeleteFolder(folder, merge) {
try {
await this.server.deleteFolder(folder.path, merge);
// If we were inside this folder (or deeper), navigate to parent
if (this.currentFolderPath === folder.path ||
this.currentFolderPath.startsWith(folder.path + '/')) {
const parent = folder.path.split('/').slice(0, -1).join('/');
this.currentFolderPath = parent;
}
await this.refreshCurrentFolderFolders();
// Remove deleted projects from registry if not merging
if (!merge) {
for (const [id, project] of Object.entries(this.serverProjects)) {
if (project.folderPath === folder.path ||
project.folderPath.startsWith(folder.path + '/')) {
delete this.serverProjects[id];
}
}
} else {
// Update folderPath for projects that were in this folder
const parentPath = folder.path.split('/').slice(0, -1).join('/');
for (const project of Object.values(this.serverProjects)) {
if (project.folderPath === folder.path) {
project.folderPath = parentPath;
}
}
}
this.renderSidebar();
} catch(e) {
alert(`Could not delete folder: ${e.message}`);
}
}
/**
* Shows a folder picker dialog and moves the given project to the chosen folder.
* @param {object} project - the project to move
*/
async moveProjectToFolder(project) {
if (!this.server.isConnected) return;
// Collect all unique folder paths from the registry + currentFolderFolders
const folderSet = new Set(['']); // always include root
for (const p of Object.values(this.serverProjects)) {
if (p.folderPath) folderSet.add(p.folderPath);
}
// Also include current location folders
for (const f of this.currentFolderFolders) {
folderSet.add(f.path);
}
// Remove the project's current folder (can't move to where it already is)
folderSet.delete(project.folderPath);
const folders = Array.from(folderSet).sort();
// Build picker overlay
const overlay = document.createElement('div');
overlay.className = 'confirm-dialog-overlay';
const modal = document.createElement('div');
modal.className = 'confirm-dialog-modal';
modal.style.cssText = 'max-height:none;width:380px;padding:0;';
const header = document.createElement('div');
header.className = 'confirm-dialog-header';
header.innerHTML = '<span>Move to Folder</span>';
modal.appendChild(header);
const body = document.createElement('div');
body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.85rem;';
const list = document.createElement('div');
list.style.cssText = 'display:flex;flex-direction:column;gap:2px;max-height:240px;overflow-y:auto;';
for (const path of folders) {
const opt = document.createElement('div');
opt.style.cssText = 'display:flex;align-items:center;gap:0.5rem;padding:0.4rem 0.5rem;border-radius:3px;cursor:pointer;font-family:var(--font-mono);font-size:var(--fs-label);color:var(--text);transition:background 0.1s;';
opt.onmouseenter = () => { opt.style.background = 'var(--surface2)'; };
opt.onmouseleave = () => { opt.style.background = ''; };
const icon = document.createElement('span');
icon.textContent = path ? '📁' : '🏠';
icon.style.fontSize = '0.8rem';
const label = document.createElement('span');
label.textContent = path ? path.replace(/\//g, ' › ') : 'Root';
opt.appendChild(icon);
opt.appendChild(label);
opt.addEventListener('click', async () => {
overlay.remove();
try {
const oldPath = project.folderPath;
await this.server.moveProjectToFolder(project.projectId, path);
project.folderPath = path;
await this.refreshCurrentFolderFolders();
this.renderSidebar();
this.workspace.history.push({
label: 'Move project', dirtyFlags: ['projectFolder'],
undo: async () => {
await this.server.moveProjectToFolder(project.projectId, oldPath);
project.folderPath = oldPath;
},
redo: async () => {
await this.server.moveProjectToFolder(project.projectId, path);
project.folderPath = path;
},
});
} catch(e) {
alert(`Could not move project: ${e.message}`);
}
});
list.appendChild(opt);
}
body.appendChild(list);
const cancel = document.createElement('button');
cancel.className = 'sample-btn';
cancel.textContent = 'Cancel';
cancel.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);align-self:flex-end;';
cancel.addEventListener('click', () => overlay.remove());
body.appendChild(cancel);
modal.appendChild(body);
overlay.appendChild(modal);
let _md2 = false;
overlay.addEventListener('mousedown', (e) => { _md2 = e.target === overlay; });
overlay.addEventListener('click', (e) => { if (e.target === overlay && _md2) overlay.remove(); });
document.body.appendChild(overlay);
}
/**
* Uploads a local project to the connected server, streaming progress updates.
* @param {object} project - The local project to push.
* @returns {Promise<void>}
*/
async pushProjectToServer(project) {
if (!this.server.isConnected) {
alert('Not connected to a server.');
return;
}
if (this.uploadingProjects.has(project)) {
return;
}
const uploadController = new AbortController();
this.uploadingProjects.add(project);
this.uploadProgress.set(project, 0);
this.uploadControllers.set(project, uploadController);
this.renderSidebar();
const wasActiveProject = this.workspace.activeProject === project;
if (wasActiveProject) {
this.workspace.setUploadBusy(true);
}
const onProgress = (fraction) => {
this.uploadProgress.set(project, fraction);
const item = this.sidebarList.querySelector(`[data-id="${project.projectId}"]`);
if (item) {
if (fraction >= 1) {
// Bytes fully sent — remove the bar so the spinner alone signals
// that we're waiting for the server to acknowledge.
item.querySelector('.sidebar-item-progress')?.remove();
} else {
const bar = item.querySelector('.sidebar-item-progress-bar');
if (bar) bar.style.width = `${fraction * 100}%`;
}
}
};
try {
// Build transcript File if the section is dirty (or this is the first upload)
let transcriptFile = null;
if (project.hasTranscript && (project.transcriptDirty || project.localOnly)) {
const json = JSON.stringify(project.transcript().compileJSON());
transcriptFile = new File([json], 'transcript.json', { type: 'application/json' });
}
// Collect speaker sample Files if the section is dirty (or this is the first upload)
const sampleFiles = {};
if (project.speakersDirty || project.localOnly) {
for (const [sid, spk] of Object.entries(project.speakers())) {
if (spk.sample?.blobUrl) {
try {
const resp = await fetch(spk.sample.blobUrl);
const blob = await resp.blob();
sampleFiles[sid] = new File([blob], `${sid}.wav`, { type: 'audio/wav' });
} catch(e) {
console.warn('Could not collect sample for speaker', sid, e);
}
}
}
}
if (project.localOnly) {
const waveform = project.waveform();
// Use the stored File reference if available (large files) so the browser
// can stream it directly into the XHR without a full in-memory pre-read.
// Fall back to fetching the blob URL for small files (already in-memory).
const audioFile = waveform?.file
?? (waveform?.url ? new File([await fetch(waveform.url).then(r => r.blob())], waveform.filename || 'audio.wav', { type: 'audio/wav' }) : null);
const uploadMetadata = {
...project.metadata(),
folder_path: this.currentFolderPath,
};
const result = await this.server.createProject(
uploadMetadata, project.waveformMetadata(), audioFile, transcriptFile, sampleFiles, onProgress, uploadController.signal
);
// Refresh cached user so storage checks use up-to-date usage
this.server.refreshUser().catch(() => {});
// Migrate from localProjects to serverProjects with the server-assigned id
const oldId = project.projectId;
project.projectId = result.id;
project.folderPath = result.folder_path || this.currentFolderPath;
project.localOnly = false;
project.markClean();
project._onStateChange = () => this.renderSidebar(result.id);
delete this.localProjects[oldId];
this.serverProjects[result.id] = project;
if (wasActiveProject) {
this.workspace.transcriptPanel.startPollingAudioReady();
}
} else {
// Existing server project — upload only dirty sections.
// Metadata (name, dates) is always included when any section is dirty.
// Speakers are sent as a separate payload when the speakers section is dirty.
const metadata = project.isDirty() ? {
name: project.projectName,
created: project.createdDate,
modified: project.modifiedDate,
} : null;
const speakersData = project.speakersDirty ? project.metadata().speakers : null;
const uploadTranscript = project.transcriptDirty ? transcriptFile : null;
const uploadSamples = project.speakersDirty ? sampleFiles : {};
let uploadAudio = null;
if (project.waveformDirty) {
const waveform = project.waveform();
uploadAudio = waveform?.file
?? (waveform?.url ? new File([await fetch(waveform.url).then(r => r.blob())], waveform.filename || 'audio.wav', { type: 'audio/wav' }) : null);
}
await this.server.updateProject(
project.projectId, metadata, speakersData, uploadAudio, uploadTranscript, uploadSamples, onProgress, uploadController.signal
);
project.markClean();
// If new audio was uploaded, sync the locally-computed peaks to the server.
// updateProject doesn't send peaks, so without this the server would keep
// any stale peaks from the previous audio.
if (uploadAudio) {
// Refresh cached user so storage checks use up-to-date usage
this.server.refreshUser().catch(() => {});
const wm = project.waveformMetadata();
if (wm.peaks.length > 0) {
this.server.saveWaveform(project.projectId, wm)
.catch(e => console.warn('Could not save peaks after audio update:', e));
}
}
if (wasActiveProject && uploadAudio) {
this.workspace.transcriptPanel.startPollingAudioReady();
}
}
this.renderSidebar();
} catch(e) {
console.error('Upload failed:', e);
alert(`Upload failed: ${e.message}`);
} finally {
this.uploadingProjects.delete(project);
this.uploadProgress.delete(project);
this.uploadControllers.delete(project);
this.renderSidebar();
if (wasActiveProject) {
this.workspace.setUploadBusy(false);
}
}
}
/**
* Schedules a debounced auto-save for a server project.
* Cancels any pending timer for the same project first.
* @param {object} project - the project to auto-save
*/
#scheduleAutoSave(project) {
if (localStorage.getItem('autosave') === 'false') return;
if (this.#autoSaveTimers.has(project)) {
clearTimeout(this.#autoSaveTimers.get(project));
}
const timer = setTimeout(() => {
this.#autoSaveTimers.delete(project);
if (project.isDirty() && this.server.isConnected && !project.localOnly) {
this.pushProjectToServer(project);
}
}, 2000);
this.#autoSaveTimers.set(project, timer);
}
// ----- PROJECT LIFECYCLE ----- //
/**
* Looks up a project by id in both registries (server takes priority).
* @param {string} projectId - the project's unique identifier
* @returns {object|null} project object, or null if not found
*/
getProject(projectId) {
return this.serverProjects[projectId] || this.localProjects[projectId] || null;
}
/**
* Returns true if the project with the given id is stored on the server.
* @param {string} projectId - the project's unique identifier
* @returns {boolean}
*/
isServerProject(projectId) {
return !this.getProject(projectId).localOnly;
}
/**
* Creates a new project. When connected, creates on the server first;
* falls back to local on failure. When offline, creates locally.
* @param {string} [name='Untitled Project'] - display name for the new project
* @returns {Promise<string>} the new project's id
*/
async createProject(name = 'Untitled Project') {
if (this.server.isConnected) {
try {
const result = await this.server.createProject(
{ name, folder_path: this.currentFolderPath },
null, null, null, {}, null, null
);
const id = result.id;
const newProject = new Project(id, name, {
onStateChange: () => { this.renderSidebar(id); },
server: this.server,
});
newProject.folderPath = result.folder_path || this.currentFolderPath;
newProject.accessLevel = 'owner';
newProject.localOnly = false;
newProject.synced = true;
this.serverProjects[id] = newProject;
this.renderSidebar();
return id;
} catch(e) {
console.error('Server project creation failed, falling back to local:', e);
alert(`Could not create project on server: ${e.message}. Creating locally instead.`);
}
}
// Offline or server fallback: create locally
const id = generateId();
const newProject = new Project(id, name, {
onStateChange: () => { this.renderSidebar(id); },
server: this.server,
});
this.localProjects[id] = newProject;
this.renderSidebar();
return id;
}
/**
* Opens a file picker for .wfs project archives, unpacks the selected file,
* registers it as a local project, and opens it in the workspace.
* @param {File|null} [filepath=null] - pre-selected file to open; if null, shows a file picker
*/
async openPackagedProject(filepath = null) {
let file = filepath;
if (!file) {
try {
const [fileHandle] = await window.showOpenFilePicker({
types: [{ description: 'Waveform Studio Project', accept: { 'application/octet-stream': ['.wfs'] } }],
multiple: false,
});
file = await fileHandle.getFile();
} catch (err) {
if (err.name !== 'AbortError') console.error('Open failed:', err);
return;
}
}
try {
const project = await Project.unpackageProject(file, { server: this.server });
const register = () => {
project._onStateChange = () => this.renderSidebar(project.projectId);
this.localProjects[project.projectId] = project;
this.renderSidebar();
this.openProject(project);
};
const existing = this.getProject(project.projectId);
if (existing) {
new ConfirmDialog(
'Project ID Conflict',
{
onConfirm: () => register(),
onDismiss: () => {
project.projectId = generateId();
register();
},
},
`A project named "${existing.projectName}" already exists with the same ID as "${project.projectName}". Overwrite it, or open as a new project with a different ID?`,
'Overwrite',
'New Project'
);
} else {
register();
}
} catch (err) {
console.error('Failed to load project:', err);
}
}
/**
* Deletes a project after confirmation. For server projects, also removes it
* from the server via the API. Resets the workspace if the deleted project
* was the currently active one.
* @param {object} project - The project to be deleted
*@param {bool} bypassWarning - If true, the delete confirm dialog will be auto-accepted
*/
async deleteProject(project, bypassWarning = false) {
const name = project.projectName || 'this project';
const isServer = !project.localOnly;
const warningText = isServer
? `Delete "${name}" from the server? This cannot be undone.`
: `Delete "${name}"? This cannot be undone.`;
const doConfirm = async () => {
// Abort any in-progress upload for this project
this.uploadControllers.get(project)?.abort();
if (isServer) {
try {
await this.server.deleteProject(project.projectId);
delete this.serverProjects[project.projectId];
} catch (e) {
console.error('Could not delete from server:', e);
return;
}
} else {
delete this.localProjects[project.projectId];
}
if (this.workspace.activeProject?.projectId === project.projectId) {
this.workspace.clearWorkspace();
this.workspace.activeProject = null;
}
this.renderSidebar();
};
// if warning is bypassed, autoconfirm
if (bypassWarning) {
doConfirm();
} else {
new ConfirmDialog(
'Delete Project', {
onConfirm: async () => { doConfirm(); },
},
warningText,
'Delete'
);
}
}
/**
* Removes a server project from the server and keeps it as a local-only project.
* Prompts the user to confirm before proceeding.
* @param {object} project - The server project to make local
*/
/**
* Shows a dialog explaining project compression, then compresses the project on confirm.
* Compression deletes the original audio file and keeps only the generated MP3.
* @param {object} project - The server project to compress
*/
async compressProject(project) {
let info;
try {
info = await this.server.getCompressInfo(project.projectId);
} catch (e) {
console.error('Could not fetch compress info:', e);
return;
}
if (!info.can_compress) {
alert(info.reason || 'This project cannot be compressed.');
return;
}
const fmt = (bytes) => {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
};
const storageBlock = document.createElement('div');
storageBlock.style.cssText = 'font-family:var(--font-mono);font-size:var(--fs-caption);line-height:1.8;background:var(--bg-2);border:1px solid var(--border);border-radius:4px;padding:0.6rem 0.75rem;';
storageBlock.innerHTML = `
<div><strong>Before:</strong> ${fmt(info.before_bytes)}</div>
<div><strong>After: </strong> ${fmt(info.after_bytes)} <span style="color:var(--text-muted)">(saves ${fmt(info.before_bytes - info.after_bytes)})</span></div>
`.trim();
new ConfirmDialog(
'Compress Project',
{
onConfirm: async () => {
try {
await this.server.compressProject(project.projectId);
} catch (e) {
console.error('Compression failed:', e);
alert('Compression failed: ' + (e.message || e));
return;
}
if (this.workspace.activeProject?.projectId === project.projectId) {
this.openProject(project);
} else {
this.renderSidebar();
}
},
},
'This will permanently remove the original audio file from the server, keeping only the generated MP3. Download a copy of the original first if you want to keep it.\n\nNote: the MP3 may have lower audio quality than the original.',
'Compress',
'Cancel',
storageBlock,
);
}
/**
* Opens a project and resets the workspace.
* @param {object} project - The project to be opened
*/
async openProject(project) {
localStorage.setItem('last-open-project', project.projectId);
this.closeActiveProject({
onClosed: () => {
this.workspace.loadProject(project);
this.renderSidebar();
}
});
}
/**
* Opens the last-viewed project on startup, if the preference is set and the
* project is available in the current session's server project list.
*/
_maybeReopenLastProject() {
const behavior = localStorage.getItem('startup-behavior') || 'last_project';
if (behavior !== 'last_project') return;
const lastId = localStorage.getItem('last-open-project');
if (!lastId) return;
const project = this.getProject(lastId);
if (project) this.openProject(project);
}
/**
* Closes the currently active project, prompting the user to confirm if
* there are unsaved changes. Calls onClosed on success or onCancel if the
* user dismisses the confirmation dialog.
* @param {object} options - options for the close operation
* @param {function} [options.onClosed] - called after the project is closed
* @param {function} [options.onCancel] - called if the user cancels
*/
closeActiveProject({ onClosed, onCancel }) {
const _onClosed = onClosed ?? (() => {})
const _onCancel = onCancel ?? (() => {})
const activeProject = this.workspace.activeProject;
if (!activeProject) {
_onClosed();
return;
}
const _doClose = () => {
if (activeProject && this.#autoSaveTimers.has(activeProject)) {
clearTimeout(this.#autoSaveTimers.get(activeProject));
this.#autoSaveTimers.delete(activeProject);
}
localStorage.removeItem('last-open-project');
this.workspace.clearWorkspace();
this.renderSidebar();
}
if(this.workspace.activeProject && this.workspace.activeProject.isDirty()) {
const confirmDialog = new ConfirmDialog(
"Unsaved Changes", {
onConfirm: () => {
_doClose();
_onClosed();
activeProject.markClean();
},
onDismiss: () => {
_onCancel();
}
},
'The currently open project has unsaved changes. If you close it without saving, they will be lost.',
'Confirm', 'Cancel')
} else {
_doClose();
_onClosed();
}
}
/**
* Duplicates the given project and handles it based on whether it is a local or server project.
* @param {object} originalProject - The project that should be duplicated
*/
async duplicateProject(originalProject) {
if (originalProject.localOnly) {
this.#createLocalCopy(originalProject);
this.renderSidebar();
} else {
try {
const result = await this.server.duplicateProject(originalProject.projectId, this.currentFolderPath);
const copy = new Project(result.id, result.name, {
onStateChange: () => { this.renderSidebar(result.id); },
server: this.server,
});
copy.createdDate = result.created;
copy.modifiedDate = result.modified;
copy.hasTranscript = result.has_transcript;
copy.folderPath = this.currentFolderPath;
copy.accessLevel = 'owner';
copy.isPublic = false;
copy.readOnly = false;
copy.localOnly = false;
copy.synced = true;
this.serverProjects[result.id] = copy;
this.renderSidebar();
} catch (e) {
console.error('Failed to duplicate project on server:', e);
alert(`Duplicate failed: ${e.message}`);
}
}
}
/**
* Creates a duplicate of a project, sets it to be local only and puts it in the local projects dict
* @param {object} project - The project to be duplicated
* @ignore
*/
#createLocalCopy(project) {
const newId = generateId();
const newName = `${project.projectName} Copy`;
const copy = project.createCopy(newId, newName, {
onStateChange: () => {
this.renderSidebar(newId);
}
});
copy.isLocal = true;
copy.synced = false;
this.localProjects[newId] = copy;
}
// ----- SIDEBAR ----- //
/**
* Builds a single sidebar list item DOM element.
* @param {object} project - project object
* @param {'server'|'local'} type - whether this is a server or local project
* @returns {HTMLElement}
* @ignore
*/
#buildProjectItem(project, type) {
const item = document.createElement('div');
// determine whether this is the active project
let active = false;
if (this.workspace.activeProject && this.workspace.activeProject.projectId === project.projectId) {
active = true;
}
// set the className to active if this project is active in the workspace
item.className = 'sidebar-item' + (active ? ' active' : '');
// save the project ID to the div dataset
item.dataset.id = project.projectId;
// build name element
const nameEl = document.createElement('span');
nameEl.className = 'sidebar-item-name';
const nameText = document.createElement('span');
nameText.className = 'sidebar-item-name-text';
nameText.textContent = project.projectName;
nameEl.appendChild(nameText);
if (project.isPublic || project.ownerName) {
const ownerEl = document.createElement('span');
ownerEl.className = 'sidebar-item-owner';
ownerEl.textContent = project.isPublic ? 'Public' : `Owner: ${project.ownerName}`;
nameEl.appendChild(ownerEl);
}
item.appendChild(nameEl);
// Show "uploading" badge while uploading, or "modified" badge when there are unsynced local edits
if (this.uploadingProjects.has(project)) {
const badge = document.createElement('span');
badge.className = 'sidebar-item-badge uploading';
badge.textContent = 'uploading';
badge.title = 'Uploading to server…';
item.appendChild(badge);
} else if (project.isDirty()) {
const badge = document.createElement('span');
badge.className = 'sidebar-item-badge modified';
badge.textContent = 'modified';
badge.title = 'Unpushed local changes';
item.appendChild(badge);
}
if (type !== 'shared') {
// Push button (↑) is shown when connected and the project has unsynced or dirty data.
// While uploading, the button is replaced with a spinner.
if (this.server.isConnected && (this.uploadingProjects.has(project) || !project.synced || project.isDirty())) {
const pushBtn = document.createElement('button');
if (this.uploadingProjects.has(project)) {
pushBtn.className = 'sidebar-item-push uploading';
pushBtn.disabled = true;
pushBtn.title = 'Uploading…';
const spinner = document.createElement('span');
spinner.className = 'sidebar-spinner';
pushBtn.appendChild(spinner);
} else {
pushBtn.className = 'sidebar-item-push';
pushBtn.innerHTML = '<span class="icon icon-arrow-up" style="width:11px;height:11px;"></span>';
pushBtn.title = type === 'server' ? 'Push changes to server' : 'Upload project to server';
pushBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.pushProjectToServer(project);
});
}
item.appendChild(pushBtn);
}
// Progress bar shown at the bottom of the item while uploading
if (this.uploadingProjects.has(project)) {
const progressTrack = document.createElement('div');
progressTrack.className = 'sidebar-item-progress';
const progressBar = document.createElement('div');
progressBar.className = 'sidebar-item-progress-bar';
progressBar.style.width = `${(this.uploadProgress.get(project) ?? 0) * 100}%`;
progressTrack.appendChild(progressBar);
item.appendChild(progressTrack);
}
// Delete button
const delBtn = document.createElement('button');
delBtn.className = 'sidebar-item-del';
delBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
delBtn.title = type === 'server' ? 'Delete from server' : 'Delete project';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteProject(project);
});
item.appendChild(delBtn);
}
// open project on click
item.addEventListener('click', (e) => {
e.stopPropagation();
this.openProject(project);
});
// open custom context menu on right click
item.addEventListener('contextmenu', (e) => {
e.stopPropagation();
e.preventDefault();
this.openProjectCtxMenu(e.clientX, e.clientY, project);
});
return item;
}
/**
* Fully re-renders the project sidebar list from the current registry state.
* Shows server projects first (sorted by modified desc), then local projects.
* Shows the "No projects yet" empty state when both registries are empty.
*/
renderSidebar() {
const localProjects = Object.values(this.localProjects);
// Projects visible in the current server folder
const folderProjects = Object.values(this.serverProjects)
.filter(p => p.folderPath === this.currentFolderPath);
const totalVisible = folderProjects.length + localProjects.length +
this.currentFolderFolders.length;
// Show empty state only when disconnected, not loading, and no local projects
const showEmpty = !this.server.isConnected && !localProjects.length && !this.serverProjectsLoading;
this.sidebarEmpty.style.display = showEmpty ? 'block' : 'none';
/**
* Sorts projects by modified date descending.
* @param {object} a - first project
* @param {object} b - second project
* @returns {number} Negative, zero, or positive for sort ordering.
*/
function projectSort(a, b) {
return (b.modifiedDate || '').localeCompare(a.modifiedDate || '');
}
// Update Server section
if (this.serverProjectsLoading) {
this.sidebarBreadcrumb.style.display = 'none';
this.sidebarFolderItems.replaceChildren();
this.sidebarServerItems.replaceChildren();
const loadingRow = document.createElement('div');
loadingRow.className = 'sidebar-loading-row';
const spinner = document.createElement('span');
spinner.className = 'sidebar-spinner';
const label = document.createElement('span');
label.textContent = 'Loading projects…';
loadingRow.appendChild(spinner);
loadingRow.appendChild(label);
this.sidebarServerItems.appendChild(loadingRow);
} else if (this.server.isConnected) {
// Breadcrumb always shown when connected
this.sidebarBreadcrumb.style.display = 'flex';
this.sidebarBreadcrumb.replaceChildren(...this.#buildBreadcrumbSegments());
this.sidebarFolderItems.replaceChildren();
if (this.currentFolderPath) {
// "Previous Folder" back button
this.sidebarFolderItems.appendChild(this.#buildPreviousFolderItem());
}
// Folder items (subfolders in current directory)
this.currentFolderFolders.forEach(folder => {
this.sidebarFolderItems.appendChild(this.#buildFolderItem(folder));
});
// Server project items in current folder
this.sidebarServerItems.replaceChildren();
folderProjects.sort(projectSort).forEach(project => {
this.sidebarServerItems.appendChild(this.#buildProjectItem(project, 'server'));
});
} else {
this.sidebarBreadcrumb.style.display = 'none';
this.sidebarFolderItems.replaceChildren();
this.sidebarServerItems.replaceChildren();
}
// Update Shared With Me section
const sharedProjects = Object.values(this.sharedProjects);
const sharedFolderProjects = Object.values(this.sharedFolderProjects);
const hasSharedContent = this.sharedFolderFolderItems.length > 0
|| sharedProjects.length > 0 || sharedFolderProjects.length > 0;
this.sidebarSharedSection.style.display =
(this.server.isConnected && (this.sharedProjectsLoading || hasSharedContent)) ? '' : 'none';
this.sidebarSharedBreadcrumb.replaceChildren(...this.#buildSharedBreadcrumbSegments());
if (this.sharedProjectsLoading) {
this.sidebarSharedItems.replaceChildren();
const loadingRow = document.createElement('div');
loadingRow.className = 'sidebar-loading-row';
const spinner = document.createElement('span');
spinner.className = 'sidebar-spinner';
const label = document.createElement('span');
label.textContent = 'Loading…';
loadingRow.appendChild(spinner);
loadingRow.appendChild(label);
this.sidebarSharedItems.appendChild(loadingRow);
} else {
this.sidebarSharedItems.replaceChildren();
if (this.sharedFolderPath) {
this.sidebarSharedItems.appendChild(this.#buildSharedPreviousFolderItem());
}
// Folder items (shared root folders or subfolders when navigated in)
this.sharedFolderFolderItems.forEach(folder => {
this.sidebarSharedItems.appendChild(this.#buildSharedFolderItem(folder));
});
// Projects: inside a folder show folder contents; at root show flat shared list
const projectsToShow = this.sharedFolderPath ? sharedFolderProjects : sharedProjects;
projectsToShow.sort(projectSort).forEach(project => {
this.sidebarSharedItems.appendChild(this.#buildProjectItem(project, 'shared'));
});
}
// Update Local section
this.sidebarLocalSection.replaceChildren();
if (localProjects.length > 0) {
const header = document.createElement('div');
header.className = 'sidebar-section';
header.textContent = this.server.isConnected ? 'Offline' : 'Local';
this.sidebarLocalSection.appendChild(header);
localProjects.sort(projectSort).forEach(project => {
this.sidebarLocalSection.appendChild(this.#buildProjectItem(project, 'local'));
});
}
}
/**
* Builds the breadcrumb segment elements for the current folder path.
* @returns {HTMLElement[]}
* @ignore
*/
#buildBreadcrumbSegments() {
const segments = [];
// Root segment — always shown
const rootSeg = document.createElement('span');
rootSeg.className = 'sidebar-breadcrumb-seg' +
(this.currentFolderPath === '' || this.myFilesCollapsed ? ' current' : '');
rootSeg.textContent = 'MY FILES';
rootSeg.title = 'Go to root';
if (this.currentFolderPath !== '' && !this.myFilesCollapsed) {
rootSeg.addEventListener('click', () => this.navigateToFolder(''));
}
segments.push(rootSeg);
// Path segments — only when expanded
if (this.currentFolderPath && !this.myFilesCollapsed) {
const parts = this.currentFolderPath.split('/');
parts.forEach((part, i) => {
const sep = document.createElement('span');
sep.className = 'sidebar-breadcrumb-sep';
sep.textContent = '›';
segments.push(sep);
const seg = document.createElement('span');
seg.className = 'sidebar-breadcrumb-seg' + (i === parts.length - 1 ? ' current' : '');
seg.textContent = part;
if (i < parts.length - 1) {
const targetPath = parts.slice(0, i + 1).join('/');
seg.addEventListener('click', () => this.navigateToFolder(targetPath));
}
segments.push(seg);
});
}
// Collapse button — always at right edge
const collapseBtn = document.createElement('button');
collapseBtn.className = 'sidebar-section-collapse' + (this.myFilesCollapsed ? ' collapsed' : '');
collapseBtn.title = this.myFilesCollapsed ? 'Expand' : 'Collapse';
collapseBtn.textContent = '▾';
collapseBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.myFilesCollapsed = !this.myFilesCollapsed;
this.myFilesContent.style.display = this.myFilesCollapsed ? 'none' : '';
this.renderSidebar();
});
segments.push(collapseBtn);
return segments;
}
/** @returns {HTMLElement} a "← Previous Folder" back-navigation item */
#buildPreviousFolderItem() {
const parts = this.currentFolderPath.split('/');
const parentPath = parts.slice(0, -1).join('/');
const item = document.createElement('div');
item.className = 'sidebar-folder-item sidebar-folder-item--back';
item.title = parentPath ? parentPath : 'Server root';
const iconWrap = document.createElement('span');
iconWrap.className = 'sidebar-folder-icon';
const icon = document.createElement('span');
icon.className = 'icon icon-folder';
icon.style.cssText = 'width:13px;height:13px;';
iconWrap.appendChild(icon);
item.appendChild(iconWrap);
const label = document.createElement('span');
label.className = 'sidebar-item-name';
label.innerHTML = '<span class="icon icon-arrow-left" style="width:11px;height:11px;"></span> Previous Folder';
item.appendChild(label);
item.addEventListener('click', () => this.navigateToFolder(parentPath));
return item;
}
/** @returns {HTMLElement} a "← Previous Folder" back-navigation item for the Shared section */
#buildSharedPreviousFolderItem() {
const parts = this.sharedFolderPath.split('/');
const parentPath = parts.slice(0, -1).join('/');
const item = document.createElement('div');
item.className = 'sidebar-folder-item sidebar-folder-item--back';
item.title = parentPath ? parentPath : 'Shared root';
const iconWrap = document.createElement('span');
iconWrap.className = 'sidebar-folder-icon';
const icon = document.createElement('span');
icon.className = 'icon icon-folder';
icon.style.cssText = 'width:13px;height:13px;';
iconWrap.appendChild(icon);
item.appendChild(iconWrap);
const label = document.createElement('span');
label.className = 'sidebar-item-name';
label.innerHTML = '<span class="icon icon-arrow-left" style="width:11px;height:11px;"></span> Previous Folder';
item.appendChild(label);
item.addEventListener('click', () => this.navigateToSharedFolder(parentPath));
return item;
}
/**
* Builds a sidebar item for a folder.
* @param {object} folder - folder descriptor {name, path}
* @returns {HTMLElement}
* @ignore
*/
#buildSharedBreadcrumbSegments() {
const segments = [];
const rootSeg = document.createElement('span');
rootSeg.className = 'sidebar-breadcrumb-seg' +
(this.sharedFolderPath === '' || this.sharedCollapsed ? ' current' : '');
rootSeg.textContent = 'SHARED';
if (this.sharedFolderPath && !this.sharedCollapsed) {
rootSeg.addEventListener('click', () => this.navigateToSharedFolder(''));
}
segments.push(rootSeg);
if (this.sharedFolderPath && !this.sharedCollapsed) {
const parts = this.sharedFolderPath.split('/');
parts.forEach((part, i) => {
const sep = document.createElement('span');
sep.className = 'sidebar-breadcrumb-sep';
sep.textContent = '›';
segments.push(sep);
const seg = document.createElement('span');
seg.className = 'sidebar-breadcrumb-seg' + (i === parts.length - 1 ? ' current' : '');
seg.textContent = part;
if (i < parts.length - 1) {
const targetPath = parts.slice(0, i + 1).join('/');
seg.addEventListener('click', () => this.navigateToSharedFolder(targetPath));
}
segments.push(seg);
});
}
const collapseBtn = document.createElement('button');
collapseBtn.className = 'sidebar-section-collapse' + (this.sharedCollapsed ? ' collapsed' : '');
collapseBtn.title = this.sharedCollapsed ? 'Expand' : 'Collapse';
collapseBtn.textContent = '▾';
collapseBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.sharedCollapsed = !this.sharedCollapsed;
this.sharedContent.style.display = this.sharedCollapsed ? 'none' : '';
this.renderSidebar();
});
segments.push(collapseBtn);
return segments;
}
/**
* Builds a sidebar folder item for use in the Shared section (no delete/rename).
* @param {object} folder - folder descriptor {name, path, owner_name, public}
* @returns {HTMLElement}
* @ignore
*/
#buildSharedFolderItem(folder) {
const item = document.createElement('div');
item.className = 'sidebar-folder-item';
item.dataset.path = folder.path;
const iconWrap = document.createElement('span');
iconWrap.className = 'sidebar-folder-icon';
const icon = document.createElement('span');
icon.className = 'icon icon-folder';
icon.style.cssText = 'width:13px;height:13px;';
iconWrap.appendChild(icon);
item.appendChild(iconWrap);
const nameEl = document.createElement('span');
nameEl.className = 'sidebar-item-name';
const nameText = document.createElement('span');
nameText.className = 'sidebar-item-name-text';
nameText.textContent = folder.name;
nameEl.appendChild(nameText);
if (folder.public || folder.owner_name) {
const ownerEl = document.createElement('span');
ownerEl.className = 'sidebar-item-owner';
ownerEl.textContent = folder.public ? 'Public' : `Owner: ${folder.owner_name}`;
nameEl.appendChild(ownerEl);
}
item.appendChild(nameEl);
const arrow = document.createElement('span');
arrow.className = 'sidebar-folder-arrow';
arrow.textContent = '›';
item.appendChild(arrow);
item.addEventListener('click', () => this.navigateToSharedFolder(folder.path));
return item;
}
/**
* Build a sidebar folder item element for an owned folder.
* @param {object} folder - Folder metadata including `path`, `name`, and `public`.
* @returns {HTMLElement} The folder item element.
*/
#buildFolderItem(folder) {
const item = document.createElement('div');
item.className = 'sidebar-folder-item';
item.dataset.path = folder.path;
const iconWrap = document.createElement('span');
iconWrap.className = 'sidebar-folder-icon';
const icon = document.createElement('span');
icon.className = 'icon icon-folder';
icon.style.cssText = 'width:13px;height:13px;';
iconWrap.appendChild(icon);
item.appendChild(iconWrap);
const nameEl = document.createElement('span');
nameEl.className = 'sidebar-item-name';
const nameText = document.createElement('span');
nameText.className = 'sidebar-item-name-text';
nameText.textContent = folder.name;
nameEl.appendChild(nameText);
if (folder.public || folder.owner_name) {
const ownerEl = document.createElement('span');
ownerEl.className = 'sidebar-item-owner';
ownerEl.textContent = folder.public ? 'Public' : `Owner: ${folder.owner_name}`;
nameEl.appendChild(ownerEl);
}
item.appendChild(nameEl);
const arrow = document.createElement('span');
arrow.className = 'sidebar-folder-arrow';
arrow.textContent = '›';
item.appendChild(arrow);
const delBtn = document.createElement('button');
delBtn.className = 'sidebar-item-del';
delBtn.innerHTML = '<span class="icon icon-close" style="width:11px;height:11px;"></span>';
delBtn.title = 'Delete folder';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.deleteFolder(folder);
});
item.appendChild(delBtn);
item.addEventListener('click', () => this.navigateToFolder(folder.path));
item.addEventListener('contextmenu', (e) => {
e.stopPropagation();
e.preventDefault();
this.openFolderCtxMenu(e.clientX, e.clientY, folder, item);
});
return item;
}
/**
* Opens a small context menu for a folder with Rename and Delete actions.
* @param {number} x - viewport x position
* @param {number} y - viewport y position
* @param {object} folder - folder descriptor {name, path}
* @param {HTMLElement} item - the folder's DOM element (for inline rename)
*/
openFolderCtxMenu(x, y, folder, item) {
this.closeCtxMenu();
const menu = document.createElement('div');
menu.className = 'ctx-menu';
const controls = document.createElement('div');
controls.className = 'ctx-controls';
const rename = document.createElement('div');
rename.className = 'ctx-item';
rename.innerHTML = '<span class="ctx-icon">✎</span><span class="ctx-item-inner">Rename</span>';
rename.addEventListener('click', () => {
menu.remove();
this.activeCtxMenu = null;
this.startFolderRename(folder, item);
});
const del = document.createElement('div');
del.className = 'ctx-item';
del.innerHTML = '<span class="ctx-icon">🗑</span><span class="ctx-item-inner">Delete</span>';
del.addEventListener('click', () => {
menu.remove();
this.activeCtxMenu = null;
this.deleteFolder(folder);
});
controls.appendChild(rename);
controls.appendChild(del);
if (folder.id) {
const idLine = document.createElement('div');
idLine.className = 'ctx-id-line';
idLine.textContent = folder.id;
controls.appendChild(idLine);
}
menu.appendChild(controls);
document.body.appendChild(menu);
// Position
menu.style.left = x + 'px';
menu.style.top = y + 'px';
requestAnimationFrame(() => {
const r = menu.getBoundingClientRect();
if (x + r.width > window.innerWidth) menu.style.left = (window.innerWidth - r.width - 8) + 'px';
if (y + r.height > window.innerHeight) menu.style.top = (window.innerHeight - r.height - 8) + 'px';
});
// Dismiss on outside click
this.activeCtxMenu = { close: () => menu.remove() };
setTimeout(() => {
const outside = (e) => {
if (!menu.contains(e.target)) {
menu.remove();
this.activeCtxMenu = null;
document.removeEventListener('mousedown', outside);
}
};
document.addEventListener('mousedown', outside);
}, 0);
}
/**
* Closes the active context menu
*/
closeCtxMenu() {
if (this.activeCtxMenu) {
this.activeCtxMenu.close();
this.activeCtxMenu = null;
}
}
/**
* Opens a new project context menu. If the provided project is null, it will open a truncated general menu
* with only options for non-project-dependant tasks like creating a new project
* @param {float} x - The x coordinate at which to place the context menu
* @param {float} y - The y coordinate at which to place the context menu
* @param {object} project - The project context for the context menu
*/
openProjectCtxMenu(x, y, project = null) {
// If there is already a context menu open, close it
this.closeCtxMenu();
// If the project is passed, make a full context menu. Otherwise, make a general one
if (project) {
// Create a new context menu with its callbacks
const readOnly = project.readOnly ?? false;
this.activeCtxMenu = new ProjectContextMenu(x, y, project, {
onDelete: !readOnly ? () => {
this.closeCtxMenu();
this.deleteProject(project);
} : null,
onDuplicate: () => {
this.closeCtxMenu();
this.duplicateProject(project);
},
onNew: () => {
this.closeCtxMenu();
this.createProject();
},
onOpen: () => {
this.closeCtxMenu();
this.openPackagedProject();
},
onRename: !readOnly ? () => {
this.closeCtxMenu();
this.startSidebarRename(project);
} : null,
onDismiss: () => { this.closeCtxMenu(); },
// show upload when connected and project has local-only or dirty data
onUpload: (this.server.isConnected && (project.localOnly || project.isDirty())) ? () => {
this.closeCtxMenu();
this.pushProjectToServer(project);
} : null,
// show download
onDownload: true ? () => {
this.closeCtxMenu();
this.workspace.saveLocalCopy(project);
} : null,
// show revert when project has unsaved changes
onRevert: project.isDirty() ? () => {
this.closeCtxMenu();
new ConfirmDialog('Discard all changes since the last save?', {
onConfirm: () => { project.revertToLastSave(); }
});
} : null,
// show move to folder when connected, server project, and not read-only
onMoveToFolder: (!readOnly && this.server.isConnected && !project.localOnly) ? () => {
this.closeCtxMenu();
this.moveProjectToFolder(project);
} : null,
onNewFolder: this.server.isConnected ? () => {
this.closeCtxMenu();
this.createServerFolder();
} : null,
onPresentation: (this.server.isConnected && !project.localOnly) ? () => {
this.closeCtxMenu();
window.open(`/presentation/${project.projectId}`, '_blank');
} : null,
onShare: (this.server.isConnected && !project.localOnly && !readOnly) ? () => {
this.closeCtxMenu();
new ShareDialog(project.projectId, this.server);
} : null,
onCompress: (!readOnly && this.server.isConnected && !project.localOnly) ? () => {
this.closeCtxMenu();
this.compressProject(project);
} : null,
onDismiss: () => { this.closeCtxMenu(); },
});
} else {
this.activeCtxMenu = new ProjectContextMenu(x, y, project, {
onNew: () => {
this.closeCtxMenu();
this.createProject();
},
onOpen: () => {
this.closeCtxMenu();
this.openPackagedProject();
},
onNewFolder: this.server.isConnected ? () => {
this.closeCtxMenu();
this.createServerFolder();
} : null,
onDismiss: () => { this.closeCtxMenu(); },
});
}
}
/**
* Activates inline rename editing for a project's sidebar item.
* Replaces the name span with a text input; commits on blur/Enter, cancels on Escape.
* @param {object} project - The project to rename
*/
startSidebarRename(project) {
const item = this.sidebarList.querySelector(`.sidebar-item[data-id="${project.projectId}"]`);
if (!item) return;
const nameEl = item.querySelector('.sidebar-item-name');
if (!nameEl) return;
const input = document.createElement('input');
input.type = 'text';
input.className = 'sidebar-item-name-input';
input.value = project.projectName;
const commit = () => {
const newName = input.value.trim() || project.projectName;
project.setName(newName);
// If this is the active project in the workspace, update its title display too
if (this.workspace.activeProject?.projectId === project.projectId) {
this.workspace.projectTitle.textContent = newName;
this.workspace.projectNameInput.value = newName;
}
};
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') {
input.removeEventListener('blur', commit);
this.renderSidebar();
}
e.stopPropagation();
});
// Prevent item click from firing while editing
input.addEventListener('click', (e) => e.stopPropagation());
nameEl.replaceWith(input);
input.focus();
input.select();
}
/** Marks the active theme button to match the current theme preference. */
#updateThemeBtns() {
const current = getTheme();
this.themeBtns.forEach(btn => {
btn.classList.toggle('active', btn.dataset.themeValue === current);
});
const icons = { auto: '◑', light: '☀', dark: '☾' };
this.sidebarThemeBtn.textContent = icons[current] ?? '◑';
}
}
// ENTRYPOINT
initTheme();
const app = new App();
window.addEventListener('load', () => {
document.fonts.ready.then(() => {
const overlay = document.getElementById('loading-overlay');
overlay.classList.add('hidden');
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
});
if (LOCAL_MODE) {
app.server.connectLocal();
} else {
let _connecting = false;
onAuthStateChanged(firebaseAuth, (firebaseUser) => {
if (firebaseUser && !app.server.isConnected && !_connecting) {
_connecting = true;
app.server.connectWithFirebaseUser(window.DEFAULT_SERVER, firebaseUser)
.catch(async (e) => {
app.loginDialog?.showConnectError(e.message || 'Server rejected the sign-in.');
try { await signOut(firebaseAuth); } catch (_) {}
})
.finally(() => { _connecting = false; });
} else if (!firebaseUser) {
if (app.server.isConnected) app.server.disconnectFromServer();
app.loginDialog?.openRequired();
}
});
}
});