main
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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; }
|
||||||
@@ -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> → <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 →</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>
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||