Elysia-Suite's picture
Upload 24 files
d58d97b verified
raw
history blame
33.2 kB
/*
ELYSIA CODE COMPANION v1.2.2 - Main Application
Entry point and UI orchestration
*/
import Utils from "./utils.js";
import DB from "./db.js";
import FileSystem from "./filesystem.js";
import Chat from "./chat.js";
class App {
constructor() {
this.state = {
apiKey: null,
model: "x-ai/grok-3-fast",
maxFiles: 100,
autoPreview: true,
syntaxHighlighting: true,
maxResponseTokens: 4000,
maxHistoryMessages: 20,
theme: "dark",
currentFile: null
};
this.elements = {
btnOpenFolder: document.getElementById("btn-open-folder"),
btnCloseFolder: document.getElementById("btn-close-folder"),
btnSettings: document.getElementById("btn-settings"),
btnHistory: document.getElementById("btn-history"),
btnSaveSettings: document.getElementById("btn-save-settings"),
btnClearHistory: document.getElementById("btn-clear-history"),
fileTree: document.getElementById("file-tree"),
filePreview: document.getElementById("file-preview"),
folderInfo: document.getElementById("folder-info"),
folderName: document.getElementById("folder-name"),
folderStats: document.getElementById("folder-stats"),
searchFiles: document.getElementById("search-files"),
apiKeyInput: document.getElementById("api-key"),
modelSelect: document.getElementById("model-select"),
maxFilesInput: document.getElementById("max-files"),
autoPreviewCheckbox: document.getElementById("auto-preview"),
syntaxHighlightingCheckbox: document.getElementById("syntax-highlighting"),
maxResponseTokensInput: document.getElementById("max-response-tokens"),
maxHistoryMessagesInput: document.getElementById("max-history-messages"),
themeSelect: document.getElementById("theme-select")
};
}
async init() {
// Load settings
this.loadSettings();
// Ensure all modals are closed on startup (safety check)
document.querySelectorAll(".modal").forEach(m => m.classList.remove("active"));
// Initialize chat
Chat.init();
// Setup event listeners
this.setupEventListeners();
// Check File System Access API support
if (!FileSystem.isSupported()) {
Utils.toast.warning("File System Access API not supported. Please use Chrome or Edge browser.");
}
// Setup keyboard shortcuts
this.setupKeyboardShortcuts();
// Setup drag & drop for folder
this.setupDragAndDrop();
// Apply theme
this.applyTheme(this.state.theme);
// App initialized (production: silent mode)
// console.log("πŸ’Ž Elysia Code Companion initialized");
}
setupDragAndDrop() {
const dropZone = document.body;
dropZone.addEventListener("dragover", e => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", e => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove("drag-over");
});
dropZone.addEventListener("drop", async e => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove("drag-over");
const items = e.dataTransfer.items;
// Check if folder was dropped
if (items && items.length > 0) {
const item = items[0];
if (item.kind === "file") {
const entry = item.webkitGetAsEntry?.();
if (entry && entry.isDirectory) {
Utils.toast.info(
"Drag & drop folder detected. Please use 'Open Folder' button for full access."
);
} else {
Utils.toast.warning("Please drag a folder, not individual files.");
}
}
}
});
}
setupSidebarToggles() {
const btnToggleLeft = document.getElementById("btn-toggle-left");
const btnToggleRight = document.getElementById("btn-toggle-right");
const sidebarLeft = document.getElementById("sidebar-left");
const sidebarRight = document.getElementById("sidebar-right");
const overlay = document.getElementById("sidebar-overlay");
const isMobile = () => window.innerWidth <= 900;
// Toggle left sidebar
btnToggleLeft.addEventListener("click", () => {
if (isMobile()) {
sidebarLeft.classList.toggle("visible");
sidebarRight.classList.remove("visible");
overlay.classList.toggle("active", sidebarLeft.classList.contains("visible"));
} else {
sidebarLeft.classList.toggle("hidden");
}
btnToggleLeft.classList.toggle(
"active",
isMobile() ? sidebarLeft.classList.contains("visible") : !sidebarLeft.classList.contains("hidden")
);
btnToggleRight.classList.remove("active");
});
// Toggle right sidebar
btnToggleRight.addEventListener("click", () => {
if (isMobile()) {
sidebarRight.classList.toggle("visible");
sidebarLeft.classList.remove("visible");
overlay.classList.toggle("active", sidebarRight.classList.contains("visible"));
} else {
sidebarRight.classList.toggle("hidden");
}
btnToggleRight.classList.toggle(
"active",
isMobile() ? sidebarRight.classList.contains("visible") : !sidebarRight.classList.contains("hidden")
);
btnToggleLeft.classList.remove("active");
});
// Close sidebars when clicking overlay
overlay.addEventListener("click", () => {
sidebarLeft.classList.remove("visible");
sidebarRight.classList.remove("visible");
overlay.classList.remove("active");
btnToggleLeft.classList.remove("active");
btnToggleRight.classList.remove("active");
});
// Set initial active state for desktop (sidebars visible by default)
if (!isMobile()) {
btnToggleLeft.classList.add("active");
btnToggleRight.classList.add("active");
}
// Handle resize
window.addEventListener("resize", () => {
if (!isMobile()) {
sidebarLeft.classList.remove("visible");
sidebarRight.classList.remove("visible");
overlay.classList.remove("active");
}
});
}
applyTheme(theme) {
document.body.setAttribute("data-theme", theme);
this.state.theme = theme;
Utils.storage.set("theme", theme);
}
setupKeyboardShortcuts() {
document.addEventListener("keydown", e => {
// Ctrl+O: Open folder
if (e.ctrlKey && e.key === "o") {
e.preventDefault();
this.openFolder();
}
// Ctrl+K: Focus search
if (e.ctrlKey && e.key === "k") {
e.preventDefault();
this.elements.searchFiles.focus();
}
// Escape: Close modals
if (e.key === "Escape") {
document.querySelectorAll(".modal.active").forEach(modal => {
Utils.modal.close(modal.id);
});
}
// Ctrl+Enter: Send message (when chat input focused)
if (e.ctrlKey && e.key === "Enter" && document.activeElement === document.getElementById("chat-input")) {
e.preventDefault();
Chat.sendMessage();
}
// Arrow Up/Down: Navigate file tree (when file tree focused)
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && document.activeElement.closest(".file-tree")) {
e.preventDefault();
this.navigateFileTree(e.key === "ArrowUp" ? -1 : 1);
}
// Enter: Open selected file in tree
if (e.key === "Enter" && document.activeElement.classList.contains("tree-item")) {
e.preventDefault();
const path = document.activeElement.dataset.path;
if (path) this.previewFile(path);
}
});
}
setupEventListeners() {
// Folder actions
this.elements.btnOpenFolder.addEventListener("click", () => this.openFolder());
this.elements.btnCloseFolder.addEventListener("click", () => this.closeFolder());
// Settings
this.elements.btnSettings.addEventListener("click", () => {
this.populateSettings();
Utils.modal.open("modal-settings");
});
this.elements.btnSaveSettings.addEventListener("click", () => {
this.saveSettings();
Utils.modal.close("modal-settings");
});
// History
this.elements.btnHistory.addEventListener("click", () => {
this.loadHistory();
Utils.modal.open("modal-history");
});
this.elements.btnClearHistory.addEventListener("click", async () => {
if (confirm("Are you sure you want to clear all chat history?")) {
await DB.clearChats();
this.loadHistory();
}
});
// Sidebar toggles
this.setupSidebarToggles();
// File search
this.elements.searchFiles.addEventListener(
"input",
Utils.debounce(e => this.searchFiles(e.target.value), 300)
);
// API Key visibility toggle
const btnToggleApiKey = document.getElementById("btn-toggle-api-key");
if (btnToggleApiKey) {
btnToggleApiKey.addEventListener("click", () => {
const apiKeyInput = this.elements.apiKeyInput;
const isPassword = apiKeyInput.type === "password";
apiKeyInput.type = isPassword ? "text" : "password";
btnToggleApiKey.textContent = isPassword ? "πŸ™ˆ" : "πŸ‘οΈ";
});
}
// About modal
const btnAbout = document.getElementById("btn-about");
if (btnAbout) {
btnAbout.addEventListener("click", e => {
e.preventDefault();
Utils.modal.open("modal-about");
});
}
}
loadSettings() {
this.state.apiKey = Utils.storage.get("apiKey");
this.state.model = Utils.storage.get("model", "x-ai/grok-4.1-fast");
this.state.maxFiles = Utils.storage.get("maxFiles", 100);
this.state.autoPreview = Utils.storage.get("autoPreview", true);
this.state.syntaxHighlighting = Utils.storage.get("syntaxHighlighting", true);
this.state.maxResponseTokens = Utils.storage.get("maxResponseTokens", 4000);
this.state.maxHistoryMessages = Utils.storage.get("maxHistoryMessages", 20);
this.state.theme = Utils.storage.get("theme", "dark");
}
populateSettings() {
this.elements.apiKeyInput.value = this.state.apiKey || "";
this.elements.modelSelect.value = this.state.model;
this.elements.maxFilesInput.value = this.state.maxFiles;
this.elements.autoPreviewCheckbox.checked = this.state.autoPreview;
this.elements.syntaxHighlightingCheckbox.checked = this.state.syntaxHighlighting;
this.elements.maxResponseTokensInput.value = this.state.maxResponseTokens;
this.elements.maxHistoryMessagesInput.value = this.state.maxHistoryMessages;
this.elements.themeSelect.value = this.state.theme;
}
saveSettings() {
this.state.apiKey = this.elements.apiKeyInput.value;
this.state.model = this.elements.modelSelect.value;
this.state.maxFiles = parseInt(this.elements.maxFilesInput.value);
this.state.autoPreview = this.elements.autoPreviewCheckbox.checked;
this.state.syntaxHighlighting = this.elements.syntaxHighlightingCheckbox.checked;
this.state.maxResponseTokens = parseInt(this.elements.maxResponseTokensInput.value);
this.state.maxHistoryMessages = parseInt(this.elements.maxHistoryMessagesInput.value);
this.state.theme = this.elements.themeSelect.value;
Utils.storage.set("apiKey", this.state.apiKey);
Utils.storage.set("model", this.state.model);
Utils.storage.set("maxFiles", this.state.maxFiles);
Utils.storage.set("autoPreview", this.state.autoPreview);
Utils.storage.set("syntaxHighlighting", this.state.syntaxHighlighting);
Utils.storage.set("maxResponseTokens", this.state.maxResponseTokens);
Utils.storage.set("maxHistoryMessages", this.state.maxHistoryMessages);
// Update Chat module with new history limit
if (window.Chat) {
window.Chat.maxHistoryMessages = this.state.maxHistoryMessages;
}
// Apply theme
this.applyTheme(this.state.theme);
Utils.toast.success("Settings saved!");
}
async openFolder() {
Utils.loading.show("Opening folder picker...");
try {
const result = await FileSystem.openFolder();
if (!result) {
Utils.loading.hide();
return;
}
Utils.loading.update("Loading files...");
// Update UI
this.elements.folderInfo.style.display = "block";
this.elements.folderName.textContent = result.name;
this.elements.folderStats.textContent = `${result.files.length} files`;
this.elements.btnOpenFolder.style.display = "none";
this.elements.btnCloseFolder.style.display = "block";
// Update chat context
Chat.updateContextInfo();
// Render file tree
Utils.loading.update("Rendering file tree...");
this.renderFileTree();
Utils.loading.hide();
} catch (err) {
Utils.loading.hide();
console.error("Failed to open folder:", err);
Utils.toast.error("Failed to open folder: " + err.message);
}
}
closeFolder() {
// Confirm if there's chat history in current session
if (Chat && Chat.messageContainer) {
const messageCount = Chat.messageContainer.querySelectorAll(".message.user").length;
if (messageCount > 0) {
if (!confirm(`You have ${messageCount} message(s) in this session. Close folder and lose context?`)) {
return;
}
}
}
FileSystem.close();
// Update UI
this.elements.folderInfo.style.display = "none";
this.elements.btnOpenFolder.style.display = "block";
this.elements.btnCloseFolder.style.display = "none";
this.elements.fileTree.innerHTML = `
<div class="empty-state">
<p>πŸ“‚ No folder opened</p>
<p class="hint">Click "Open Folder" to start</p>
</div>
`;
this.elements.filePreview.innerHTML = `
<div class="empty-state">
<p>πŸ“„ No file selected</p>
<p class="hint">Click a file in the tree to preview</p>
</div>
`;
// Update chat context
Chat.updateContextInfo();
Utils.toast.info("Folder closed");
}
renderFileTree() {
const tree = FileSystem.buildTree();
this.elements.fileTree.innerHTML = "";
// Performance optimization: Collapse large directories by default
const shouldCollapseByDefault = FileSystem.files.length > 100;
const renderNode = (node, parentEl, level = 0) => {
if (node.type === "directory") {
const folderEl = document.createElement("div");
folderEl.className = "tree-folder";
const isCollapsed = shouldCollapseByDefault && level > 0;
const folderHeader = document.createElement("div");
folderHeader.className = "tree-item tree-folder-header";
folderHeader.style.paddingLeft = `${level * 1.5}rem`;
folderHeader.innerHTML = `
<span class="icon">${isCollapsed ? "πŸ“" : "πŸ“‚"}</span>
<span>${node.name}</span>
`;
// Toggle collapse on click
folderHeader.addEventListener("click", () => {
const isCurrentlyCollapsed = folderHeader.querySelector(".icon").textContent === "πŸ“";
folderHeader.querySelector(".icon").textContent = isCurrentlyCollapsed ? "πŸ“‚" : "πŸ“";
childrenContainer.style.display = isCurrentlyCollapsed ? "block" : "none";
});
folderEl.appendChild(folderHeader);
const childrenContainer = document.createElement("div");
childrenContainer.className = "tree-children";
childrenContainer.style.display = isCollapsed ? "none" : "block";
if (node.children) {
node.children.forEach(child => renderNode(child, childrenContainer, level + 1));
}
folderEl.appendChild(childrenContainer);
parentEl.appendChild(folderEl);
} else {
const fileEl = document.createElement("div");
fileEl.className = "tree-item";
fileEl.style.paddingLeft = `${level * 1.5}rem`;
fileEl.dataset.path = node.path; // Add data attribute for identification
fileEl.innerHTML = `
<span class="icon">πŸ“„</span>
<span>${node.name}</span>
`;
fileEl.addEventListener("click", e => {
// Highlight active file properly
document.querySelectorAll(".tree-item").forEach(el => el.classList.remove("active"));
fileEl.classList.add("active");
this.previewFile(node.path);
// Mobile UX: Close sidebar automatically after file selection
if (window.innerWidth <= 900) {
const sidebarLeft = document.getElementById("sidebar-left");
const overlay = document.getElementById("sidebar-overlay");
sidebarLeft.classList.remove("visible");
overlay.classList.remove("active");
document.getElementById("btn-toggle-left").classList.remove("active");
}
});
parentEl.appendChild(fileEl);
}
};
if (tree.children) {
tree.children.forEach(child => renderNode(child, this.elements.fileTree));
}
}
async previewFile(path) {
try {
const fileEntry = FileSystem.getFileByPath(path);
if (!fileEntry) return;
// Highlight active file IMMEDIATELY (before loading) for instant feedback
document.querySelectorAll(".tree-item").forEach(el => el.classList.remove("active"));
// Find and highlight the clicked item by path (not name - multiple files can have same name!)
const activeItem = document.querySelector(`.tree-item[data-path="${CSS.escape(fileEntry.path)}"]`);
if (activeItem) activeItem.classList.add("active");
this.state.currentFile = fileEntry;
// Show loading indicator for large files (>100KB)
if (fileEntry.size > 100 * 1024) {
this.elements.filePreview.innerHTML = '<div class="empty-state"><p>πŸ“„ Loading file...</p></div>';
}
// Read file content
const content = await FileSystem.readFile(fileEntry);
// Render preview
const language = fileEntry.language;
const escapedContent = Utils.escapeHtml(content);
const escapedPath = Utils.escapeHtml(fileEntry.path);
const escapedName = Utils.escapeHtml(fileEntry.name);
// Check if file can be shown as artifact
const canShowArtifact = ["html", "htm"].includes(fileEntry.extension);
this.elements.filePreview.innerHTML = `
<div class="preview-header">
<span class="preview-filename">${escapedPath}</span>
<div class="preview-actions">
<button class="btn-icon" onclick="app.copyFileContent()" title="Copy">πŸ“‹</button>
<button class="btn-icon" onclick="app.viewFileFullscreen()" title="View Fullscreen">πŸ”</button>
${canShowArtifact ? '<button class="btn-icon" onclick="app.viewAsArtifact()" title="Preview as Artifact">✨</button>' : ""}
</div>
</div>
<pre><code class="language-${language}">${escapedContent}</code></pre>
`;
// Apply syntax highlighting
if (this.state.syntaxHighlighting && window.Prism) {
const codeBlock = this.elements.filePreview.querySelector("code");
Prism.highlightElement(codeBlock);
}
} catch (err) {
console.error("Failed to preview file:", err);
Utils.toast.error("Failed to preview file");
}
}
async copyFileContent() {
if (!this.state.currentFile) return;
try {
const content = await FileSystem.readFile(this.state.currentFile);
await Utils.copyToClipboard(content);
} catch (err) {
console.error("Failed to copy:", err);
Utils.toast.error("Failed to copy file content");
}
}
searchFiles(query) {
if (!query) {
this.renderFileTree();
return;
}
const results = FileSystem.searchFiles(query);
this.elements.fileTree.innerHTML = "";
if (results.length === 0) {
this.elements.fileTree.innerHTML = `
<div class="empty-state">
<p>No files found</p>
</div>
`;
return;
}
results.forEach(file => {
const fileEl = document.createElement("div");
fileEl.className = "tree-item";
fileEl.dataset.path = file.path;
fileEl.innerHTML = `
<span class="icon">πŸ“„</span>
<span>${file.path}</span>
`;
fileEl.addEventListener("click", e => {
document.querySelectorAll(".tree-item").forEach(el => el.classList.remove("active"));
fileEl.classList.add("active");
this.previewFile(file.path);
// Mobile UX: Close sidebar automatically after file selection
if (window.innerWidth <= 900) {
const sidebarLeft = document.getElementById("sidebar-left");
const overlay = document.getElementById("sidebar-overlay");
sidebarLeft.classList.remove("visible");
overlay.classList.remove("active");
document.getElementById("btn-toggle-left").classList.remove("active");
}
});
this.elements.fileTree.appendChild(fileEl);
});
}
async loadHistory() {
const chats = await DB.getChats(20);
const historyList = document.getElementById("history-list");
if (chats.length === 0) {
historyList.innerHTML = `<p class="empty-state">No chat history yet</p>`;
return;
}
// Add read-only notice at the top
let html = `<p style="padding: 0.75rem 1rem; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.85rem; margin: 0; border-bottom: 1px solid var(--border-color);">
πŸ“– Read-only history β€” Start a new chat to continue coding!
</p>`;
html += chats
.map(chat => {
const date = new Date(chat.timestamp);
// Escape ALL user-controlled content to prevent XSS
const escapedModel = Utils.escapeHtml(chat.model || "unknown");
const escapedUserMsg = Utils.escapeHtml(Utils.truncate(chat.userMessage, 100));
const escapedAssistantMsg = Utils.escapeHtml(Utils.truncate(chat.assistantMessage, 150));
const escapedDate = Utils.escapeHtml(date.toLocaleString());
const escapedFolderName = Utils.escapeHtml(chat.folderName || "Unknown");
return `
<div class="history-item" style="padding: 1rem; border-bottom: 1px solid var(--border-color);">
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 0.5rem;">
${escapedDate} β€’ ${escapedModel} β€’ ${escapedFolderName}
</div>
<div style="margin-bottom: 0.5rem;">
<strong>You:</strong> ${escapedUserMsg}
</div>
<div>
<strong>Assistant:</strong> ${escapedAssistantMsg}
</div>
</div>
`;
})
.join("");
historyList.innerHTML = html;
}
// Navigate file tree with keyboard
navigateFileTree(direction) {
const items = Array.from(document.querySelectorAll(".tree-item[data-path]"));
if (items.length === 0) return;
const currentIndex = items.findIndex(el => el.classList.contains("active"));
let newIndex = currentIndex + direction;
// Wrap around
if (newIndex < 0) newIndex = items.length - 1;
if (newIndex >= items.length) newIndex = 0;
// Update active state
items.forEach(el => el.classList.remove("active"));
items[newIndex].classList.add("active");
items[newIndex].focus();
items[newIndex].scrollIntoView({ block: "nearest" });
}
// Fullscreen Code Viewer
async viewFileFullscreen() {
if (!this.state.currentFile) return;
try {
const content = await FileSystem.readFile(this.state.currentFile);
const language = this.state.currentFile.language;
const escapedContent = Utils.escapeHtml(content);
// Update modal content
document.getElementById("viewer-filename").textContent = `πŸ“„ ${this.state.currentFile.name}`;
document.getElementById("viewer-file-info").textContent =
`${this.state.currentFile.extension} β€’ ${Utils.formatFileSize(this.state.currentFile.size)}`;
const viewerContent = document.getElementById("viewer-content");
viewerContent.innerHTML = `<pre><code class="language-${language}">${escapedContent}</code></pre>`;
// Apply syntax highlighting
if (this.state.syntaxHighlighting && window.Prism) {
const codeBlock = viewerContent.querySelector("code");
Prism.highlightElement(codeBlock);
}
// Show/hide artifact button
const artifactBtn = document.getElementById("btn-viewer-artifact");
const canShowArtifact = ["html", "htm"].includes(this.state.currentFile.extension);
artifactBtn.style.display = canShowArtifact ? "block" : "none";
// Setup buttons
document.getElementById("btn-viewer-copy").onclick = () => this.copyFileContent();
document.getElementById("btn-viewer-artifact").onclick = () => {
Utils.modal.close("modal-code-viewer");
this.viewAsArtifact();
};
// Open modal
Utils.modal.open("modal-code-viewer");
} catch (err) {
console.error("Failed to view file:", err);
Utils.toast.error("Failed to open file viewer");
}
}
// Artifact Preview (HTML Live Preview)
async viewAsArtifact() {
if (!this.state.currentFile) return;
try {
const content = await FileSystem.readFile(this.state.currentFile);
// Update modal
document.getElementById("artifact-filename").textContent = this.state.currentFile.name;
// Resolve dependencies (CSS/JS in same folder)
const processedContent = await this.resolveArtifactDependencies(content);
// Load into iframe
const iframe = document.getElementById("artifact-iframe");
// Security: sandbox restricts capabilities
// allow-scripts: needed for JS in artifacts
// allow-forms: needed for form submission preview
// NOTE: allow-same-origin removed for security (prevents parent access)
iframe.setAttribute("sandbox", "allow-scripts allow-forms");
iframe.srcdoc = processedContent;
// Setup buttons
document.getElementById("btn-artifact-refresh").onclick = () => {
iframe.srcdoc = processedContent;
Utils.toast.success("Artifact refreshed");
};
document.getElementById("btn-artifact-code").onclick = () => {
Utils.modal.close("modal-artifact");
this.viewFileFullscreen();
};
// Open modal
Utils.modal.open("modal-artifact");
} catch (err) {
console.error("Failed to view artifact:", err);
Utils.toast.error("Failed to preview artifact");
}
}
// Resolve CSS/JS dependencies in HTML
async resolveArtifactDependencies(htmlContent) {
// Get current file's directory
const currentPath = this.state.currentFile.path;
const dirPath = currentPath.substring(0, currentPath.lastIndexOf("/"));
// Find all local references (href/src with relative paths)
let processed = htmlContent;
// Match CSS links
const cssRegex = /<link[^>]+href=["'](?!http|https|\/\/)([^"']+)["'][^>]*>/gi;
const cssMatches = [...htmlContent.matchAll(cssRegex)];
for (const match of cssMatches) {
const relativePath = match[1];
const fullPath = dirPath ? `${dirPath}/${relativePath}` : relativePath;
const cssFile = FileSystem.getFileByPath(fullPath);
if (cssFile) {
try {
const cssContent = await FileSystem.readFile(cssFile);
// Replace link tag with inline style
processed = processed.replace(match[0], `<style>${cssContent}</style>`);
} catch (err) {
console.warn(`Could not load CSS: ${fullPath}`);
}
}
}
// Match JS scripts
const jsRegex = /<script[^>]+src=["'](?!http|https|\/\/)([^"']+)["'][^>]*><\/script>/gi;
const jsMatches = [...htmlContent.matchAll(jsRegex)];
for (const match of jsMatches) {
const relativePath = match[1];
const fullPath = dirPath ? `${dirPath}/${relativePath}` : relativePath;
const jsFile = FileSystem.getFileByPath(fullPath);
if (jsFile) {
try {
const jsContent = await FileSystem.readFile(jsFile);
// Replace script tag with inline script
processed = processed.replace(match[0], `<script>${jsContent}</script>`);
} catch (err) {
console.warn(`Could not load JS: ${fullPath}`);
}
}
}
return processed;
}
}
// Create global app instance
const app = new App();
// Initialize on DOM ready
document.addEventListener("DOMContentLoaded", () => {
app.init();
});
// Export for window access (for inline onclick handlers)
window.app = app;
export default app;