711 lines
24 KiB
JavaScript
711 lines
24 KiB
JavaScript
/* ObsiGate — Sync: SSE client + PWA registration + Offline mode */
|
||
import { state } from './state.js';
|
||
import { t } from './i18n.js';
|
||
import { showToast } from './ui.js';
|
||
import { api, AuthManager } from './auth.js';
|
||
import { loadVaults, loadTags, refreshTagsForContext, refreshSidebarTreePreservingState } from './sidebar.js';
|
||
import { loadRecentFiles, initAboutModal } from './config.js';
|
||
import { openFile } from './viewer.js';
|
||
import { closeEditor, reloadExternalWrite } from './utils.js';
|
||
import { initOffline, syncFileIndexFromServer } from './offline.js';
|
||
import { initThemes } from './themes.js';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SSE Client — IndexUpdateManager
|
||
// ---------------------------------------------------------------------------
|
||
export const IndexUpdateManager = (() => {
|
||
let eventSource = null;
|
||
let reconnectTimer = null;
|
||
let reconnectDelay = 1000;
|
||
const MAX_RECONNECT_DELAY = 30000;
|
||
let recentEvents = [];
|
||
const MAX_RECENT_EVENTS = 20;
|
||
let connectionState = "disconnected"; // disconnected | connecting | connected
|
||
|
||
function connect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
}
|
||
connectionState = "connecting";
|
||
_updateBadge();
|
||
|
||
eventSource = new EventSource("/api/events");
|
||
|
||
eventSource.addEventListener("connected", (e) => {
|
||
connectionState = "connected";
|
||
reconnectDelay = 1000;
|
||
_updateBadge();
|
||
});
|
||
|
||
eventSource.addEventListener("index_updated", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_updated", data);
|
||
_onIndexUpdated(data);
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_reloaded", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_reloaded", data);
|
||
_onIndexReloaded(data);
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("vault_added", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("vault_added", data);
|
||
showToast(t('sync.vault_added', { vault: data.vault, count: data.stats.file_count }), "info");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("vault_removed", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("vault_removed", data);
|
||
showToast(t('sync.vault_removed', { vault: data.vault }), "info");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_start", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_start", data);
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
showToast(t('sync.index_started', { count: data.total_vaults }), "info");
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_progress", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_progress", data);
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.addEventListener("index_complete", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
_addEvent("index_complete", data);
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
showToast(t('sync.index_complete', { count: data.total_files }), "success");
|
||
loadVaults();
|
||
loadTags();
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
// #169/#190 — Chat : relais des broadcasts SSE vers les panneaux
|
||
eventSource.addEventListener("chat_message", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
import("./filechat.js").then((m) => m.onChatMessage(data)).catch(() => {});
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
// #191 — suppression d'un post
|
||
eventSource.addEventListener("chat_deleted", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {});
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
// #192 — accusé de réception (un participant vient d'ouvrir la conversation)
|
||
eventSource.addEventListener("chat_read", (e) => {
|
||
try {
|
||
const data = JSON.parse(e.data);
|
||
import("./filechat.js").then((m) => m.onChatRead(data)).catch(() => {});
|
||
} catch (err) {
|
||
console.error("SSE parse error:", err);
|
||
}
|
||
});
|
||
|
||
eventSource.onerror = () => {
|
||
connectionState = "disconnected";
|
||
_updateBadge();
|
||
eventSource.close();
|
||
eventSource = null;
|
||
_scheduleReconnect();
|
||
};
|
||
}
|
||
|
||
function _scheduleReconnect() {
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
||
connect();
|
||
}, reconnectDelay);
|
||
}
|
||
|
||
function _addEvent(type, data) {
|
||
recentEvents.unshift({
|
||
type,
|
||
data,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
if (recentEvents.length > MAX_RECENT_EVENTS) {
|
||
recentEvents = recentEvents.slice(0, MAX_RECENT_EVENTS);
|
||
}
|
||
}
|
||
|
||
async function _onIndexUpdated(data) {
|
||
// Brief syncing state
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
|
||
const n = data.total_changes || 0;
|
||
const vaults = (data.vaults || []).join(", ");
|
||
// Toast removed: silent auto-indexing — no notification needed
|
||
|
||
// Refresh sidebar and tags if affected vault matches current context
|
||
const affectsCurrentVault = state.selectedContextVault === "all" || (data.vaults || []).includes(state.selectedContextVault);
|
||
if (affectsCurrentVault) {
|
||
try {
|
||
await Promise.all([refreshSidebarTreePreservingState(), refreshTagsForContext()]);
|
||
// Refresh current file if it was updated
|
||
if (state.currentVault && state.currentPath) {
|
||
const changed = (data.changes || []).some((c) => c.vault === state.currentVault && c.path === state.currentPath);
|
||
if (changed) {
|
||
// #93 — an inline edition session (or Forge) owns the content area:
|
||
// re-render the read view below it would destroy the editor mid-
|
||
// session. Reload the editor buffer from disk instead (same path as
|
||
// an external AI write).
|
||
reloadExternalWrite(state.currentVault, state.currentPath);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error("Error refreshing after index update:", err);
|
||
}
|
||
}
|
||
|
||
// Refresh recent tab if it is active
|
||
if (state.activeSidebarTab === "recent") {
|
||
const vaultFilter = document.getElementById("recent-vault-filter");
|
||
loadRecentFiles(vaultFilter ? vaultFilter.value || null : null);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
}, 1500);
|
||
}
|
||
|
||
async function _onIndexReloaded(data) {
|
||
connectionState = "syncing";
|
||
_updateBadge();
|
||
showToast(t("sync.index_reloaded"), "info");
|
||
try {
|
||
await Promise.all([loadVaults(), loadTags()]);
|
||
} catch (err) {
|
||
console.error("Error refreshing after full reload:", err);
|
||
}
|
||
setTimeout(() => {
|
||
connectionState = "connected";
|
||
_updateBadge();
|
||
}, 1500);
|
||
}
|
||
|
||
function _updateBadge() {
|
||
const badge = document.getElementById("sync-badge");
|
||
if (!badge) return;
|
||
badge.className = "sync-badge sync-badge--" + connectionState;
|
||
const labels = {
|
||
disconnected: t('sync.disconnected'),
|
||
connecting: "Connexion...",
|
||
connected: t('sync.connected'),
|
||
syncing: t('sync.updating'),
|
||
};
|
||
badge.title = labels[connectionState] || connectionState;
|
||
}
|
||
|
||
function disconnect() {
|
||
if (eventSource) {
|
||
eventSource.close();
|
||
eventSource = null;
|
||
}
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
connectionState = "disconnected";
|
||
_updateBadge();
|
||
}
|
||
|
||
function getState() {
|
||
return connectionState;
|
||
}
|
||
|
||
function getRecentEvents() {
|
||
return recentEvents;
|
||
}
|
||
|
||
return { connect, disconnect, getState, getRecentEvents };
|
||
})();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Sync status badge and panel
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function initSyncStatus() {
|
||
const badge = document.getElementById("sync-badge");
|
||
if (!badge) return;
|
||
|
||
badge.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
toggleSyncPanel();
|
||
});
|
||
|
||
IndexUpdateManager.connect();
|
||
|
||
// Initialize AI status tracking
|
||
_initAIStatus();
|
||
}
|
||
|
||
function toggleSyncPanel() {
|
||
let panel = document.getElementById("sync-panel");
|
||
if (panel) {
|
||
panel.remove();
|
||
return;
|
||
}
|
||
// Auto reconnect if disconnected when user opens the panel
|
||
if (IndexUpdateManager.getState() === "disconnected") {
|
||
IndexUpdateManager.connect();
|
||
}
|
||
panel = document.createElement("div");
|
||
panel.id = "sync-panel";
|
||
panel.className = "sync-panel";
|
||
_renderSyncPanel(panel);
|
||
document.body.appendChild(panel);
|
||
|
||
// Close on outside click
|
||
setTimeout(() => {
|
||
document.addEventListener("click", _closeSyncPanelOutside, { once: true });
|
||
}, 0);
|
||
}
|
||
|
||
function _closeSyncPanelOutside(e) {
|
||
const panel = document.getElementById("sync-panel");
|
||
if (panel && !panel.contains(e.target) && e.target.id !== "sync-badge") {
|
||
panel.remove();
|
||
}
|
||
}
|
||
|
||
function _renderSyncPanel(panel) {
|
||
const state = IndexUpdateManager.getState();
|
||
const events = IndexUpdateManager.getRecentEvents();
|
||
|
||
const stateLabels = {
|
||
disconnected: t('sync.disconnected'),
|
||
connecting: "Connexion...",
|
||
connected: t('sync.connected_short'),
|
||
syncing: "Synchronisation...",
|
||
};
|
||
|
||
let html = `<div class="sync-panel__header">
|
||
<span class="sync-panel__title">Synchronisation</span>
|
||
<span class="sync-panel__state sync-panel__state--${state}">${stateLabels[state] || state}</span>
|
||
</div>`;
|
||
|
||
if (events.length === 0) {
|
||
html += `<div class="sync-panel__empty">${t('sync.no_recent_events')}</div>`;
|
||
} else {
|
||
html += `<div class="sync-panel__events">`;
|
||
events.slice(0, 10).forEach((ev) => {
|
||
const time = new Date(ev.timestamp).toLocaleTimeString();
|
||
const typeLabels = {
|
||
index_updated: t('sync.event_index_updated'),
|
||
index_reloaded: "Rechargement",
|
||
vault_added: t('sync.event_vault_added'),
|
||
vault_removed: t('sync.event_vault_removed'),
|
||
index_start: t('sync.event_index_start'),
|
||
index_progress: t('sync.event_index_progress'),
|
||
index_complete: "Indexation tech.",
|
||
};
|
||
const label = typeLabels[ev.type] || ev.type;
|
||
let detail = ev.data.vaults ? ev.data.vaults.join(", ") : ev.data.vault || "";
|
||
if (ev.type === "index_start") detail = `${ev.data.total_vaults} vaults à traiter`;
|
||
if (ev.type === "index_progress") detail = t("sync.vault_files", {vault: ev.data.vault, files: ev.data.files});
|
||
if (ev.type === "index_complete" && ev.data.total_files !== undefined) detail = `${ev.data.total_files} fichiers total`;
|
||
html += `<div class="sync-panel__event">
|
||
<span class="sync-panel__event-type">${label}</span>
|
||
<span class="sync-panel__event-detail">${detail}</span>
|
||
<span class="sync-panel__event-time">${time}</span>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
|
||
panel.innerHTML = html;
|
||
|
||
// ── AI Status section ──
|
||
_renderAIStatusSection(panel);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// AI Status Indicator (inside sync panel + header badge animation)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let _aiProcessing = false;
|
||
let _aiConfigured = null; // null = unknown, true/false
|
||
let _aiAutocomplete = null; // null = unknown, {available, server_ok, model_loaded, model, error}
|
||
|
||
/** Check if AI is configured by calling the /api/ai/status endpoint.
|
||
* Updates _aiConfigured, _aiAutocomplete and refreshes the panel if open. */
|
||
async function _checkAIStatus() {
|
||
try {
|
||
const status = await api('/api/ai/status');
|
||
_aiConfigured = status.configured === true;
|
||
_aiAutocomplete = status.autocomplete || null;
|
||
} catch {
|
||
_aiConfigured = false;
|
||
_aiAutocomplete = null;
|
||
}
|
||
_updateAIStatusInPanel();
|
||
}
|
||
|
||
function _renderAIStatusSection(panel) {
|
||
const section = document.createElement('div');
|
||
section.className = 'sync-panel__ai-section';
|
||
section.id = 'sync-panel-ai';
|
||
|
||
// ---- External provider status ----
|
||
let extIcon, extText, extClass;
|
||
if (_aiProcessing) {
|
||
extIcon = 'loader';
|
||
extText = t('sync.processing');
|
||
extClass = 'sync-panel__ai--processing';
|
||
} else if (_aiConfigured === true) {
|
||
extIcon = 'check-circle';
|
||
extText = 'Fournisseur externe actif';
|
||
extClass = 'sync-panel__ai--active';
|
||
} else if (_aiConfigured === false) {
|
||
extIcon = 'x-circle';
|
||
extText = t('sync.no_provider');
|
||
extClass = 'sync-panel__ai--inactive';
|
||
} else {
|
||
extIcon = 'help-circle';
|
||
extText = t('sync.verifying');
|
||
extClass = 'sync-panel__ai--unknown';
|
||
}
|
||
|
||
// ---- Autocomplete (Ollama) status ----
|
||
let acIcon, acText, acClass;
|
||
if (_aiAutocomplete && _aiAutocomplete.available) {
|
||
acIcon = 'check-circle';
|
||
acText = t('sync.autocomplete_active');
|
||
acClass = 'sync-panel__ai--active';
|
||
} else if (_aiAutocomplete && _aiAutocomplete.server_ok && !_aiAutocomplete.model_loaded) {
|
||
acIcon = 'alert-triangle';
|
||
acText = t('sync.model_not_loaded');
|
||
acClass = 'sync-panel__ai--inactive';
|
||
} else if (_aiAutocomplete && _aiAutocomplete.error) {
|
||
acIcon = 'x-circle';
|
||
acText = 'Serveur Ollama injoignable';
|
||
acClass = 'sync-panel__ai--inactive';
|
||
} else if (_aiAutocomplete === null) {
|
||
acIcon = 'help-circle';
|
||
acText = t('sync.verifying');
|
||
acClass = 'sync-panel__ai--unknown';
|
||
} else {
|
||
acIcon = 'x-circle';
|
||
acText = t('sync.not_configured');
|
||
acClass = 'sync-panel__ai--inactive';
|
||
}
|
||
|
||
section.innerHTML = '' +
|
||
'<div class="sync-panel__ai-title">Intelligence Artificielle</div>' +
|
||
'<div class="sync-panel__ai-row">' +
|
||
'<span class="sync-panel__ai-label">Fournisseur</span>' +
|
||
'<span class="sync-panel__ai-state ' + extClass + '" data-lucide-icon="' + extIcon + '">' +
|
||
'<i data-lucide="' + extIcon + '" style="width:14px;height:14px"></i>' +
|
||
extText +
|
||
'</span>' +
|
||
'</div>' +
|
||
'<div class="sync-panel__ai-row">' +
|
||
'<span class="sync-panel__ai-label">' + t('sync.autocomplete') + '</span>' +
|
||
'<span class="sync-panel__ai-state ' + acClass + '" data-lucide-icon="' + acIcon + '">' +
|
||
'<i data-lucide="' + acIcon + '" style="width:14px;height:14px"></i>' +
|
||
acText +
|
||
'</span>' +
|
||
'</div>';
|
||
|
||
panel.appendChild(section);
|
||
// Trigger icon refresh via global
|
||
if (window._obsigateRefreshIcons) window._obsigateRefreshIcons();
|
||
}
|
||
|
||
/** Update the AI section in the open sync panel. */
|
||
function _updateAIStatusInPanel() {
|
||
const section = document.getElementById('sync-panel-ai');
|
||
if (!section) return;
|
||
section.innerHTML = '';
|
||
const panel = document.getElementById('sync-panel');
|
||
if (panel) {
|
||
// Re-render just the AI section
|
||
const oldSection = panel.querySelector('.sync-panel__ai-section');
|
||
if (oldSection) oldSection.remove();
|
||
_renderAIStatusSection(panel);
|
||
}
|
||
}
|
||
|
||
/** Flash the sync badge white during AI processing.
|
||
* NOTE: now handled via CSS class on #sync-badge.
|
||
* The visual flash in the editor itself is handled by ai.js
|
||
* which adds a processing indicator inline. */
|
||
function _setBadgeAIProcessing(active) {
|
||
const badge = document.getElementById('sync-badge');
|
||
if (!badge) return;
|
||
if (active) {
|
||
badge.classList.add('sync-badge--ai-processing');
|
||
} else {
|
||
badge.classList.remove('sync-badge--ai-processing');
|
||
}
|
||
}
|
||
|
||
/** Initialize AI status tracking. */
|
||
function _initAIStatus() {
|
||
// Check AI status immediately at startup
|
||
_checkAIStatus();
|
||
|
||
// Listen for AI processing events from ai.js
|
||
document.addEventListener('ai-processing-start', () => {
|
||
_aiProcessing = true;
|
||
_setBadgeAIProcessing(true);
|
||
_updateAIStatusInPanel();
|
||
});
|
||
|
||
document.addEventListener('ai-processing-end', () => {
|
||
_aiProcessing = false;
|
||
_setBadgeAIProcessing(false);
|
||
_updateAIStatusInPanel();
|
||
});
|
||
|
||
// Re-check AI status when sync panel opens (in case config changed)
|
||
const badge = document.getElementById('sync-badge');
|
||
if (badge) {
|
||
badge.addEventListener('click', () => {
|
||
_checkAIStatus();
|
||
}, { once: false });
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PWA Service Worker Registration
|
||
// ---------------------------------------------------------------------------
|
||
function registerServiceWorker() {
|
||
if (!("serviceWorker" in navigator)) return;
|
||
|
||
// Reload once when a new service worker takes control (guarded against
|
||
// loops). Only relevant when a worker was already controlling the page.
|
||
const hadController = !!navigator.serviceWorker.controller;
|
||
let refreshing = false;
|
||
navigator.serviceWorker.addEventListener("controllerchange", () => {
|
||
if (!hadController || refreshing) return;
|
||
refreshing = true;
|
||
window.location.reload();
|
||
});
|
||
|
||
window.addEventListener("load", () => {
|
||
navigator.serviceWorker
|
||
.register("/sw.js")
|
||
.then((registration) => {
|
||
console.log("[PWA] Service Worker registered successfully:", registration.scope);
|
||
|
||
// Check for updates periodically.
|
||
setInterval(() => {
|
||
registration.update();
|
||
}, 60000); // Check every minute
|
||
|
||
// Handle service worker updates: activate the new worker immediately
|
||
// so the fresh (network-first) code is served on the next load.
|
||
registration.addEventListener("updatefound", () => {
|
||
const newWorker = registration.installing;
|
||
if (!newWorker) return;
|
||
newWorker.addEventListener("statechange", () => {
|
||
if (newWorker.state === "installed") {
|
||
if (navigator.serviceWorker.controller) {
|
||
newWorker.postMessage({ type: "SKIP_WAITING" });
|
||
showUpdateNotification();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
})
|
||
.catch((error) => {
|
||
console.log("[PWA] Service Worker registration failed:", error);
|
||
});
|
||
});
|
||
}
|
||
|
||
function showUpdateNotification() {
|
||
const message = document.createElement("div");
|
||
message.className = "pwa-update-notification";
|
||
message.innerHTML = `
|
||
<div class="pwa-update-content">
|
||
<span>Une nouvelle version d'ObsiGate est disponible !</span>
|
||
<button class="pwa-update-btn">Mettre à jour</button>
|
||
<button class="pwa-update-dismiss">×</button>
|
||
</div>
|
||
`;
|
||
message.querySelector(".pwa-update-btn").addEventListener("click", function() {
|
||
window.location.reload();
|
||
});
|
||
message.querySelector(".pwa-update-dismiss").addEventListener("click", function() {
|
||
message.remove();
|
||
});
|
||
document.body.appendChild(message);
|
||
|
||
// Auto-dismiss after 30 seconds
|
||
setTimeout(() => {
|
||
if (message.parentElement) {
|
||
message.remove();
|
||
}
|
||
}, 30000);
|
||
}
|
||
|
||
// Handle install prompt
|
||
let deferredPrompt;
|
||
window.addEventListener("beforeinstallprompt", (e) => {
|
||
e.preventDefault();
|
||
deferredPrompt = e;
|
||
|
||
// Show install button if desired
|
||
const installBtn = document.getElementById("pwa-install-btn");
|
||
if (installBtn) {
|
||
installBtn.style.display = "block";
|
||
installBtn.addEventListener("click", async () => {
|
||
if (deferredPrompt) {
|
||
deferredPrompt.prompt();
|
||
const { outcome } = await deferredPrompt.userChoice;
|
||
console.log(`[PWA] User response to install prompt: ${outcome}`);
|
||
deferredPrompt = null;
|
||
installBtn.style.display = "none";
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// Log when app is installed
|
||
window.addEventListener("appinstalled", () => {
|
||
console.log("[PWA] ObsiGate has been installed");
|
||
showToast(t("sync.install_success"), "success");
|
||
});
|
||
|
||
// ── Dashboard tab switching (runs on page load and after rebuild) ──
|
||
function initDashboardTabs() {
|
||
document.querySelectorAll(".dashboard-tab").forEach(tab => {
|
||
// Remove existing listeners by cloning
|
||
const newTab = tab.cloneNode(true);
|
||
tab.parentNode.replaceChild(newTab, tab);
|
||
newTab.addEventListener("click", function() {
|
||
document.querySelectorAll(".dashboard-tab").forEach(t => t.classList.remove("active"));
|
||
document.querySelectorAll(".dashboard-panel").forEach(p => p.classList.remove("active"));
|
||
this.classList.add("active");
|
||
const panel = document.getElementById("dashboard-panel-" + this.dataset.tab);
|
||
if (panel) panel.classList.add("active");
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Init — called by app.js orchestrator
|
||
// ---------------------------------------------------------------------------
|
||
export { initSyncStatus, initDashboardTabs };
|
||
export function init() {
|
||
registerServiceWorker();
|
||
initOffline();
|
||
initThemes();
|
||
initAboutModal();
|
||
// Sync file index after a short delay (let vaults load first).
|
||
// Gate on auth at execution time (not scheduling time) so that
|
||
// initAuth() has a chance to complete and set _authEnabled / token.
|
||
// The login handler triggers syncFileIndexFromServer explicitly on
|
||
// fresh login, so we only do it here for already-authenticated or
|
||
// auth-disabled sessions.
|
||
setTimeout(() => {
|
||
if (!AuthManager._authEnabled || AuthManager.hasSession()) {
|
||
syncFileIndexFromServer();
|
||
}
|
||
}, 3000);
|
||
initDashboardTabs();
|
||
|
||
// Listen for Forge editor rename events
|
||
window.addEventListener('message', function(e) {
|
||
if (!e.data || !e.data.type) return;
|
||
if (e.data.type === 'forge-renamed') {
|
||
var vault = e.data.vault;
|
||
var oldPath = e.data.oldPath;
|
||
var newPath = e.data.newPath;
|
||
console.log('[Forge] File renamed:', vault, oldPath, '->', newPath);
|
||
var titleEl = document.getElementById('editor-title');
|
||
if (titleEl && titleEl.textContent.indexOf('Forge:') === 0) {
|
||
titleEl.textContent = 'Forge: ' + newPath.split('/').pop();
|
||
}
|
||
refreshSidebarTreePreservingState(vault);
|
||
if (state.currentVault === vault && state.currentPath === oldPath) {
|
||
import('./viewer.js').then(function(m) { m.openFile(vault, newPath); });
|
||
}
|
||
}
|
||
if (e.data.type === 'forge-close') {
|
||
// #93 — shared close path: hands the editor container back to the modal
|
||
// (inline mode) and re-renders the document read view.
|
||
closeEditor();
|
||
}
|
||
if (e.data.type === 'forge-open-ai') {
|
||
// #101 — Forge has no rich AI panel of its own: its AI button asks the
|
||
// parent to open the shared AI Assistant (same provider/model, skills,
|
||
// context and history).
|
||
var openForgeAssistant = function() {
|
||
import('./bookslm.js').then(function(m) {
|
||
if (m && m.default) m.default.openForCurrentContext();
|
||
}).catch(function(err) {
|
||
console.warn('[Forge] Unable to open AI assistant', err);
|
||
});
|
||
};
|
||
// BUG-056 — the assistant panel is mounted in this document. The native
|
||
// fullscreen may be owned by the parent (Forge iframe) rather than by the
|
||
// iframe itself, so the parent must also leave fullscreen before the
|
||
// panel can be seen.
|
||
if (document.fullscreenElement && document.exitFullscreen) {
|
||
try {
|
||
document.exitFullscreen().catch(function() {}).then(openForgeAssistant, openForgeAssistant);
|
||
} catch (err) {
|
||
openForgeAssistant();
|
||
}
|
||
} else {
|
||
openForgeAssistant();
|
||
}
|
||
}
|
||
});
|
||
}
|