main
This commit is contained in:
+966
@@ -0,0 +1,966 @@
|
||||
// =====================================================================
|
||||
// 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();
|
||||
});
|
||||
Reference in New Issue
Block a user