967 lines
36 KiB
JavaScript
967 lines
36 KiB
JavaScript
// =====================================================================
|
|
// Sulfur Launcher — Core Logic & Instance Engine
|
|
// =====================================================================
|
|
|
|
const STORAGE_KEY = 'sulfur:state';
|
|
const DB_NAME = 'SulfurLauncherDB';
|
|
const DB_VERSION = 1;
|
|
|
|
// --- 1. Default Instances Catalog --------------------------------------
|
|
const DEFAULT_INSTANCES = [
|
|
{
|
|
id: 'sulfur-v25',
|
|
title: 'Sulfur Client v2.5',
|
|
icon: 'assets/thumb-atm10.png',
|
|
bg: 'assets/bg-atm10.jpg',
|
|
tags: ['2.5', 'SULFUR', 'FPS-BOOST'],
|
|
playtimeSeconds: 7200,
|
|
lastPlayed: new Date(Date.now() - 3600 * 2000).toISOString(),
|
|
launchesCount: 14,
|
|
launchType: 'local',
|
|
launchFile: 'instances/sulfur.html'
|
|
},
|
|
{
|
|
id: 'resent-188',
|
|
title: 'Resent Client 1.8.8',
|
|
icon: 'assets/thumb-vanilla8.png',
|
|
bg: 'assets/bg-vanilla8.jpg',
|
|
tags: ['1.8.8', 'MODDED', 'PVP'],
|
|
playtimeSeconds: 14400,
|
|
lastPlayed: new Date(Date.now() - 3600 * 24000).toISOString(),
|
|
launchesCount: 28,
|
|
launchType: 'local',
|
|
launchFile: 'instances/resent_client.html'
|
|
},
|
|
{
|
|
id: 'vanilla-188',
|
|
title: 'Vanilla 1.8.8',
|
|
icon: 'assets/thumb-vanilla8.png',
|
|
bg: 'assets/bg-vanilla8.jpg',
|
|
tags: ['1.8.8', 'VANILLA', 'MULTIPLAYER'],
|
|
playtimeSeconds: 43200,
|
|
lastPlayed: new Date(Date.now() - 3600 * 48000).toISOString(),
|
|
launchesCount: 52,
|
|
launchType: 'local',
|
|
launchFile: 'instances/eagler_1_8_8.html'
|
|
}
|
|
];
|
|
|
|
// Curated Collection Presets catalog
|
|
const CURATED_PRESETS = [
|
|
{
|
|
id: 'preset-sulfur',
|
|
title: 'Sulfur Client v2.5 Obsidian',
|
|
version: '2.5',
|
|
description: 'Official Sulfur web client with WebGL shader optimization and server list.',
|
|
icon: 'assets/thumb-atm10.png',
|
|
bg: 'assets/bg-atm10.jpg',
|
|
tags: ['2.5', 'SULFUR', 'OFFICIAL'],
|
|
launchType: 'local',
|
|
launchFile: 'instances/sulfur.html'
|
|
},
|
|
{
|
|
id: 'preset-resent',
|
|
title: 'Resent Client 1.8.8',
|
|
version: '1.8.8',
|
|
description: 'Popular PvP modpack client built for Eaglercraft 1.8.8.',
|
|
icon: 'assets/thumb-vanilla8.png',
|
|
bg: 'assets/bg-vanilla8.jpg',
|
|
tags: ['1.8.8', 'PVP', 'MODDED'],
|
|
launchType: 'local',
|
|
launchFile: 'instances/resent_client.html'
|
|
},
|
|
{
|
|
id: 'preset-vanilla',
|
|
title: 'Vanilla Eagler 1.8.8',
|
|
version: '1.8.8',
|
|
description: 'Clean standard Eaglercraft 1.8.8 vanilla client build.',
|
|
icon: 'assets/thumb-vanilla8.png',
|
|
bg: 'assets/bg-vanilla8.jpg',
|
|
tags: ['1.8.8', 'VANILLA', 'CLEAN'],
|
|
launchType: 'local',
|
|
launchFile: 'instances/eagler_1_8_8.html'
|
|
}
|
|
];
|
|
|
|
// --- 2. IndexedDB Storage Manager -------------------------------------
|
|
class SulfurDBManager {
|
|
constructor() {
|
|
this.db = null;
|
|
}
|
|
|
|
async init() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
req.onupgradeneeded = e => {
|
|
const db = e.target.result;
|
|
if (!db.objectStoreNames.contains('instances_files')) {
|
|
db.createObjectStore('instances_files', { keyPath: 'id' });
|
|
}
|
|
};
|
|
req.onsuccess = e => {
|
|
this.db = e.target.result;
|
|
resolve(this.db);
|
|
};
|
|
req.onerror = e => reject(e.target.error);
|
|
});
|
|
}
|
|
|
|
async saveClientFile(fileId, content) {
|
|
if (!this.db) await this.init();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = this.db.transaction('instances_files', 'readwrite');
|
|
const store = tx.objectStore('instances_files');
|
|
const req = store.put({ id: fileId, content, updatedAt: Date.now() });
|
|
req.onsuccess = () => resolve(fileId);
|
|
req.onerror = e => reject(e.target.error);
|
|
});
|
|
}
|
|
|
|
async getClientFile(fileId) {
|
|
if (!this.db) await this.init();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = this.db.transaction('instances_files', 'readonly');
|
|
const store = tx.objectStore('instances_files');
|
|
const req = store.get(fileId);
|
|
req.onsuccess = () => resolve(req.result ? req.result.content : null);
|
|
req.onerror = e => reject(e.target.error);
|
|
});
|
|
}
|
|
|
|
async deleteClientFile(fileId) {
|
|
if (!this.db) await this.init();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = this.db.transaction('instances_files', 'readwrite');
|
|
const store = tx.objectStore('instances_files');
|
|
const req = store.delete(fileId);
|
|
req.onsuccess = () => resolve();
|
|
req.onerror = e => reject(e.target.error);
|
|
});
|
|
}
|
|
}
|
|
|
|
const dbManager = new SulfurDBManager();
|
|
|
|
// --- 3. State Management -----------------------------------------------
|
|
function loadState() {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) throw new Error('no state');
|
|
const parsed = JSON.parse(raw);
|
|
return {
|
|
instances: parsed.instances && parsed.instances.length ? parsed.instances : DEFAULT_INSTANCES,
|
|
activeInstanceId: parsed.activeInstanceId || DEFAULT_INSTANCES[0].id,
|
|
onboardingCompleted: parsed.onboardingCompleted || false,
|
|
settings: Object.assign({ reduceMotion: false, compactSidebar: false }, parsed.settings || {})
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
instances: DEFAULT_INSTANCES,
|
|
activeInstanceId: DEFAULT_INSTANCES[0].id,
|
|
onboardingCompleted: false,
|
|
settings: { reduceMotion: false, compactSidebar: false }
|
|
};
|
|
}
|
|
}
|
|
|
|
function saveState() {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
} catch (e) { /* fail-safe */ }
|
|
}
|
|
|
|
const state = loadState();
|
|
let activeSession = null;
|
|
let deferredPrompt = null; // PWA install prompt
|
|
|
|
// --- 4. Utilities & Formatters -----------------------------------------
|
|
function toast(message) {
|
|
const container = document.getElementById('toast-container');
|
|
const el = document.createElement('div');
|
|
el.className = 'toast';
|
|
el.textContent = message;
|
|
container.appendChild(el);
|
|
setTimeout(() => {
|
|
el.classList.add('leaving');
|
|
setTimeout(() => el.remove(), 220);
|
|
}, 3200);
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str || '';
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatPlaytime(seconds) {
|
|
if (!seconds || seconds <= 0) return 'Never played';
|
|
const hrs = Math.floor(seconds / 3600);
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
if (hrs > 0) return `${hrs}h ${mins}m played`;
|
|
return `${mins}m played`;
|
|
}
|
|
|
|
function formatLastPlayed(timestamp) {
|
|
if (!timestamp) return 'Never';
|
|
const date = new Date(timestamp);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
|
const diffHrs = Math.floor(diffMs / (1000 * 60 * 60));
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffMins < 2) return 'Just now';
|
|
if (diffMins < 60) return `${diffMins}m ago`;
|
|
if (diffHrs < 24) return `${diffHrs}h ago`;
|
|
if (diffDays === 1) return 'Yesterday';
|
|
return `${diffDays}d ago`;
|
|
}
|
|
|
|
// --- 5. Startup Animation ----------------------------------------------
|
|
function runStartup() {
|
|
const startupScreen = document.getElementById('startup-screen');
|
|
const status = document.getElementById('startup-status');
|
|
const messages = ['Loading instances…', 'Initializing Sulfur engine…', 'Preparing stage…'];
|
|
let i = 0;
|
|
|
|
if (state.settings.reduceMotion) {
|
|
startupScreen.classList.add('removed');
|
|
checkOnboarding();
|
|
return;
|
|
}
|
|
|
|
const msgInterval = setInterval(() => {
|
|
i = (i + 1) % messages.length;
|
|
status.textContent = messages[i];
|
|
}, 600);
|
|
|
|
setTimeout(() => {
|
|
clearInterval(msgInterval);
|
|
startupScreen.classList.add('wipe-out');
|
|
setTimeout(() => {
|
|
startupScreen.classList.add('removed');
|
|
checkOnboarding();
|
|
}, 900);
|
|
}, 1800);
|
|
}
|
|
|
|
function checkOnboarding() {
|
|
if (!state.onboardingCompleted) {
|
|
openModal('modal-onboarding');
|
|
}
|
|
}
|
|
|
|
// --- 6. Sidebar & Stage Renderers ---------------------------------------
|
|
function renderSidebar() {
|
|
const sidebarList = document.getElementById('sidebar-instances');
|
|
sidebarList.innerHTML = '';
|
|
|
|
state.instances.forEach(inst => {
|
|
const iconDiv = document.createElement('div');
|
|
iconDiv.className = `instance-icon ${inst.id === state.activeInstanceId ? 'active' : ''}`;
|
|
iconDiv.style.backgroundImage = `url('${inst.icon}')`;
|
|
iconDiv.title = inst.title;
|
|
iconDiv.onclick = () => selectInstance(inst.id, true);
|
|
sidebarList.appendChild(iconDiv);
|
|
});
|
|
}
|
|
|
|
function renderLibrary() {
|
|
const grid = document.getElementById('library-grid');
|
|
grid.innerHTML = '';
|
|
state.instances.forEach(inst => {
|
|
const card = document.createElement('div');
|
|
card.className = `library-card ${inst.id === state.activeInstanceId ? 'active' : ''}`;
|
|
card.style.backgroundImage = `url('${inst.bg}')`;
|
|
card.innerHTML = `
|
|
<div class="library-card-overlay">
|
|
<h4>${escapeHtml(inst.title)}</h4>
|
|
<span>${inst.tags.join(' · ')} • ${formatPlaytime(inst.playtimeSeconds)}</span>
|
|
</div>
|
|
`;
|
|
card.onclick = () => {
|
|
selectInstance(inst.id, true);
|
|
switchView('play');
|
|
};
|
|
grid.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function renderDiscoverCatalog() {
|
|
const catalog = document.getElementById('discover-catalog');
|
|
catalog.innerHTML = '';
|
|
|
|
CURATED_PRESETS.forEach(preset => {
|
|
const card = document.createElement('div');
|
|
card.className = 'discover-card';
|
|
const isInstalled = state.instances.some(i => i.title === preset.title);
|
|
|
|
card.innerHTML = `
|
|
<div>
|
|
<h3>${escapeHtml(preset.title)}</h3>
|
|
<p>${escapeHtml(preset.description)}</p>
|
|
<div class="discover-card-tags">
|
|
${preset.tags.map(t => `<span class="tag">${t}</span>`).join('')}
|
|
</div>
|
|
</div>
|
|
<button class="btn-primary btn-full-width" ${isInstalled ? 'disabled' : ''} id="btn-install-${preset.id}">
|
|
${isInstalled ? 'Installed' : 'Install Instance'}
|
|
</button>
|
|
`;
|
|
catalog.appendChild(card);
|
|
|
|
if (!isInstalled) {
|
|
document.getElementById(`btn-install-${preset.id}`).onclick = () => installCuratedPreset(preset);
|
|
}
|
|
});
|
|
}
|
|
|
|
function selectInstance(id, persist) {
|
|
state.activeInstanceId = id;
|
|
renderSidebar();
|
|
renderLibrary();
|
|
loadInstance(id);
|
|
if (persist) saveState();
|
|
}
|
|
|
|
function loadInstance(id) {
|
|
const inst = state.instances.find(i => i.id === id);
|
|
if (!inst) return;
|
|
|
|
document.getElementById('background-layer').style.backgroundImage = `url('${inst.bg}')`;
|
|
document.getElementById('instance-title').innerText = inst.title;
|
|
|
|
const playtimeText = activeSession && activeSession.instanceId === id
|
|
? `Playing now • ${formatPlaytime(inst.playtimeSeconds)}`
|
|
: `${formatPlaytime(inst.playtimeSeconds)} • Last played ${formatLastPlayed(inst.lastPlayed)}`;
|
|
document.getElementById('instance-playtime').innerText = playtimeText;
|
|
|
|
const tagsContainer = document.getElementById('instance-tags');
|
|
tagsContainer.innerHTML = '';
|
|
inst.tags.forEach(tag => {
|
|
const tagSpan = document.createElement('span');
|
|
tagSpan.className = 'tag';
|
|
tagSpan.innerText = tag;
|
|
tagsContainer.appendChild(tagSpan);
|
|
});
|
|
}
|
|
|
|
function switchView(view) {
|
|
document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.view === view);
|
|
});
|
|
document.querySelectorAll('.view').forEach(section => {
|
|
section.classList.toggle('active', section.id === `view-${view}`);
|
|
});
|
|
if (view === 'library') renderLibrary();
|
|
if (view === 'discover') renderDiscoverCatalog();
|
|
}
|
|
|
|
// --- 7. Play Button & Session Engine -----------------------------------
|
|
async function initPlayButton() {
|
|
document.getElementById('play-btn').addEventListener('click', async () => {
|
|
const inst = state.instances.find(i => i.id === state.activeInstanceId);
|
|
if (!inst) return;
|
|
|
|
const btn = document.getElementById('play-btn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<svg class="icon"><use href="#icon-hourglass"></use></svg> LAUNCHING…';
|
|
|
|
let targetUrl = inst.launchFile;
|
|
|
|
// If local IndexedDB client file, resolve Blob URL
|
|
if (inst.launchType === 'db' || targetUrl.startsWith('db:')) {
|
|
const fileId = targetUrl.replace('db:', '');
|
|
const htmlContent = await dbManager.getClientFile(fileId);
|
|
if (!htmlContent) {
|
|
toast('Error: Local client HTML file not found in database.');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<svg class="icon"><use href="#icon-play"></use></svg> PLAY';
|
|
return;
|
|
}
|
|
const blob = new Blob([htmlContent], { type: 'text/html' });
|
|
targetUrl = URL.createObjectURL(blob);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
const win = window.open(
|
|
targetUrl,
|
|
`SulfurClient_${inst.id}`,
|
|
'popup=1,width=1280,height=720,menubar=no,toolbar=no,location=no,status=no'
|
|
);
|
|
|
|
if (!win) {
|
|
toast('Browser blocked popup window — allow popups for Sulfur.');
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<svg class="icon"><use href="#icon-play"></use></svg> PLAY';
|
|
return;
|
|
}
|
|
|
|
// Start Session Tracking
|
|
inst.launchesCount = (inst.launchesCount || 0) + 1;
|
|
inst.lastPlayed = new Date().toISOString();
|
|
saveState();
|
|
|
|
startPlaytimeSession(inst, win);
|
|
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<svg class="icon"><use href="#icon-play"></use></svg> PLAYING';
|
|
}, 500);
|
|
});
|
|
}
|
|
|
|
function startPlaytimeSession(inst, win) {
|
|
if (activeSession) clearInterval(activeSession.timerId);
|
|
|
|
const startTime = Date.now();
|
|
const timerId = setInterval(() => {
|
|
if (win.closed) {
|
|
clearInterval(timerId);
|
|
const durationSec = Math.floor((Date.now() - startTime) / 1000);
|
|
inst.playtimeSeconds = (inst.playtimeSeconds || 0) + durationSec;
|
|
inst.lastPlayed = new Date().toISOString();
|
|
saveState();
|
|
|
|
activeSession = null;
|
|
loadInstance(state.activeInstanceId);
|
|
renderLibrary();
|
|
toast(`Session finished — played ${inst.title} for ${formatPlaytime(durationSec)}.`);
|
|
} else {
|
|
inst.playtimeSeconds = (inst.playtimeSeconds || 0) + 1;
|
|
if (state.activeInstanceId === inst.id) {
|
|
document.getElementById('instance-playtime').innerText = `Playing now • ${formatPlaytime(inst.playtimeSeconds)}`;
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
activeSession = { instanceId: inst.id, startTime, timerId, win };
|
|
}
|
|
|
|
// --- 8. Instance Manager (Add, Edit, Delete, Collection Model) --------
|
|
let pendingLocalFile = null;
|
|
let pendingJsonCollection = null;
|
|
|
|
function initAddInstanceModal() {
|
|
// Open Add Modal
|
|
document.getElementById('btn-sidebar-add').addEventListener('click', () => openModal('modal-add-instance'));
|
|
document.getElementById('btn-lib-add').addEventListener('click', () => openModal('modal-add-instance'));
|
|
|
|
// Modal Tabs
|
|
document.querySelectorAll('.modal-tab').forEach(tabBtn => {
|
|
tabBtn.addEventListener('click', () => {
|
|
document.querySelectorAll('.modal-tab').forEach(b => b.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
tabBtn.classList.add('active');
|
|
document.getElementById(tabBtn.dataset.tab).classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Local File Drop Zone
|
|
const dropZone = document.getElementById('file-drop-zone');
|
|
const fileInput = document.getElementById('local-file-input');
|
|
|
|
fileInput.addEventListener('change', e => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
handleSelectedLocalFile(e.target.files[0]);
|
|
}
|
|
});
|
|
|
|
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
|
|
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
|
|
dropZone.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
dropZone.classList.remove('dragover');
|
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
|
handleSelectedLocalFile(e.dataTransfer.files[0]);
|
|
}
|
|
});
|
|
|
|
function handleSelectedLocalFile(file) {
|
|
if (!file.name.endsWith('.html') && !file.name.endsWith('.htm')) {
|
|
toast('Please select an .html or .htm file.');
|
|
return;
|
|
}
|
|
pendingLocalFile = file;
|
|
document.getElementById('drop-filename').innerText = `Selected: ${file.name} (${Math.round(file.size / 1024)} KB)`;
|
|
if (!document.getElementById('add-local-title').value.trim()) {
|
|
document.getElementById('add-local-title').value = file.name.replace(/\.[^/.]+$/, '');
|
|
}
|
|
}
|
|
|
|
// Confirm Add Local
|
|
document.getElementById('confirm-add-local').addEventListener('click', async () => {
|
|
if (!pendingLocalFile) {
|
|
toast('Please select a local HTML file first.');
|
|
return;
|
|
}
|
|
const title = document.getElementById('add-local-title').value.trim() || 'Local Client';
|
|
const tags = document.getElementById('add-local-tags').value.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
|
|
const version = document.getElementById('add-local-version').value.trim() || '1.8.8';
|
|
|
|
const fileId = `file_${Date.now()}`;
|
|
const htmlText = await pendingLocalFile.text();
|
|
await dbManager.saveClientFile(fileId, htmlText);
|
|
|
|
const newInst = {
|
|
id: `inst_${Date.now()}`,
|
|
title,
|
|
tags,
|
|
version,
|
|
icon: 'assets/thumb-atm10.png',
|
|
bg: 'assets/bg-atm10.jpg',
|
|
playtimeSeconds: 0,
|
|
lastPlayed: null,
|
|
launchesCount: 0,
|
|
launchType: 'db',
|
|
launchFile: `db:${fileId}`
|
|
};
|
|
|
|
state.instances.push(newInst);
|
|
saveState();
|
|
selectInstance(newInst.id, true);
|
|
closeModal('modal-add-instance');
|
|
toast(`Instance "${title}" added!`);
|
|
pendingLocalFile = null;
|
|
document.getElementById('drop-filename').innerText = 'Click or Drag & Drop local .html client file here';
|
|
});
|
|
|
|
// Confirm Add Hosted
|
|
document.getElementById('confirm-add-hosted').addEventListener('click', () => {
|
|
const url = document.getElementById('add-hosted-url').value.trim();
|
|
if (!url) { toast('Please enter a launch URL.'); return; }
|
|
const title = document.getElementById('add-hosted-title').value.trim() || 'Hosted Client';
|
|
const tags = document.getElementById('add-hosted-tags').value.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
|
|
const version = document.getElementById('add-hosted-version').value.trim() || '1.8.8';
|
|
|
|
const newInst = {
|
|
id: `inst_${Date.now()}`,
|
|
title,
|
|
tags,
|
|
version,
|
|
icon: 'assets/thumb-vanilla8.png',
|
|
bg: 'assets/bg-vanilla8.jpg',
|
|
playtimeSeconds: 0,
|
|
lastPlayed: null,
|
|
launchesCount: 0,
|
|
launchType: 'url',
|
|
launchFile: url
|
|
};
|
|
|
|
state.instances.push(newInst);
|
|
saveState();
|
|
selectInstance(newInst.id, true);
|
|
closeModal('modal-add-instance');
|
|
toast(`Hosted instance "${title}" added!`);
|
|
});
|
|
|
|
// Populate Curated Collection Tab
|
|
renderPresetCollectionTab();
|
|
|
|
// Import Collection JSON tab logic
|
|
const jsonInput = document.getElementById('json-file-input');
|
|
jsonInput.addEventListener('change', e => {
|
|
if (e.target.files && e.target.files[0]) handleSelectedJson(e.target.files[0]);
|
|
});
|
|
|
|
function handleSelectedJson(file) {
|
|
const reader = new FileReader();
|
|
reader.onload = e => {
|
|
try {
|
|
const data = JSON.parse(e.target.result);
|
|
if (!data.instances || !Array.isArray(data.instances)) throw new Error('Invalid schema');
|
|
pendingJsonCollection = data;
|
|
document.getElementById('json-filename').innerText = `Selected: ${file.name}`;
|
|
document.getElementById('preview-collection-name').innerText = data.name || 'Custom Collection';
|
|
document.getElementById('preview-collection-count').innerText = `${data.instances.length} instances ready to import`;
|
|
document.getElementById('json-import-preview').classList.remove('hidden');
|
|
document.getElementById('confirm-import-json').disabled = false;
|
|
} catch (err) {
|
|
toast('Invalid Sulfur collection JSON file.');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
document.getElementById('confirm-import-json').addEventListener('click', () => {
|
|
if (!pendingJsonCollection) return;
|
|
let addedCount = 0;
|
|
pendingJsonCollection.instances.forEach(item => {
|
|
const exists = state.instances.some(i => i.id === item.id || i.title === item.title);
|
|
if (!exists) {
|
|
state.instances.push({
|
|
id: item.id || `inst_${Date.now()}_${Math.random()}`,
|
|
title: item.title || 'Imported Client',
|
|
tags: item.tags || ['IMPORTED'],
|
|
icon: item.icon || 'assets/thumb-atm10.png',
|
|
bg: item.bg || 'assets/bg-atm10.jpg',
|
|
playtimeSeconds: item.playtimeSeconds || 0,
|
|
lastPlayed: item.lastPlayed || null,
|
|
launchesCount: item.launchesCount || 0,
|
|
launchType: item.launchType || 'local',
|
|
launchFile: item.launchFile || 'instances/sulfur.html'
|
|
});
|
|
addedCount++;
|
|
}
|
|
});
|
|
|
|
saveState();
|
|
renderSidebar();
|
|
renderLibrary();
|
|
closeModal('modal-add-instance');
|
|
toast(`Imported ${addedCount} instances from collection!`);
|
|
});
|
|
}
|
|
|
|
function renderPresetCollectionTab() {
|
|
const container = document.getElementById('preset-collection-list');
|
|
container.innerHTML = '';
|
|
|
|
CURATED_PRESETS.forEach(preset => {
|
|
const item = document.createElement('div');
|
|
item.className = 'collection-item';
|
|
item.innerHTML = `
|
|
<div class="coll-info">
|
|
<h5>${escapeHtml(preset.title)}</h5>
|
|
<p>${preset.tags.join(' · ')} • ${escapeHtml(preset.description)}</p>
|
|
</div>
|
|
<button class="btn-ghost-border" id="btn-add-preset-${preset.id}">Add</button>
|
|
`;
|
|
container.appendChild(item);
|
|
document.getElementById(`btn-add-preset-${preset.id}`).onclick = () => installCuratedPreset(preset);
|
|
});
|
|
}
|
|
|
|
function installCuratedPreset(preset) {
|
|
const exists = state.instances.some(i => i.title === preset.title);
|
|
if (exists) { toast('Instance is already in your launcher!'); return; }
|
|
|
|
const newInst = {
|
|
id: `inst_${Date.now()}`,
|
|
title: preset.title,
|
|
tags: preset.tags,
|
|
icon: preset.icon,
|
|
bg: preset.bg,
|
|
playtimeSeconds: 0,
|
|
lastPlayed: null,
|
|
launchesCount: 0,
|
|
launchType: preset.launchType,
|
|
launchFile: preset.launchFile
|
|
};
|
|
|
|
state.instances.push(newInst);
|
|
saveState();
|
|
selectInstance(newInst.id, true);
|
|
closeModal('modal-add-instance');
|
|
toast(`Added ${preset.title}!`);
|
|
}
|
|
|
|
// --- 9. Edit & Delete Instance -----------------------------------------
|
|
function initEditModal() {
|
|
document.getElementById('btn-edit').addEventListener('click', () => {
|
|
const inst = state.instances.find(i => i.id === state.activeInstanceId);
|
|
if (!inst) return;
|
|
document.getElementById('edit-title').value = inst.title;
|
|
document.getElementById('edit-tags').value = inst.tags.join(', ');
|
|
document.getElementById('edit-launch-file').value = inst.launchFile;
|
|
openModal('modal-edit');
|
|
});
|
|
|
|
document.getElementById('confirm-edit').addEventListener('click', () => {
|
|
const inst = state.instances.find(i => i.id === state.activeInstanceId);
|
|
if (!inst) return;
|
|
|
|
const newTitle = document.getElementById('edit-title').value.trim();
|
|
const newTags = document.getElementById('edit-tags').value
|
|
.split(',').map(t => t.trim().toUpperCase()).filter(Boolean);
|
|
const newTarget = document.getElementById('edit-launch-file').value.trim();
|
|
|
|
if (newTitle) inst.title = newTitle;
|
|
if (newTags.length) inst.tags = newTags;
|
|
if (newTarget) inst.launchFile = newTarget;
|
|
|
|
loadInstance(inst.id);
|
|
renderSidebar();
|
|
renderLibrary();
|
|
saveState();
|
|
closeModal('modal-edit');
|
|
toast('Instance saved.');
|
|
});
|
|
|
|
// Delete Instance
|
|
document.getElementById('btn-delete-instance').addEventListener('click', () => {
|
|
const inst = state.instances.find(i => i.id === state.activeInstanceId);
|
|
if (!inst) return;
|
|
document.getElementById('delete-instance-name').innerText = inst.title;
|
|
closeModal('modal-edit');
|
|
openModal('modal-confirm-delete');
|
|
});
|
|
|
|
document.getElementById('confirm-delete-action').addEventListener('click', async () => {
|
|
const instId = state.activeInstanceId;
|
|
const inst = state.instances.find(i => i.id === instId);
|
|
|
|
if (inst && (inst.launchType === 'db' || inst.launchFile.startsWith('db:'))) {
|
|
const fileId = inst.launchFile.replace('db:', '');
|
|
await dbManager.deleteClientFile(fileId);
|
|
}
|
|
|
|
state.instances = state.instances.filter(i => i.id !== instId);
|
|
if (!state.instances.length) state.instances = DEFAULT_INSTANCES;
|
|
state.activeInstanceId = state.instances[0].id;
|
|
|
|
saveState();
|
|
renderSidebar();
|
|
renderLibrary();
|
|
loadInstance(state.activeInstanceId);
|
|
closeModal('modal-confirm-delete');
|
|
toast('Instance deleted.');
|
|
});
|
|
}
|
|
|
|
// --- 10. Collection Model JSON Export/Import ----------------------------
|
|
function exportCollectionJson() {
|
|
const collectionData = {
|
|
schemaVersion: '1.0',
|
|
name: 'Sulfur Local Collection',
|
|
exportedAt: new Date().toISOString(),
|
|
instances: state.instances.map(inst => ({
|
|
id: inst.id,
|
|
title: inst.title,
|
|
tags: inst.tags,
|
|
icon: inst.icon,
|
|
bg: inst.bg,
|
|
launchType: inst.launchType,
|
|
launchFile: inst.launchType === 'db' ? 'instances/sulfur.html' : inst.launchFile,
|
|
playtimeSeconds: inst.playtimeSeconds || 0,
|
|
lastPlayed: inst.lastPlayed || null
|
|
}))
|
|
};
|
|
|
|
const blob = new Blob([JSON.stringify(collectionData, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'sulfur-collection.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
toast('Exported sulfur-collection.json!');
|
|
}
|
|
|
|
// --- 11. Onboarding Wizard & PWA Setup ---------------------------------
|
|
let currentOnboardingStep = 1;
|
|
|
|
function initOnboardingWizard() {
|
|
document.getElementById('btn-onboarding').addEventListener('click', () => {
|
|
currentOnboardingStep = 1;
|
|
updateOnboardingStep();
|
|
openModal('modal-onboarding');
|
|
});
|
|
|
|
document.getElementById('setting-onboarding-btn').addEventListener('click', () => {
|
|
closeModal('modal-settings');
|
|
currentOnboardingStep = 1;
|
|
updateOnboardingStep();
|
|
openModal('modal-onboarding');
|
|
});
|
|
|
|
document.getElementById('onboarding-next').addEventListener('click', () => {
|
|
if (currentOnboardingStep < 4) {
|
|
currentOnboardingStep++;
|
|
updateOnboardingStep();
|
|
} else {
|
|
finishOnboarding();
|
|
}
|
|
});
|
|
|
|
document.getElementById('onboarding-prev').addEventListener('click', () => {
|
|
if (currentOnboardingStep > 1) {
|
|
currentOnboardingStep--;
|
|
updateOnboardingStep();
|
|
}
|
|
});
|
|
|
|
document.getElementById('onboarding-skip').addEventListener('click', finishOnboarding);
|
|
|
|
// Capture PWA deferred prompt
|
|
window.addEventListener('beforeinstallprompt', e => {
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
});
|
|
|
|
document.getElementById('pwa-install-trigger').addEventListener('click', async () => {
|
|
if (deferredPrompt) {
|
|
deferredPrompt.prompt();
|
|
const { outcome } = await deferredPrompt.userChoice;
|
|
if (outcome === 'accepted') {
|
|
toast('Sulfur Desktop App installed!');
|
|
}
|
|
deferredPrompt = null;
|
|
} else {
|
|
toast('To install: click your browser URL bar install button or Share → Add to Home Screen.');
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateOnboardingStep() {
|
|
document.querySelectorAll('.step-indicator').forEach(ind => {
|
|
const s = parseInt(ind.dataset.step);
|
|
ind.classList.toggle('active', s <= currentOnboardingStep);
|
|
});
|
|
|
|
document.querySelectorAll('.onboarding-slide').forEach((slide, idx) => {
|
|
slide.classList.toggle('active', (idx + 1) === currentOnboardingStep);
|
|
});
|
|
|
|
document.getElementById('onboarding-prev').style.visibility = currentOnboardingStep > 1 ? 'visible' : 'hidden';
|
|
const nextBtn = document.getElementById('onboarding-next');
|
|
nextBtn.innerHTML = currentOnboardingStep === 4 ? 'Get Started ✓' : 'Next →';
|
|
}
|
|
|
|
function finishOnboarding() {
|
|
state.onboardingCompleted = true;
|
|
saveState();
|
|
closeModal('modal-onboarding');
|
|
toast('Welcome to Sulfur Launcher!');
|
|
}
|
|
|
|
// --- 12. Stats & Analytics Panel ---------------------------------------
|
|
function initStatsModal() {
|
|
document.getElementById('btn-stats').addEventListener('click', () => {
|
|
renderStatsData();
|
|
openModal('modal-stats');
|
|
});
|
|
}
|
|
|
|
function renderStatsData() {
|
|
const totalSec = state.instances.reduce((acc, i) => acc + (i.playtimeSeconds || 0), 0);
|
|
const totalLaunches = state.instances.reduce((acc, i) => acc + (i.launchesCount || 0), 0);
|
|
|
|
const sortedByTime = [...state.instances].sort((a, b) => (b.playtimeSeconds || 0) - (a.playtimeSeconds || 0));
|
|
const mostPlayed = sortedByTime[0] ? sortedByTime[0].title : 'None';
|
|
|
|
document.getElementById('stat-total-playtime').innerText = formatPlaytime(totalSec);
|
|
document.getElementById('stat-total-launches').innerText = totalLaunches.toString();
|
|
document.getElementById('stat-most-played').innerText = mostPlayed;
|
|
document.getElementById('stat-instance-count').innerText = state.instances.length.toString();
|
|
|
|
const activityList = document.getElementById('stats-instance-list');
|
|
activityList.innerHTML = '';
|
|
|
|
sortedByTime.forEach(inst => {
|
|
const item = document.createElement('div');
|
|
item.className = 'activity-item';
|
|
item.innerHTML = `
|
|
<div><strong>${escapeHtml(inst.title)}</strong></div>
|
|
<div style="font-family:'JetBrains Mono',monospace;color:var(--accent);">${formatPlaytime(inst.playtimeSeconds)} • ${inst.launchesCount || 0} launches</div>
|
|
`;
|
|
activityList.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// --- 13. Settings & Window Controls ------------------------------------
|
|
function initSettingsModal() {
|
|
const reduceMotionInput = document.getElementById('setting-reduce-motion');
|
|
const compactSidebarInput = document.getElementById('setting-compact-sidebar');
|
|
|
|
reduceMotionInput.checked = state.settings.reduceMotion;
|
|
compactSidebarInput.checked = state.settings.compactSidebar;
|
|
document.body.classList.toggle('compact-sidebar', state.settings.compactSidebar);
|
|
|
|
document.getElementById('btn-settings').addEventListener('click', () => openModal('modal-settings'));
|
|
|
|
reduceMotionInput.addEventListener('change', () => {
|
|
state.settings.reduceMotion = reduceMotionInput.checked;
|
|
saveState();
|
|
});
|
|
|
|
compactSidebarInput.addEventListener('change', () => {
|
|
state.settings.compactSidebar = compactSidebarInput.checked;
|
|
document.body.classList.toggle('compact-sidebar', state.settings.compactSidebar);
|
|
saveState();
|
|
});
|
|
|
|
document.getElementById('setting-export-btn').addEventListener('click', exportCollectionJson);
|
|
document.getElementById('btn-lib-export').addEventListener('click', exportCollectionJson);
|
|
|
|
document.getElementById('btn-changelog').addEventListener('click', () => {
|
|
toast('v2.5 — Instance Manager, Playtime Tracker, Local Files & PWA Onboarding!');
|
|
});
|
|
}
|
|
|
|
function initWindowControls() {
|
|
const pill = document.getElementById('minimized-pill');
|
|
|
|
document.getElementById('btn-minimize').addEventListener('click', () => {
|
|
document.body.classList.add('minimized');
|
|
setTimeout(() => pill.classList.remove('hidden'), 250);
|
|
try { window.blur(); } catch (e) {}
|
|
});
|
|
|
|
pill.addEventListener('click', () => {
|
|
pill.classList.add('hidden');
|
|
document.body.classList.remove('minimized');
|
|
try { window.focus(); } catch (e) {}
|
|
});
|
|
|
|
const maxBtn = document.getElementById('btn-maximize');
|
|
const maxIconUse = document.querySelector('#maximize-icon use');
|
|
|
|
maxBtn.addEventListener('click', async () => {
|
|
try {
|
|
if (!document.fullscreenElement) {
|
|
await document.documentElement.requestFullscreen();
|
|
} else {
|
|
await document.exitFullscreen();
|
|
}
|
|
} catch (e) {
|
|
toast('Browser blocked fullscreen.');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('fullscreenchange', () => {
|
|
const isFull = !!document.fullscreenElement;
|
|
maxIconUse.setAttribute('href', isFull ? '#icon-restore' : '#icon-maximize');
|
|
maxBtn.title = isFull ? 'Restore' : 'Maximize';
|
|
});
|
|
|
|
document.getElementById('btn-close').addEventListener('click', () => openModal('modal-close'));
|
|
document.getElementById('confirm-close').addEventListener('click', () => {
|
|
closeModal('modal-close');
|
|
window.close();
|
|
setTimeout(() => {
|
|
toast("Press Ctrl+W (⌘W on Mac) to close this browser tab.");
|
|
}, 300);
|
|
});
|
|
}
|
|
|
|
// Global modal helpers
|
|
function openModal(id) { document.getElementById(id).classList.add('visible'); }
|
|
function closeModal(id) { document.getElementById(id).classList.remove('visible'); }
|
|
|
|
document.addEventListener('click', e => {
|
|
if (e.target.dataset && e.target.dataset.closeModal) closeModal(e.target.dataset.closeModal);
|
|
if (e.target.classList.contains('modal-overlay')) e.target.classList.remove('visible');
|
|
});
|
|
|
|
// --- 14. Boot Application -----------------------------------------------
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await dbManager.init();
|
|
runStartup();
|
|
renderSidebar();
|
|
renderLibrary();
|
|
loadInstance(state.activeInstanceId);
|
|
|
|
// Bind nav views
|
|
document.querySelectorAll('.nav-item[data-view]').forEach(btn => {
|
|
btn.addEventListener('click', () => switchView(btn.dataset.view));
|
|
});
|
|
|
|
initPlayButton();
|
|
initAddInstanceModal();
|
|
initEditModal();
|
|
initOnboardingWizard();
|
|
initStatsModal();
|
|
initSettingsModal();
|
|
initWindowControls();
|
|
});
|