This commit is contained in:
2026-08-20 18:44:46 -05:00
parent 5a54e50882
commit dbe0ce8af2
16 changed files with 3002 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+625
View File
@@ -0,0 +1,625 @@
:root {
--bg-dark: #0a0a0a;
--sidebar-bg: #121212;
--accent: #e0df94;
--accent-glow: rgba(224, 223, 148, 0.4);
--accent-dim: rgba(224, 223, 148, 0.12);
--text-main: #ffffff;
--text-muted: #a0a0a0;
--glass-bg: rgba(255, 255, 255, 0.05);
--glass-bg-strong: rgba(255, 255, 255, 0.08);
--glass-border: rgba(255, 255, 255, 0.1);
--danger: #ff6b6b;
--success: #6bffb8;
--sidebar-width: 84px;
--sidebar-width-compact: 64px;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-dark);
color: var(--text-main);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
button { font-family: inherit; }
/* Self-contained icon sprite */
.icon-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.icon {
width: 22px;
height: 22px;
flex-shrink: 0;
display: block;
color: inherit;
}
.icon-sm { width: 16px; height: 16px; }
.icon-xl { width: 44px; height: 44px; margin-bottom: 14px; color: var(--text-muted); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
/* Startup screen */
#startup-screen {
position: fixed;
inset: 0;
background: radial-gradient(circle at 50% 45%, #141406 0%, var(--bg-dark) 70%);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 9999;
clip-path: circle(150% at 50% 45%);
transition: clip-path 1s var(--ease);
}
.startup-mark {
position: relative;
width: 84px;
height: 84px;
margin-bottom: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.startup-mark::before {
content: "";
position: absolute;
inset: 0;
border-radius: 22px;
border: 2px solid var(--accent);
animation: ring-pulse 1.6s ease-in-out infinite;
}
.sulfur-logo-anim {
width: 100%;
height: 100%;
border-radius: 18px;
background: var(--accent);
color: var(--bg-dark);
font-family: 'Inter', sans-serif;
font-weight: 800;
font-size: 34px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 40px var(--accent-glow);
animation: sizzle 1.6s ease-in-out infinite;
}
@keyframes ring-pulse {
0% { transform: scale(1); opacity: 0.9; }
70% { transform: scale(1.55); opacity: 0; }
100% { transform: scale(1.55); opacity: 0; }
}
@keyframes sizzle {
0%, 100% { transform: scale(1); box-shadow: 0 0 20px var(--accent-glow); }
50% { transform: scale(1.06); box-shadow: 0 0 45px var(--accent-glow); }
}
#startup-screen h2 {
font-size: 13px;
letter-spacing: 8px;
font-weight: 600;
color: var(--text-main);
margin-bottom: 10px;
}
.startup-status {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
letter-spacing: 1px;
color: var(--text-muted);
}
#startup-screen.wipe-out { clip-path: circle(0% at 50% 45%); }
#startup-screen.removed { display: none; }
/* Title Bar */
#title-bar {
height: 40px;
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
position: relative;
padding: 0 12px;
background: var(--bg-dark);
border-bottom: 1px solid var(--glass-border);
}
.title-text {
font-size: 11px;
letter-spacing: 4px;
color: var(--text-muted);
font-weight: 600;
}
.window-controls {
position: absolute; right: 8px; top: 0; bottom: 0;
display: flex; align-items: center;
}
.win-btn {
width: 34px; height: 28px;
border: none; background: transparent; color: var(--text-muted);
display: flex; align-items: center; justify-content: center;
border-radius: 6px; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.win-btn:hover { background: var(--glass-bg); color: var(--text-main); }
.win-btn-close:hover { background: var(--danger); color: #1a0000; }
/* App shell */
#app-container {
display: flex;
height: calc(100vh - 40px);
transform-origin: center bottom;
transition: transform 0.5s var(--ease), opacity 0.5s var(--ease);
}
body.minimized #app-container {
transform: scale(0.05) translateY(60vh);
opacity: 0;
pointer-events: none;
}
body.compact-sidebar #sidebar { width: var(--sidebar-width-compact); }
body.compact-sidebar .instance-icon,
body.compact-sidebar .app-icon { width: 34px; height: 34px; }
/* Sidebar */
#sidebar {
width: var(--sidebar-width);
background: var(--sidebar-bg);
display: flex; flex-direction: column; align-items: center;
padding: 20px 0;
border-right: 1px solid var(--glass-border);
z-index: 10; flex-shrink: 0;
transition: width 0.25s var(--ease);
}
.sidebar-top { display: flex; flex-direction: column; align-items: center; }
.app-icon {
width: 40px; height: 40px;
background: var(--accent); color: black;
font-weight: 800; font-size: 20px;
display: flex; justify-content: center; align-items: center;
border-radius: 10px; margin-bottom: 22px;
transition: width 0.25s, height 0.25s;
}
.nav-item {
width: 40px; height: 40px;
display: flex; justify-content: center; align-items: center;
color: var(--text-muted);
border-radius: 12px; margin-bottom: 10px;
cursor: pointer; border: none; background: transparent;
transition: all 0.2s;
}
.nav-item .icon { pointer-events: none; }
.nav-item:hover { color: var(--text-main); background: var(--glass-bg); }
.nav-item.active {
color: var(--accent);
background: var(--accent-dim);
box-shadow: inset 2px 0 0 var(--accent);
}
.nav-item-add {
color: var(--accent);
border: 1px dashed var(--accent-dim);
margin-top: 4px;
}
.nav-item-add:hover {
background: var(--accent-dim);
border-color: var(--accent);
}
.instance-list {
flex-grow: 1; width: 100%;
display: flex; flex-direction: column; align-items: center;
overflow-y: auto; overflow-x: hidden;
margin-top: 14px; padding-top: 14px;
border-top: 1px solid var(--glass-border);
}
.instance-list::-webkit-scrollbar { width: 0; }
.instance-icon {
width: 40px; height: 40px;
border-radius: 10px;
background-size: cover; background-position: center;
margin-bottom: 12px; cursor: pointer;
border: 2px solid transparent;
transition: transform 0.2s, border-color 0.2s;
flex-shrink: 0;
}
.instance-icon:hover { transform: scale(1.08); }
.instance-icon.active { border-color: var(--accent); }
.sidebar-bottom { display: flex; flex-direction: column; align-items: center; }
/* Main stage & views */
#main-stage {
flex-grow: 1; position: relative;
background: #000; overflow: hidden;
}
.view {
position: absolute; inset: 0;
display: none; opacity: 0;
}
.view.active {
display: block; opacity: 1;
animation: view-in 0.35s var(--ease);
}
@keyframes view-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
#background-layer {
position: absolute; inset: 0;
background-size: cover; background-position: center;
transition: background-image 0.5s ease-in-out;
}
.vignette-overlay {
position: absolute; inset: 0;
background: linear-gradient(to top, rgba(10,10,10,1) 0%, rgba(10,10,10,0.45) 32%, transparent 100%);
}
.edit-btn {
position: absolute; top: 28px; right: 28px;
width: 40px; height: 40px; border-radius: 50%;
background: rgba(0,0,0,0.5); backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
color: white; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: border-color 0.2s, color 0.2s, transform 0.2s;
z-index: 5;
}
.edit-btn:hover { border-color: var(--accent); color: var(--accent); transform: rotate(-8deg); }
.bottom-ui {
position: absolute; bottom: 36px; left: 40px; right: 40px;
display: flex; justify-content: space-between; align-items: flex-end;
z-index: 5;
}
#instance-title {
font-size: 2.75rem; font-weight: 800; margin-bottom: 14px;
text-shadow: 0 4px 20px rgba(0,0,0,0.8); letter-spacing: -0.5px;
}
.tags { display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
.tag {
background: var(--glass-bg); backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
padding: 5px 11px; border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.72rem; color: var(--text-main);
}
.playtime { color: var(--text-muted); font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
#play-btn {
display: flex; align-items: center; gap: 8px;
background: var(--glass-bg); backdrop-filter: blur(20px);
border: 1px solid var(--accent); color: var(--accent);
padding: 15px 38px; border-radius: 12px;
font-size: 1.05rem; font-weight: 800; letter-spacing: 2px;
cursor: pointer; box-shadow: 0 0 20px var(--accent-dim);
transition: all 0.25s ease; flex-shrink: 0;
}
#play-btn:hover {
background: var(--accent); color: black;
box-shadow: 0 0 30px var(--accent-glow); transform: scale(1.05);
}
#play-btn:disabled { opacity: 0.7; cursor: default; transform: none; }
/* Library + Discover headers */
.view-header {
padding: 40px 48px 0;
display: flex; justify-content: space-between; align-items: flex-end;
}
.view-header h2 { font-size: 2rem; font-weight: 800; margin-bottom: 6px; }
.view-header p { color: var(--text-muted); font-size: 0.9rem; }
.header-actions { display: flex; gap: 12px; align-items: center; }
.btn-ghost-border {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-main);
padding: 10px 16px; border-radius: 8px;
font-size: 0.82rem; font-weight: 700;
cursor: pointer; display: inline-flex; align-items: center; gap: 8px;
transition: all 0.2s ease;
}
.btn-ghost-border:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger-ghost {
background: transparent;
border: 1px solid rgba(255,107,107,0.3);
color: var(--danger);
padding: 10px 16px; border-radius: 8px;
font-size: 0.82rem; font-weight: 700;
cursor: pointer; display: inline-flex; align-items: center; gap: 8px;
transition: all 0.2s ease;
}
.btn-danger-ghost:hover { background: rgba(255,107,107,0.15); border-color: var(--danger); }
.btn-full-width { width: 100%; justify-content: center; padding: 12px; }
/* Library Grid */
.library-grid {
padding: 28px 48px 48px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 18px; overflow-y: auto;
height: calc(100% - 96px);
}
.library-card {
position: relative; border-radius: 14px; overflow: hidden;
height: 150px; cursor: pointer;
background-size: cover; background-position: center;
border: 1px solid var(--glass-border);
transition: transform 0.2s, border-color 0.2s;
}
.library-card:hover { transform: translateY(-4px); border-color: var(--accent); }
.library-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.library-card-overlay {
position: absolute; inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.9), transparent 60%);
display: flex; flex-direction: column; justify-content: flex-end;
padding: 14px;
}
.library-card-overlay h4 { font-size: 1rem; font-weight: 700; margin-bottom: 4px; }
.library-card-overlay span { font-size: 0.7rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
/* Discover Catalog */
.discover-catalog {
padding: 28px 48px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px; overflow-y: auto; height: calc(100% - 96px);
}
.discover-card {
background: var(--sidebar-bg);
border: 1px solid var(--glass-border);
border-radius: 14px; padding: 22px;
display: flex; flex-direction: column; justify-content: space-between;
transition: all 0.2s ease;
}
.discover-card:hover { border-color: var(--accent); transform: translateY(-3px); }
.discover-card h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 6px; }
.discover-card p { font-size: 0.82rem; color: var(--text-muted); line-height: 1.4; margin-bottom: 16px; }
.discover-card-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
/* Minimized pill */
#minimized-pill {
position: fixed; bottom: 24px; left: 50%;
transform: translateX(-50%) translateY(0);
background: var(--sidebar-bg); border: 1px solid var(--accent);
color: var(--accent); padding: 10px 20px; border-radius: 999px;
display: flex; align-items: center; gap: 8px;
font-size: 0.8rem; font-weight: 600; cursor: pointer;
box-shadow: 0 8px 30px rgba(0,0,0,0.5), 0 0 20px var(--accent-dim);
z-index: 500; transition: transform 0.3s var(--ease), opacity 0.3s var(--ease);
}
#minimized-pill.hidden {
opacity: 0; transform: translateX(-50%) translateY(20px);
pointer-events: none;
}
/* Modals */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.65); backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
z-index: 1000; opacity: 0; pointer-events: none;
transition: opacity 0.2s ease;
}
.modal-overlay.visible { opacity: 1; pointer-events: all; }
.modal {
width: 360px; background: var(--sidebar-bg);
border: 1px solid var(--glass-border); border-radius: 18px;
padding: 28px; transform: scale(0.94) translateY(6px);
transition: transform 0.25s var(--ease);
box-shadow: 0 25px 70px rgba(0,0,0,0.7);
}
.modal-wide { width: 560px; max-width: 92vw; }
.modal-overlay.visible .modal { transform: scale(1) translateY(0); }
.modal h3 { font-size: 1.2rem; font-weight: 700; margin-bottom: 4px; }
.modal-subtitle { font-size: 0.83rem; color: var(--text-muted); margin-bottom: 20px; line-height: 1.4; }
.modal p { color: var(--text-muted); font-size: 0.85rem; line-height: 1.5; margin-bottom: 18px; }
/* Modal Tabs */
.modal-tabs {
display: flex; gap: 4px;
border-bottom: 1px solid var(--glass-border);
margin-bottom: 20px;
}
.modal-tab {
background: transparent; border: none;
color: var(--text-muted); padding: 10px 14px;
font-size: 0.82rem; font-weight: 600; cursor: pointer;
border-bottom: 2px solid transparent; transition: all 0.2s ease;
}
.modal-tab:hover { color: var(--text-main); }
.modal-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; animation: fadeIn 0.2s ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
/* File Drop Zone */
.file-drop-zone {
border: 2px dashed var(--glass-border); border-radius: 14px;
padding: 28px 18px; text-align: center; cursor: pointer;
background: var(--glass-bg); transition: all 0.2s ease;
position: relative; margin-bottom: 16px;
display: flex; flex-direction: column; align-items: center;
}
.file-drop-zone:hover, .file-drop-zone.dragover {
border-color: var(--accent); background: var(--accent-dim);
}
.drop-title { font-size: 0.88rem; font-weight: 600; color: var(--text-main); margin-top: 8px; }
.drop-sub { font-size: 0.75rem; color: var(--text-muted); margin-top: 4px; }
.file-input-hidden { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
.form-row { display: flex; gap: 14px; }
.form-col { flex: 1; }
.field-label {
display: block; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 1px; color: var(--text-muted); margin: 14px 0 6px;
}
.field-hint { text-transform: none; letter-spacing: 0; opacity: 0.7; }
.field-input {
width: 100%; background: var(--glass-bg);
border: 1px solid var(--glass-border); border-radius: 8px;
padding: 10px 12px; color: var(--text-main);
font-size: 0.9rem; font-family: inherit; outline: none;
transition: border-color 0.2s;
}
.field-input:focus { border-color: var(--accent); }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
.modal-actions-between { justify-content: space-between !important; }
.btn-ghost, .btn-primary, .btn-danger {
border: none; padding: 10px 18px; border-radius: 8px;
font-size: 0.82rem; font-weight: 700; cursor: pointer;
transition: all 0.15s; font-family: inherit;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-ghost { background: transparent; color: var(--text-muted); }
.btn-ghost:hover { color: var(--text-main); background: var(--glass-bg); }
.btn-primary { background: var(--accent); color: #111; }
.btn-primary:hover { box-shadow: 0 0 20px var(--accent-glow); transform: translateY(-1px); }
.btn-primary:disabled { opacity: 0.5; cursor: default; transform: none; box-shadow: none; }
.btn-danger { background: var(--danger); color: #2a0000; }
.btn-danger:hover { box-shadow: 0 0 20px rgba(255,107,107,0.4); transform: translateY(-1px); }
/* Preset collection list inside modal */
.collection-list { display: flex; flex-direction: column; gap: 10px; max-height: 280px; overflow-y: auto; padding-right: 4px; }
.collection-item {
display: flex; align-items: center; justify-content: space-between;
background: var(--glass-bg); border: 1px solid var(--glass-border);
border-radius: 12px; padding: 12px 16px; transition: border-color 0.2s;
}
.collection-item:hover { border-color: var(--accent); }
.coll-info h5 { font-size: 0.9rem; font-weight: 700; margin-bottom: 2px; }
.coll-info p { font-size: 0.75rem; color: var(--text-muted); }
/* Onboarding Modal */
.onboarding-modal { width: 580px; }
.onboarding-stepper { display: flex; align-items: center; justify-content: center; margin-bottom: 24px; }
.step-indicator {
width: 28px; height: 28px; border-radius: 50%;
background: var(--glass-bg-strong); border: 1px solid var(--glass-border);
color: var(--text-muted); font-size: 0.8rem; font-weight: 700;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s ease;
}
.step-indicator.active { background: var(--accent); color: #0a0a0a; border-color: var(--accent); box-shadow: 0 0 12px var(--accent-glow); }
.step-line { width: 36px; height: 2px; background: var(--glass-border); margin: 0 6px; }
.onboarding-slide { display: none; }
.onboarding-slide.active { display: block; animation: fadeIn 0.25s ease; }
.onboarding-hero { text-align: center; margin-bottom: 20px; }
.onboarding-mark {
width: 52px; height: 52px; border-radius: 14px;
background: var(--accent); color: #0a0a0a; font-weight: 800; font-size: 26px;
display: inline-flex; align-items: center; justify-content: center;
margin-bottom: 12px; box-shadow: 0 0 20px var(--accent-glow);
}
.onboarding-hero h2 { font-size: 1.4rem; font-weight: 800; margin-bottom: 8px; }
.onboarding-hero p { font-size: 0.85rem; color: var(--text-muted); max-width: 460px; margin: 0 auto; line-height: 1.5; }
.onboarding-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 16px; }
.onboard-card {
background: var(--glass-bg); border: 1px solid var(--glass-border);
border-radius: 12px; padding: 16px; text-align: left;
}
.onboard-card h4 { font-size: 0.88rem; font-weight: 700; margin: 8px 0 4px; }
.onboard-card p { font-size: 0.75rem; color: var(--text-muted); line-height: 1.4; }
.pwa-guide-box { background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: 12px; padding: 16px; margin: 16px 0; }
.pwa-step { display: flex; align-items: center; gap: 12px; font-size: 0.82rem; margin-bottom: 10px; }
.pwa-step:last-child { margin-bottom: 0; }
.pwa-badge { background: var(--accent-dim); color: var(--accent); border: 1px solid var(--accent); padding: 3px 8px; border-radius: 6px; font-size: 0.72rem; font-weight: 700; flex-shrink: 0; }
/* Stats Modal */
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
.stat-card { background: var(--glass-bg); border: 1px solid var(--glass-border); border-radius: 12px; padding: 16px; text-align: center; }
.stat-value { font-size: 1.5rem; font-weight: 800; color: var(--accent); margin-bottom: 4px; font-family: 'JetBrains Mono', monospace; }
.stat-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); }
.section-title-sm { font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); margin: 16px 0 10px; }
.instance-activity-list { max-height: 180px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
.activity-item { display: flex; align-items: center; justify-content: space-between; background: var(--glass-bg); padding: 10px 14px; border-radius: 8px; font-size: 0.82rem; }
.import-preview { background: var(--accent-dim); border: 1px solid var(--accent); border-radius: 10px; padding: 12px 16px; margin-top: 12px; }
.preview-title { font-weight: 700; color: var(--accent); font-size: 0.9rem; }
.preview-count { font-size: 0.78rem; color: var(--text-muted); margin-top: 2px; }
/* Settings rows */
.setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 0; border-bottom: 1px solid var(--glass-border);
}
.setting-row:last-of-type { border-bottom: none; }
.setting-title { font-size: 0.88rem; font-weight: 600; }
.setting-desc { font-size: 0.75rem; color: var(--text-muted); margin-top: 2px; max-width: 220px; }
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch-track {
position: absolute; inset: 0;
background: var(--glass-bg-strong); border: 1px solid var(--glass-border);
border-radius: 999px; cursor: pointer; transition: background 0.2s;
}
.switch-track::before {
content: ""; position: absolute;
width: 16px; height: 16px; left: 2px; top: 2px;
background: var(--text-muted); border-radius: 50%;
transition: transform 0.2s, background 0.2s;
}
.switch input:checked + .switch-track { background: var(--accent-dim); border-color: var(--accent); }
.switch input:checked + .switch-track::before { transform: translateX(18px); background: var(--accent); }
/* Toasts */
#toast-container {
position: fixed; top: 56px; right: 20px;
display: flex; flex-direction: column; gap: 10px; z-index: 2000;
}
.toast {
background: var(--sidebar-bg); border: 1px solid var(--glass-border);
border-left: 3px solid var(--accent); padding: 12px 16px; border-radius: 8px;
font-size: 0.82rem; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
animation: toast-in 0.25s var(--ease); max-width: 280px;
}
.toast.leaving { animation: toast-out 0.2s ease forwards; }
@keyframes toast-in { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes toast-out { to { opacity: 0; transform: translateX(20px); } }
.hidden { display: none !important; }
+511
View File
@@ -0,0 +1,511 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sulfur Launcher</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" href="assets/favicon.png" type="image/png">
<link rel="stylesheet" href="css/style.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
</head>
<body>
<!-- SVG Icon Sprite -->
<svg class="icon-sprite" aria-hidden="true">
<defs>
<symbol id="icon-play" viewBox="0 0 24 24"><path d="M7 4.5v15l13-7.5z" fill="currentColor"/></symbol>
<symbol id="icon-minimize" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
<symbol id="icon-maximize" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="icon-restore" viewBox="0 0 24 24"><rect x="4" y="8" width="11" height="11" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="9" y="4" width="11" height="11" rx="1.3" fill="none" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="icon-close" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
<symbol id="icon-category" viewBox="0 0 24 24"><rect x="4" y="4" width="7" height="7" rx="1.3" fill="currentColor"/><rect x="13" y="4" width="7" height="7" rx="1.3" fill="currentColor"/><rect x="4" y="13" width="7" height="7" rx="1.3" fill="currentColor"/><rect x="13" y="13" width="7" height="7" rx="1.3" fill="currentColor"/></symbol>
<symbol id="icon-explore" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M15.2 8.8l-2 4.4-4.4 2 2-4.4z" fill="currentColor"/></symbol>
<symbol id="icon-article" viewBox="0 0 24 24"><rect x="5" y="3" width="14" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="1.6"/><line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/><line x1="8" y1="16" x2="13" y2="16" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></symbol>
<symbol id="icon-bar-chart" viewBox="0 0 24 24"><rect x="4.5" y="13" width="3.4" height="7" rx="0.6" fill="currentColor"/><rect x="10.3" y="8" width="3.4" height="12" rx="0.6" fill="currentColor"/><rect x="16.1" y="4" width="3.4" height="16" rx="0.6" fill="currentColor"/></symbol>
<symbol id="icon-settings" viewBox="0 0 24 24"><line x1="4" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="9.5" cy="7" r="2.1" fill="currentColor"/><line x1="4" y1="12.5" x2="20" y2="12.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="15" cy="12.5" r="2.1" fill="currentColor"/><line x1="4" y1="18" x2="20" y2="18" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="8" cy="18" r="2.1" fill="currentColor"/></symbol>
<symbol id="icon-edit" viewBox="0 0 24 24"><path d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round"/><line x1="13.5" y1="7.5" x2="16.5" y2="10.5" stroke="currentColor" stroke-width="1.6"/></symbol>
<symbol id="icon-expand-less" viewBox="0 0 24 24"><path d="M6 15l6-6 6 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="icon-hourglass" viewBox="0 0 24 24"><path d="M6 3h12M6 21h12M7 3c0 5 4 6 5 6s5-1 5-6M7 21c0-5 4-6 5-6s5 1 5 6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<!-- New Glyphs for Instances, Files, Collections, PWA, Onboarding -->
<symbol id="icon-plus" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
<symbol id="icon-trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="icon-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="icon-upload" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="icon-file" viewBox="0 0 24 24"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" fill="none" stroke="currentColor" stroke-width="1.8"/><polyline points="13 2 13 9 20 9" fill="none" stroke="currentColor" stroke-width="1.8"/></symbol>
<symbol id="icon-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3M12 17h.01" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></symbol>
<symbol id="icon-sparkles" viewBox="0 0 24 24"><path d="M12 3l1.912 5.813a2 2 0 0 0 1.275 1.275L21 12l-5.813 1.912a2 2 0 0 0-1.275 1.275L12 21l-1.912-5.813a2 2 0 0 0-1.275-1.275L3 12l5.813-1.912a2 2 0 0 0 1.275-1.275L12 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></symbol>
<symbol id="icon-app" viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M9 12l2 2 4-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
</defs>
</svg>
<!-- Startup Screen -->
<div id="startup-screen">
<div class="startup-mark">
<div class="sulfur-logo-anim">S</div>
</div>
<h2>S U L F U R</h2>
<div class="startup-status" id="startup-status">Loading instances…</div>
</div>
<!-- Title Bar -->
<div id="title-bar">
<div class="title-text">S U L F U R</div>
<div class="window-controls">
<button class="win-btn" id="btn-minimize" title="Minimize" aria-label="Minimize">
<svg class="icon icon-sm"><use href="#icon-minimize"></use></svg>
</button>
<button class="win-btn" id="btn-maximize" title="Maximize" aria-label="Maximize">
<svg class="icon icon-sm" id="maximize-icon"><use href="#icon-maximize"></use></svg>
</button>
<button class="win-btn win-btn-close" id="btn-close" title="Close" aria-label="Close">
<svg class="icon icon-sm"><use href="#icon-close"></use></svg>
</button>
</div>
</div>
<div id="app-container">
<!-- Sidebar / Dock -->
<nav id="sidebar">
<div class="sidebar-top">
<div class="app-icon" title="Sulfur Launcher">S</div>
<button class="nav-item active" data-view="play" title="Play">
<svg class="icon"><use href="#icon-play"></use></svg>
</button>
<button class="nav-item" data-view="library" title="Library">
<svg class="icon"><use href="#icon-category"></use></svg>
</button>
<button class="nav-item" data-view="discover" title="Discover">
<svg class="icon"><use href="#icon-explore"></use></svg>
</button>
<button class="nav-item nav-item-add" id="btn-sidebar-add" title="Add Instance">
<svg class="icon"><use href="#icon-plus"></use></svg>
</button>
</div>
<div class="instance-list" id="sidebar-instances">
<!-- Injected via JS -->
</div>
<div class="sidebar-bottom">
<button class="nav-item" id="btn-onboarding" title="Onboarding & Guide">
<svg class="icon"><use href="#icon-help"></use></svg>
</button>
<button class="nav-item" id="btn-changelog" title="Changelog">
<svg class="icon"><use href="#icon-article"></use></svg>
</button>
<button class="nav-item" id="btn-stats" title="Statistics & Playtime">
<svg class="icon"><use href="#icon-bar-chart"></use></svg>
</button>
<button class="nav-item" id="btn-settings" title="Settings">
<svg class="icon"><use href="#icon-settings"></use></svg>
</button>
</div>
</nav>
<!-- Main Stage -->
<main id="main-stage">
<!-- PLAY VIEW -->
<section class="view active" id="view-play">
<div id="background-layer"></div>
<div class="vignette-overlay"></div>
<button class="edit-btn" id="btn-edit" title="Edit instance">
<svg class="icon"><use href="#icon-edit"></use></svg>
</button>
<div class="bottom-ui">
<div class="metadata">
<h1 id="instance-title">Loading…</h1>
<div class="tags" id="instance-tags"></div>
<p id="instance-playtime" class="playtime">Loading data…</p>
</div>
<button id="play-btn">
<svg class="icon"><use href="#icon-play"></use></svg> PLAY
</button>
</div>
</section>
<!-- LIBRARY VIEW -->
<section class="view" id="view-library">
<div class="view-header">
<div>
<h2>Library</h2>
<p>Every instance you've set up, in one place.</p>
</div>
<div class="header-actions">
<button class="btn-primary" id="btn-lib-add">
<svg class="icon icon-sm"><use href="#icon-plus"></use></svg> Add Instance
</button>
<button class="btn-ghost-border" id="btn-lib-export">
<svg class="icon icon-sm"><use href="#icon-download"></use></svg> Export Collection
</button>
</div>
</div>
<div class="library-grid" id="library-grid"><!-- Injected via JS --></div>
</section>
<!-- DISCOVER VIEW -->
<section class="view" id="view-discover">
<div class="view-header">
<div>
<h2>Discover</h2>
<p>Curated Eaglercraft clients and modpacks ready to install.</p>
</div>
</div>
<div class="discover-catalog" id="discover-catalog">
<!-- Injected via JS -->
</div>
</section>
</main>
</div>
<!-- Minimized pill -->
<button id="minimized-pill" class="hidden">
<svg class="icon icon-sm"><use href="#icon-expand-less"></use></svg>
Sulfur — click to restore
</button>
<!-- Close confirmation modal -->
<div class="modal-overlay" id="modal-close">
<div class="modal">
<h3>Close Sulfur?</h3>
<p>Any active session or unsaved edits will be saved.</p>
<div class="modal-actions">
<button class="btn-ghost" data-close-modal="modal-close">Cancel</button>
<button class="btn-danger" id="confirm-close">Close</button>
</div>
</div>
</div>
<!-- ADD INSTANCE MODAL -->
<div class="modal-overlay" id="modal-add-instance">
<div class="modal modal-wide">
<h3>Add New Instance</h3>
<p class="modal-subtitle">Upload a custom HTML client, link a hosted URL, or select from curated collections.</p>
<!-- Tabs -->
<div class="modal-tabs">
<button class="modal-tab active" data-tab="tab-add-local">Local HTML File</button>
<button class="modal-tab" data-tab="tab-add-hosted">Hosted URL</button>
<button class="modal-tab" data-tab="tab-add-collection">Curated Collection</button>
<button class="modal-tab" data-tab="tab-add-import">Import JSON</button>
</div>
<!-- Tab 1: Local HTML File -->
<div class="tab-content active" id="tab-add-local">
<div class="file-drop-zone" id="file-drop-zone">
<svg class="icon icon-xl"><use href="#icon-upload"></use></svg>
<div class="drop-title" id="drop-filename">Click or Drag & Drop local .html client file here</div>
<div class="drop-sub">File will be stored securely in local browser storage</div>
<input type="file" id="local-file-input" accept=".html,.htm" class="file-input-hidden">
</div>
<label class="field-label" for="add-local-title">Instance Name</label>
<input type="text" id="add-local-title" class="field-input" placeholder="e.g. My Custom Eagler Client" maxlength="40">
<div class="form-row">
<div class="form-col">
<label class="field-label" for="add-local-version">Version Tag</label>
<input type="text" id="add-local-version" class="field-input" placeholder="1.8.8" value="1.8.8" maxlength="15">
</div>
<div class="form-col">
<label class="field-label" for="add-local-tags">Tags <span class="field-hint">(comma separated)</span></label>
<input type="text" id="add-local-tags" class="field-input" placeholder="CUSTOM, LOCAL, EAGLER" value="LOCAL, CUSTOM" maxlength="40">
</div>
</div>
<div class="modal-actions">
<button class="btn-ghost" data-close-modal="modal-add-instance">Cancel</button>
<button class="btn-primary" id="confirm-add-local">Add Local Instance</button>
</div>
</div>
<!-- Tab 2: Hosted URL -->
<div class="tab-content" id="tab-add-hosted">
<label class="field-label" for="add-hosted-url">Client Launch URL</label>
<input type="url" id="add-hosted-url" class="field-input" placeholder="https://example.com/eaglercraft.html">
<label class="field-label" for="add-hosted-title">Instance Name</label>
<input type="text" id="add-hosted-title" class="field-input" placeholder="e.g. Web Client Hosted" maxlength="40">
<div class="form-row">
<div class="form-col">
<label class="field-label" for="add-hosted-version">Version</label>
<input type="text" id="add-hosted-version" class="field-input" placeholder="1.8.8" value="1.8.8" maxlength="15">
</div>
<div class="form-col">
<label class="field-label" for="add-hosted-tags">Tags</label>
<input type="text" id="add-hosted-tags" class="field-input" placeholder="HOSTED, ONLINE" value="HOSTED, ONLINE" maxlength="40">
</div>
</div>
<div class="modal-actions">
<button class="btn-ghost" data-close-modal="modal-add-instance">Cancel</button>
<button class="btn-primary" id="confirm-add-hosted">Add Hosted Instance</button>
</div>
</div>
<!-- Tab 3: Curated Collection -->
<div class="tab-content" id="tab-add-collection">
<div class="collection-list" id="preset-collection-list">
<!-- Injected via JS -->
</div>
<div class="modal-actions">
<button class="btn-ghost" data-close-modal="modal-add-instance">Close</button>
</div>
</div>
<!-- Tab 4: Import JSON -->
<div class="tab-content" id="tab-add-import">
<div class="file-drop-zone" id="json-drop-zone">
<svg class="icon icon-xl"><use href="#icon-file"></use></svg>
<div class="drop-title" id="json-filename">Select or drop a sulfur-collection.json file</div>
<div class="drop-sub">Bulk import multiple instances and settings</div>
<input type="file" id="json-file-input" accept=".json" class="file-input-hidden">
</div>
<div id="json-import-preview" class="import-preview hidden">
<div class="preview-title" id="preview-collection-name">Collection Preview</div>
<div class="preview-count" id="preview-collection-count">0 instances ready to import</div>
</div>
<div class="modal-actions">
<button class="btn-ghost" data-close-modal="modal-add-instance">Cancel</button>
<button class="btn-primary" id="confirm-import-json" disabled>Import Collection</button>
</div>
</div>
</div>
</div>
<!-- Edit instance modal -->
<div class="modal-overlay" id="modal-edit">
<div class="modal">
<h3>Edit Instance</h3>
<label class="field-label" for="edit-title">Instance Name</label>
<input type="text" id="edit-title" class="field-input" maxlength="40">
<label class="field-label" for="edit-tags">Tags <span class="field-hint">comma separated</span></label>
<input type="text" id="edit-tags" class="field-input" maxlength="60">
<label class="field-label" for="edit-launch-file">Launch Target <span class="field-hint">(Path or URL)</span></label>
<input type="text" id="edit-launch-file" class="field-input">
<div class="modal-actions modal-actions-between">
<button class="btn-danger-ghost" id="btn-delete-instance" title="Delete instance">
<svg class="icon icon-sm"><use href="#icon-trash"></use></svg> Delete
</button>
<div style="display:flex;gap:8px;">
<button class="btn-ghost" data-close-modal="modal-edit">Cancel</button>
<button class="btn-primary" id="confirm-edit">Save Changes</button>
</div>
</div>
</div>
</div>
<!-- Confirm Delete Modal -->
<div class="modal-overlay" id="modal-confirm-delete">
<div class="modal">
<h3>Delete Instance?</h3>
<p>Are you sure you want to remove <strong id="delete-instance-name">this instance</strong>? This cannot be undone.</p>
<div class="modal-actions">
<button class="btn-ghost" data-close-modal="modal-confirm-delete">Cancel</button>
<button class="btn-danger" id="confirm-delete-action">Delete Instance</button>
</div>
</div>
</div>
<!-- ONBOARDING WIZARD MODAL -->
<div class="modal-overlay" id="modal-onboarding">
<div class="modal modal-wide onboarding-modal">
<div class="onboarding-stepper">
<div class="step-indicator active" data-step="1">1</div>
<div class="step-line"></div>
<div class="step-indicator" data-step="2">2</div>
<div class="step-line"></div>
<div class="step-indicator" data-step="3">3</div>
<div class="step-line"></div>
<div class="step-indicator" data-step="4">4</div>
</div>
<!-- Slide 1: Welcome -->
<div class="onboarding-slide active" id="onboarding-slide-1">
<div class="onboarding-hero">
<div class="onboarding-mark">S</div>
<h2>Welcome to Sulfur Launcher</h2>
<p>The high-performance web client launcher for Eaglercraft. Manage local HTML clients, connect to hosted instances, and track your playtime all in one sleek place.</p>
</div>
<div class="onboarding-cards">
<div class="onboard-card">
<svg class="icon"><use href="#icon-file"></use></svg>
<h4>Local Client Linking</h4>
<p>Link your own custom Eaglercraft HTML files directly without external hosting.</p>
</div>
<div class="onboard-card">
<svg class="icon"><use href="#icon-bar-chart"></use></svg>
<h4>Live Playtime Tracking</h4>
<p>Automatic session timer records your hours played across every client.</p>
</div>
</div>
</div>
<!-- Slide 2: PWA Install -->
<div class="onboarding-slide" id="onboarding-slide-2">
<div class="onboarding-hero">
<svg class="icon icon-xl" style="color:var(--accent);"><use href="#icon-app"></use></svg>
<h2>Install as Desktop App (PWA)</h2>
<p>Run Sulfur as a standalone desktop application with zero browser navigation UI and instant offline access.</p>
</div>
<div class="pwa-guide-box">
<div class="pwa-step">
<span class="pwa-badge">Chrome / Edge</span>
<span>Click the <strong>Install App</strong> button below or the install icon in your browser URL bar.</span>
</div>
<div class="pwa-step">
<span class="pwa-badge">Safari / iOS</span>
<span>Tap <strong>Share</strong> &rarr; <strong>Add to Home Screen</strong>.</span>
</div>
</div>
<button class="btn-primary btn-full-width" id="pwa-install-trigger">
<svg class="icon icon-sm"><use href="#icon-sparkles"></use></svg> Install Sulfur Desktop App Now
</button>
</div>
<!-- Slide 3: Instances & Collections -->
<div class="onboarding-slide" id="onboarding-slide-3">
<div class="onboarding-hero">
<svg class="icon icon-xl" style="color:var(--accent);"><use href="#icon-category"></use></svg>
<h2>Instances & Local Collections</h2>
<p>Easily organize and import your client library.</p>
</div>
<div class="onboarding-cards">
<div class="onboard-card">
<svg class="icon"><use href="#icon-plus"></use></svg>
<h4>Add & Edit Instances</h4>
<p>Customize titles, version tags, background artwork, and client launch URLs anytime.</p>
</div>
<div class="onboard-card">
<svg class="icon"><use href="#icon-download"></use></svg>
<h4>Import/Export Collections</h4>
<p>Share your full setup via <code>sulfur-collection.json</code> format.</p>
</div>
</div>
</div>
<!-- Slide 4: Ready -->
<div class="onboarding-slide" id="onboarding-slide-4">
<div class="onboarding-hero">
<div class="onboarding-mark" style="background:var(--success);color:#0a0a0a;"></div>
<h2>You're All Set!</h2>
<p>Your launcher is ready to play. Launch Sulfur Client or add your custom builds.</p>
</div>
</div>
<!-- Controls -->
<div class="modal-actions modal-actions-between" style="margin-top:28px;">
<button class="btn-ghost" id="onboarding-prev" style="visibility:hidden;">Back</button>
<div style="display:flex;gap:10px;">
<button class="btn-ghost" id="onboarding-skip">Skip</button>
<button class="btn-primary" id="onboarding-next">Next &rarr;</button>
</div>
</div>
</div>
</div>
<!-- STATS MODAL -->
<div class="modal-overlay" id="modal-stats">
<div class="modal modal-wide">
<h3>Launcher Statistics & Playtime</h3>
<p class="modal-subtitle">Detailed breakdown of your overall gaming activity in Sulfur Launcher.</p>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="stat-total-playtime">0h 0m</div>
<div class="stat-label">Total Time Played</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-total-launches">0</div>
<div class="stat-label">Total Game Launches</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-most-played"></div>
<div class="stat-label">Most Played Client</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-instance-count">0</div>
<div class="stat-label">Installed Instances</div>
</div>
</div>
<div class="section-title-sm">Instance Activity Breakdown</div>
<div class="instance-activity-list" id="stats-instance-list">
<!-- Injected via JS -->
</div>
<div class="modal-actions">
<button class="btn-primary" data-close-modal="modal-stats">Done</button>
</div>
</div>
</div>
<!-- Settings modal -->
<div class="modal-overlay" id="modal-settings">
<div class="modal">
<h3>Launcher Settings</h3>
<div class="setting-row">
<div>
<div class="setting-title">Reduce Motion</div>
<div class="setting-desc">Skip startup radial wipe and transitions</div>
</div>
<label class="switch">
<input type="checkbox" id="setting-reduce-motion">
<span class="switch-track"></span>
</label>
</div>
<div class="setting-row">
<div>
<div class="setting-title">Compact Sidebar</div>
<div class="setting-desc">Narrower dock for maximum stage area</div>
</div>
<label class="switch">
<input type="checkbox" id="setting-compact-sidebar">
<span class="switch-track"></span>
</label>
</div>
<div class="setting-row">
<div>
<div class="setting-title">Export Collection</div>
<div class="setting-desc">Backup your instances to JSON</div>
</div>
<button class="btn-ghost-border" id="setting-export-btn">Export JSON</button>
</div>
<div class="setting-row">
<div>
<div class="setting-title">Replay Onboarding</div>
<div class="setting-desc">View the welcome guide and PWA install steps</div>
</div>
<button class="btn-ghost-border" id="setting-onboarding-btn">Open Guide</button>
</div>
<div class="modal-actions">
<button class="btn-primary" data-close-modal="modal-settings">Done</button>
</div>
</div>
</div>
<div id="toast-container"></div>
<script src="js/launcher.js"></script>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').catch(() => {});
}
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vanilla 1.8.8 — Sulfur</title>
<style>
body {
margin: 0; height: 100vh;
background: #0a0a0a; color: #fff;
font-family: 'Inter', sans-serif;
display: flex; align-items: center; justify-content: center;
text-align: center;
}
.box { max-width: 480px; padding: 40px; }
h1 { color: #e0df94; font-size: 1.4rem; margin-bottom: 12px; }
p { color: #a0a0a0; font-size: 0.9rem; line-height: 1.6; }
code { background: rgba(255,255,255,0.08); padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<div class="box">
<h1>Vanilla 1.8.8 client not installed yet</h1>
<p>Replace this file with your actual Eaglercraft 1.8.8 client build, keeping the filename
<code>eagler_1_8_8.html</code> inside <code>/instances/</code>, and the PLAY button will launch it directly.</p>
</div>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+966
View File
@@ -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 &rarr;';
}
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();
});
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Sulfur Launcher",
"short_name": "Sulfur",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#0a0a0a",
"theme_color": "#e0df94",
"description": "A premium web-based launcher for Eaglercraft",
"icons": [
{
"src": "assets/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "assets/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+37
View File
@@ -0,0 +1,37 @@
const CACHE_NAME = 'sulfur-cache-v3';
const urlsToCache = [
'./index.html',
'./css/style.css',
'./js/launcher.js',
'./manifest.json',
'./instances/sulfur.html',
'./assets/icon-192.png',
'./assets/icon-512.png',
'./assets/favicon.png',
'./assets/bg-atm10.jpg',
'./assets/bg-vanilla8.jpg',
'./assets/thumb-atm10.png',
'./assets/thumb-vanilla8.png'
];
self.addEventListener('install', event => {
self.skipWaiting();
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(urlsToCache))
);
});
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(response => response || fetch(event.request))
);
});