| <!DOCTYPE html> |
| <html lang="en"> |
|
|
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>⬑ LocalSpace Deployer</title> |
| <style> |
| * { |
| box-sizing: border-box; |
| } |
| |
| :root { |
| --bg: #0a0a0f; |
| --surface: #14141a; |
| --surface-hover: #1c1c24; |
| --border: #2a2a35; |
| --accent: #3b82f6; |
| --accent-hover: #2563eb; |
| --text: #e2e2e8; |
| --text-muted: #888898; |
| --success: #22c55e; |
| --warn: #f59e0b; |
| --radius: 12px; |
| --radius-sm: 8px; |
| } |
| |
| body { |
| margin: 0; |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
| background: var(--bg); |
| color: var(--text); |
| min-height: 100vh; |
| } |
| |
| .container { |
| max-width: 1100px; |
| margin: 0 auto; |
| padding: 40px 24px; |
| } |
| |
| header { |
| text-align: center; |
| margin-bottom: 48px; |
| position: relative; |
| min-height: 200px; |
| } |
| |
| header h1 { |
| margin: 0 0 8px; |
| font-size: 2.2rem; |
| font-weight: 700; |
| letter-spacing: -0.02em; |
| } |
| |
| header p { |
| margin: 0; |
| color: var(--text-muted); |
| font-size: 1.05rem; |
| } |
| |
| |
| .landing-carousel { |
| position: relative; |
| overflow: hidden; |
| min-height: 220px; |
| } |
| |
| .landing-slide { |
| position: absolute; |
| inset: 0; |
| opacity: 0; |
| transform: translateY(20px); |
| transition: opacity 1.2s ease, transform 1.2s ease; |
| pointer-events: none; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| padding: 20px 0; |
| } |
| |
| .landing-slide.active { |
| opacity: 1; |
| transform: translateY(0); |
| pointer-events: auto; |
| } |
| |
| .landing-slide .icon { |
| font-size: 4rem; |
| margin-bottom: 12px; |
| line-height: 1; |
| } |
| |
| .landing-slide .slide-title { |
| font-size: 2.4rem; |
| font-weight: 700; |
| margin: 0 0 10px; |
| letter-spacing: -0.02em; |
| } |
| |
| .landing-slide .slide-subtitle { |
| color: var(--text-muted); |
| font-size: 1.1rem; |
| max-width: 520px; |
| line-height: 1.5; |
| } |
| |
| .landing-slide .slide-cta { |
| margin-top: 16px; |
| padding: 10px 24px; |
| background: linear-gradient(135deg, var(--accent), #8b5cf6); |
| color: #fff; |
| border: none; |
| border-radius: var(--radius-sm); |
| font-size: 0.95rem; |
| cursor: pointer; |
| transition: transform 0.2s, box-shadow 0.2s; |
| } |
| |
| .landing-slide .slide-cta:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3); |
| } |
| |
| .landing-dots { |
| display: flex; |
| gap: 8px; |
| justify-content: center; |
| margin-top: 16px; |
| } |
| |
| .landing-dot { |
| width: 8px; |
| height: 8px; |
| border-radius: 50%; |
| background: var(--border); |
| cursor: pointer; |
| transition: background 0.3s, transform 0.3s; |
| } |
| |
| .landing-dot.active { |
| background: var(--accent); |
| transform: scale(1.3); |
| } |
| |
| .landing-timer { |
| position: absolute; |
| bottom: 0; |
| left: 50%; |
| transform: translateX(-50%); |
| width: 120px; |
| height: 3px; |
| background: var(--border); |
| border-radius: 2px; |
| overflow: hidden; |
| } |
| |
| .landing-timer .fill { |
| height: 100%; |
| background: var(--accent); |
| width: 0%; |
| border-radius: 2px; |
| } |
| |
| |
| .dropzone { |
| border: 2px dashed var(--border); |
| border-radius: var(--radius); |
| padding: 64px 32px; |
| text-align: center; |
| background: var(--surface); |
| transition: all 0.2s ease; |
| cursor: pointer; |
| position: relative; |
| } |
| |
| .dropzone:hover, |
| .dropzone.dragover { |
| border-color: var(--accent); |
| background: var(--surface-hover); |
| } |
| |
| .dropzone .icon { |
| font-size: 3rem; |
| margin-bottom: 16px; |
| opacity: 0.7; |
| } |
| |
| .dropzone h3 { |
| margin: 0 0 8px; |
| font-size: 1.2rem; |
| } |
| |
| .dropzone p { |
| margin: 0; |
| color: var(--text-muted); |
| font-size: 0.9rem; |
| } |
| |
| .dropzone input[type="file"] { |
| position: absolute; |
| inset: 0; |
| width: 100%; |
| height: 100%; |
| opacity: 0; |
| cursor: pointer; |
| } |
| |
| |
| .progress-wrap { |
| margin-top: 24px; |
| display: none; |
| } |
| |
| .progress-wrap.active { |
| display: block; |
| } |
| |
| .progress-bar { |
| height: 6px; |
| background: var(--border); |
| border-radius: 3px; |
| overflow: hidden; |
| } |
| |
| .progress-bar .fill { |
| height: 100%; |
| background: var(--accent); |
| width: 0%; |
| transition: width 0.3s ease; |
| border-radius: 3px; |
| } |
| |
| .progress-text { |
| text-align: center; |
| margin-top: 8px; |
| font-size: 0.85rem; |
| color: var(--text-muted); |
| } |
| |
| |
| .result { |
| margin-top: 24px; |
| padding: 20px; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| display: none; |
| } |
| |
| .result.active { |
| display: block; |
| } |
| |
| .result h4 { |
| margin: 0 0 12px; |
| color: var(--accent); |
| } |
| |
| .result .field { |
| display: flex; |
| justify-content: space-between; |
| padding: 8px 0; |
| border-bottom: 1px solid var(--border); |
| font-size: 0.92rem; |
| } |
| |
| .result .field:last-child { |
| border-bottom: none; |
| } |
| |
| .result .field .label { |
| color: var(--text-muted); |
| } |
| |
| .result .field .value { |
| font-family: ui-monospace, monospace; |
| font-size: 0.85rem; |
| } |
| |
| .result .actions { |
| margin-top: 16px; |
| display: flex; |
| gap: 12px; |
| } |
| |
| .btn { |
| padding: 10px 20px; |
| border-radius: var(--radius-sm); |
| border: none; |
| font-size: 0.92rem; |
| cursor: pointer; |
| text-decoration: none; |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| transition: background 0.15s; |
| } |
| |
| .btn-primary { |
| background: var(--accent); |
| color: #fff; |
| } |
| |
| .btn-primary:hover { |
| background: var(--accent-hover); |
| } |
| |
| .btn-secondary { |
| background: var(--surface-hover); |
| color: var(--text); |
| border: 1px solid var(--border); |
| } |
| |
| .btn-secondary:hover { |
| background: var(--border); |
| } |
| |
| |
| .app-bundle { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 20px; |
| margin-top: 20px; |
| } |
| |
| .app-bundle .bundle-header { |
| display: flex; |
| align-items: center; |
| gap: 16px; |
| margin-bottom: 16px; |
| } |
| |
| .app-bundle .bundle-icon { |
| width: 56px; |
| height: 56px; |
| border-radius: var(--radius-sm); |
| background: var(--surface-hover); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 1.6rem; |
| flex-shrink: 0; |
| } |
| |
| .app-bundle .bundle-title { |
| flex: 1; |
| min-width: 0; |
| } |
| |
| .app-bundle .bundle-title .name { |
| font-weight: 600; |
| font-size: 1.1rem; |
| margin-bottom: 4px; |
| } |
| |
| .app-bundle .bundle-title .meta { |
| font-size: 0.82rem; |
| color: var(--text-muted); |
| } |
| |
| .app-bundle .badge { |
| display: inline-block; |
| padding: 4px 10px; |
| border-radius: 999px; |
| font-size: 0.75rem; |
| font-weight: 500; |
| margin-left: 8px; |
| } |
| |
| .badge-success { |
| background: rgba(34, 197, 94, 0.15); |
| color: var(--success); |
| } |
| |
| .badge-warn { |
| background: rgba(245, 158, 11, 0.15); |
| color: var(--warn); |
| } |
| |
| |
| .file-tree { |
| margin-top: 16px; |
| padding: 12px; |
| background: var(--bg); |
| border-radius: var(--radius-sm); |
| max-height: 300px; |
| overflow-y: auto; |
| } |
| |
| .file-tree .item { |
| padding: 4px 0; |
| font-size: 0.85rem; |
| font-family: ui-monospace, monospace; |
| color: var(--text-muted); |
| } |
| |
| .file-tree .item::before { |
| content: "π "; |
| } |
| |
| .file-tree .item.dir::before { |
| content: "π "; |
| } |
| |
| |
| .section { |
| margin-top: 48px; |
| } |
| |
| .section h2 { |
| margin: 0 0 20px; |
| font-size: 1.3rem; |
| } |
| |
| .app-grid { |
| display: grid; |
| gap: 12px; |
| } |
| |
| .app-card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius); |
| padding: 16px 20px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| gap: 16px; |
| transition: border-color 0.15s; |
| } |
| |
| .app-card:hover { |
| border-color: var(--accent); |
| } |
| |
| .app-card .info { |
| flex: 1; |
| min-width: 0; |
| } |
| |
| .app-card .info .name { |
| font-weight: 600; |
| margin-bottom: 4px; |
| white-space: nowrap; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| } |
| |
| .app-card .info .meta { |
| font-size: 0.82rem; |
| color: var(--text-muted); |
| } |
| |
| .app-card .actions { |
| display: flex; |
| gap: 8px; |
| flex-shrink: 0; |
| } |
| |
| .app-card .actions a { |
| padding: 6px 14px; |
| border-radius: var(--radius-sm); |
| background: var(--surface-hover); |
| color: var(--text); |
| text-decoration: none; |
| font-size: 0.82rem; |
| border: 1px solid var(--border); |
| } |
| |
| .app-card .actions a:hover { |
| background: var(--border); |
| } |
| |
| .empty { |
| text-align: center; |
| padding: 40px; |
| color: var(--text-muted); |
| } |
| |
| |
| .toast { |
| position: fixed; |
| bottom: 24px; |
| left: 50%; |
| transform: translateX(-50%) translateY(100px); |
| background: var(--surface); |
| border: 1px solid var(--border); |
| padding: 12px 24px; |
| border-radius: var(--radius-sm); |
| font-size: 0.9rem; |
| opacity: 0; |
| transition: all 0.3s ease; |
| z-index: 1000; |
| } |
| |
| .toast.show { |
| opacity: 1; |
| transform: translateX(-50%) translateY(0); |
| } |
| |
| .toast.error { |
| border-color: #ef4444; |
| color: #fca5a5; |
| } |
| |
| .toast.success { |
| border-color: #22c55e; |
| color: #86efac; |
| } |
| |
| |
| .ctx-menu { |
| position: fixed; |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| padding: 6px 0; |
| min-width: 200px; |
| z-index: 2000; |
| box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); |
| display: none; |
| } |
| |
| .ctx-menu-item { |
| padding: 8px 16px; |
| font-size: 0.88rem; |
| color: var(--text); |
| cursor: pointer; |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| transition: background 0.1s; |
| } |
| |
| .ctx-menu-item:hover { |
| background: var(--surface-hover); |
| } |
| |
| .ctx-menu-sep { |
| height: 1px; |
| background: var(--border); |
| margin: 4px 0; |
| } |
| |
| |
| .links-table { |
| width: 100%; |
| border-collapse: collapse; |
| font-size: 0.9rem; |
| } |
| |
| .links-table th, |
| .links-table td { |
| padding: 10px 12px; |
| text-align: left; |
| border-bottom: 1px solid var(--border); |
| } |
| |
| .links-table th { |
| color: var(--text-muted); |
| font-weight: 500; |
| font-size: 0.82rem; |
| text-transform: uppercase; |
| letter-spacing: 0.03em; |
| } |
| |
| .links-table td { |
| color: var(--text); |
| } |
| |
| .links-table .link-cell { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| .links-table input { |
| flex: 1; |
| background: var(--bg); |
| border: 1px solid var(--border); |
| color: var(--text); |
| padding: 6px 10px; |
| border-radius: var(--radius-sm); |
| font-size: 0.82rem; |
| font-family: ui-monospace, monospace; |
| } |
| |
| .links-table .copy-btn { |
| padding: 5px 10px; |
| border-radius: var(--radius-sm); |
| background: var(--surface-hover); |
| border: 1px solid var(--border); |
| color: var(--text); |
| font-size: 0.8rem; |
| cursor: pointer; |
| } |
| |
| .links-table .copy-btn:hover { |
| background: var(--border); |
| } |
| |
| |
| .view-toggle { |
| display: flex; |
| gap: 8px; |
| justify-content: center; |
| margin-bottom: 24px; |
| } |
| |
| .view-toggle button { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| color: var(--text); |
| padding: 8px 18px; |
| border-radius: var(--radius-sm); |
| cursor: pointer; |
| font-size: 0.9rem; |
| transition: all 0.15s; |
| } |
| |
| .view-toggle button.active { |
| background: var(--accent); |
| border-color: var(--accent); |
| color: #fff; |
| } |
| |
| .microspaces-grid { |
| display: grid; |
| grid-template-columns: repeat(3, 1fr); |
| grid-template-rows: repeat(2, 320px); |
| gap: 12px; |
| margin-bottom: 40px; |
| } |
| |
| @media (max-width: 900px) { |
| .microspaces-grid { |
| grid-template-columns: repeat(2, 1fr); |
| grid-template-rows: repeat(3, 280px); |
| } |
| } |
| |
| @media (max-width: 600px) { |
| .microspaces-grid { |
| grid-template-columns: 1fr; |
| grid-template-rows: repeat(6, 260px); |
| } |
| } |
| |
| .microspace { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: var(--radius-sm); |
| overflow: hidden; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .microspace-header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 6px 10px; |
| background: var(--surface-hover); |
| border-bottom: 1px solid var(--border); |
| font-size: 0.8rem; |
| color: var(--text-muted); |
| } |
| |
| .microspace-header select { |
| background: var(--bg); |
| border: 1px solid var(--border); |
| color: var(--text); |
| font-size: 0.78rem; |
| padding: 3px 6px; |
| border-radius: 4px; |
| max-width: 160px; |
| } |
| |
| .microspace-body { |
| flex: 1; |
| position: relative; |
| overflow: hidden; |
| } |
| |
| .microspace-body iframe { |
| width: 100%; |
| height: 100%; |
| border: none; |
| display: block; |
| } |
| |
| .microspace-empty { |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| height: 100%; |
| color: var(--text-muted); |
| font-size: 0.85rem; |
| } |
| |
| |
| .terminal { |
| background: #0c0c12; |
| color: #e2e2e8; |
| font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; |
| font-size: 0.82rem; |
| line-height: 1.5; |
| height: 100%; |
| overflow-y: auto; |
| padding: 10px; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .terminal-output { |
| flex: 1; |
| overflow-y: auto; |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| |
| .terminal-output .cmd { |
| color: #22c55e; |
| } |
| |
| .terminal-output .err { |
| color: #f87171; |
| } |
| |
| .terminal-output .info { |
| color: #60a5fa; |
| } |
| |
| .terminal-output .warn { |
| color: #fbbf24; |
| } |
| |
| .terminal-input-line { |
| display: flex; |
| align-items: center; |
| gap: 6px; |
| padding-top: 4px; |
| } |
| |
| .terminal-prompt { |
| color: #22c55e; |
| white-space: nowrap; |
| } |
| |
| .terminal-input { |
| flex: 1; |
| background: transparent; |
| border: none; |
| color: #e2e2e8; |
| font-family: inherit; |
| font-size: inherit; |
| outline: none; |
| padding: 0; |
| margin: 0; |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <div class="container"> |
| <header> |
| <div class="landing-carousel" id="landingCarousel"> |
| |
| <div class="landing-slide active" data-slide="0"> |
| <div class="icon">π¦</div> |
| <div class="slide-title">Deploy Any DMG</div> |
| <div class="slide-subtitle">Drag a macOS app bundle onto the Space. We extract it, inspect it, and serve it |
| live β no setup required.</div> |
| <button class="slide-cta" onclick="switchView('deploy')">Start Deploying β</button> |
| </div> |
| |
| <div class="landing-slide" data-slide="1"> |
| <div class="icon">πͺ</div> |
| <div class="slide-title">Six Microspaces</div> |
| <div class="slide-subtitle">Run 5 apps side-by-side in a grid, plus a built-in terminal. Mix web apps and |
| native previews in one dashboard.</div> |
| <button class="slide-cta" onclick="switchView('micro')">Open Microspaces β</button> |
| </div> |
| |
| <div class="landing-slide" data-slide="2"> |
| <div class="icon">π</div> |
| <div class="slide-title">Share Instantly</div> |
| <div class="slide-subtitle">Every upload gets permanent public URLs. Right-click any app to copy its link. |
| Shareable app viewers for anyone.</div> |
| <button class="slide-cta" onclick="switchView('links')">View Links β</button> |
| </div> |
| </div> |
| <div class="landing-dots" id="landingDots"> |
| <div class="landing-dot active" onclick="goToSlide(0)"></div> |
| <div class="landing-dot" onclick="goToSlide(1)"></div> |
| <div class="landing-dot" onclick="goToSlide(2)"></div> |
| </div> |
| <div class="landing-timer"> |
| <div class="fill" id="landingTimerFill"></div> |
| </div> |
| </header> |
|
|
| <div class="view-toggle"> |
| <button id="viewDeployBtn" class="active" onclick="switchView('deploy')">π¦ Deploy</button> |
| <button id="viewMicroBtn" onclick="switchView('micro')">πͺ Microspaces</button> |
| <button id="viewLinksBtn" onclick="switchView('links')">π Links</button> |
| </div> |
|
|
| |
| <div id="microspacesView" style="display:none;"> |
| <div class="microspaces-grid" id="microGrid"> |
| |
| </div> |
| </div> |
|
|
| |
| <div id="linksView" style="display:none;"> |
| <div class="section"> |
| <h2>π App Links</h2> |
| <p style="color:var(--text-muted);margin:-12px 0 20px;">Every app gets permanent shareable URLs. Right-click any |
| card to copy a link.</p> |
| <div id="linksTableWrap"> |
| <div class="empty">Loading...</div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="deployView"> |
| <div class="dropzone" id="dropzone"> |
| <input type="file" id="fileInput" accept=".dmg"> |
| <div class="icon">π¦</div> |
| <h3>Drop a DMG here</h3> |
| <p>or click to browse. The DMG will be opened and its contents shown below.</p> |
| </div> |
|
|
| <div class="progress-wrap" id="progressWrap"> |
| <div class="progress-bar"> |
| <div class="fill" id="progressFill"></div> |
| </div> |
| <div class="progress-text" id="progressText">Uploading and opening...</div> |
| </div> |
|
|
| <div class="result" id="result"> |
| <h4 id="resTitle">Registered</h4> |
| <div class="field"><span class="label">Slug</span><span class="value" id="resSlug">-</span></div> |
| <div class="field"><span class="label">Filename</span><span class="value" id="resFilename">-</span></div> |
| <div class="field"><span class="label">Size</span><span class="value" id="resSize">-</span></div> |
| <div class="field"><span class="label">SHA-256</span><span class="value" id="resHash">-</span></div> |
| <div class="actions"> |
| <a href="#" class="btn btn-primary" id="launchBtn" target="_blank" |
| style="display:none;background:linear-gradient(135deg,#3b82f6,#8b5cf6);">π Launch App</a> |
| <a href="#" class="btn btn-secondary" id="downloadBtn" target="_blank" style="display:none;">β¬ Download</a> |
| <button class="btn btn-secondary" id="copyBtn">π Copy Link</button> |
| </div> |
| <div id="bundles"></div> |
|
|
| <div id="previewWrap" style="margin-top:24px;display:none;"> |
| <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;"> |
| <h4 style="margin:0;color:var(--accent)">π Live Preview</h4> |
| <div style="display:flex;gap:8px;"> |
| <select id="previewFileSelect" |
| style="padding:6px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.85rem;max-width:220px;"></select> |
| <button class="btn btn-secondary" id="previewNewTabBtn" style="padding:6px 14px;font-size:0.82rem;">β New |
| Tab</button> |
| <button class="btn btn-secondary" id="previewFullscreenBtn" style="padding:6px 14px;font-size:0.82rem;">βΆ |
| Fullscreen</button> |
| </div> |
| </div> |
| <div id="previewContainer" |
| style="position:relative;border:1px solid var(--border);border-radius:var(--radius-sm);overflow:hidden;background:#fff;"> |
| <iframe id="previewFrame" style="width:100%;height:500px;border:none;display:block;"></iframe> |
| <div id="previewLoading" |
| style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(10,10,15,0.9);color:var(--text-muted);"> |
| <span style="animation:pulse 1.4s infinite;">Loading preview...</span> |
| </div> |
| </div> |
| <style> |
| @keyframes pulse { |
| |
| 0%, |
| 100% { |
| opacity: 0.4 |
| } |
| |
| 50% { |
| opacity: 1 |
| } |
| } |
| </style> |
| </div> |
| </div> |
|
|
| <div class="section"> |
| <h2>π§ͺ Create App from URL</h2> |
| <p style="color:var(--text-muted);margin-bottom:16px;">Turn any website into a downloadable macOS app bundle. |
| Enter a URL, get an .app/.dmg.</p> |
| <div style="display:grid;gap:12px;max-width:600px;"> |
| <input type="text" id="webUrl" placeholder="https://example.com" |
| style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.95rem;"> |
| <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;"> |
| <input type="text" id="webAppName" placeholder="App Name" value="MyApp" |
| style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.95rem;"> |
| <input type="text" id="webBundleId" placeholder="Bundle ID" value="app.localspace.myapp" |
| style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.95rem;"> |
| </div> |
| <input type="text" id="webVersion" placeholder="Version" value="1.0.0" |
| style="padding:10px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--surface);color:var(--text);font-size:0.95rem;"> |
| <button class="btn btn-primary" id="createWebappBtn" style="justify-self:start;">π¦ Package as App</button> |
| </div> |
| <div id="webappResult" style="margin-top:16px;display:none;" class="result"></div> |
| </div> |
|
|
| <div class="section"> |
| <h2>π¦ Published Apps Gallery</h2> |
| <p style="color:var(--text-muted);margin:-12px 0 20px;">Every uploaded app with a web preview gets its own |
| public |
| URL. Click Launch to open it.</p> |
| <div class="app-grid" id="appGrid"> |
| <div class="empty">No apps yet. Upload a DMG above to open it.</div> |
| </div> |
| </div> |
| </div> |
|
|
| <div class="toast" id="toast"></div> |
|
|
| <script> |
| const $ = id => document.getElementById(id); |
| const dropzone = $('dropzone'), fileInput = $('fileInput'); |
| const progressWrap = $('progressWrap'), progressFill = $('progressFill'), progressText = $('progressText'); |
| const result = $('result'), bundlesEl = $('bundles'); |
| const toast = $('toast'); |
| |
| function showToast(msg, type = 'success') { |
| toast.textContent = msg; toast.className = 'toast ' + type + ' show'; |
| setTimeout(() => toast.classList.remove('show'), 3000); |
| } |
| |
| ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(e => { |
| dropzone.addEventListener(e, ev => { ev.preventDefault(); ev.stopPropagation(); }); |
| }); |
| ['dragenter', 'dragover'].forEach(e => dropzone.addEventListener(e, () => dropzone.classList.add('dragover'))); |
| ['dragleave', 'drop'].forEach(e => dropzone.addEventListener(e, () => dropzone.classList.remove('dragover'))); |
| |
| dropzone.addEventListener('drop', e => { if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); }); |
| fileInput.addEventListener('change', e => { if (fileInput.files.length) handleFile(fileInput.files[0]); }); |
| |
| async function handleFile(file) { |
| if (!file.name.toLowerCase().endsWith('.dmg')) { showToast('Only .dmg files.', 'error'); return; } |
| progressWrap.classList.add('active'); result.classList.remove('active'); progressFill.style.width = '0%'; |
| progressText.textContent = 'Uploading and opening DMG...'; |
| |
| const form = new FormData(); form.append('file', file); |
| try { |
| const xhr = new XMLHttpRequest(); |
| xhr.open('POST', '/api/upload'); |
| xhr.upload.onprogress = e => { |
| if (e.lengthComputable) { |
| const pct = Math.round((e.loaded / e.total) * 60); |
| progressFill.style.width = pct + '%'; |
| progressText.textContent = `Uploading... ${pct}%`; |
| } |
| }; |
| xhr.onload = () => { |
| progressWrap.classList.remove('active'); |
| if (xhr.status === 201) { |
| const data = JSON.parse(xhr.responseText); |
| showApp(data.app); |
| showToast(data.message || 'Opened!'); |
| loadApps(); |
| } else { |
| const err = JSON.parse(xhr.responseText || '{}'); |
| showToast(err.error || 'Upload failed', 'error'); |
| } |
| }; |
| xhr.onerror = () => { progressWrap.classList.remove('active'); showToast('Network error', 'error'); }; |
| xhr.send(form); |
| } catch (err) { progressWrap.classList.remove('active'); showToast(String(err), 'error'); } |
| } |
| |
| function showApp(app) { |
| $('resTitle').textContent = app.opened ? 'β
DMG Opened' : 'β οΈ Uploaded (could not open)'; |
| $('resTitle').style.color = app.opened ? 'var(--success)' : 'var(--warn)'; |
| $('resSlug').textContent = app.slug; |
| $('resFilename').textContent = app.filename; |
| $('resSize').textContent = app.size.toLocaleString() + ' bytes'; |
| $('resHash').textContent = app.sha256.substring(0, 32) + '...'; |
| $('copyBtn').onclick = () => { navigator.clipboard.writeText(window.location.origin + app.download_url); showToast('Link copied!'); }; |
| |
| |
| const launchBtn = $('launchBtn'); |
| const downloadBtn = $('downloadBtn'); |
| if (app.has_preview) { |
| launchBtn.href = `/app/${app.app_id}`; |
| launchBtn.style.display = 'inline-flex'; |
| downloadBtn.style.display = 'none'; |
| } else { |
| launchBtn.style.display = 'none'; |
| downloadBtn.href = app.download_url; |
| downloadBtn.style.display = 'inline-flex'; |
| } |
| |
| bundlesEl.innerHTML = ''; |
| if (app.apps && app.apps.length) { |
| app.apps.forEach((bundle, i) => { |
| const div = document.createElement('div'); |
| div.className = 'app-bundle'; |
| const filesHtml = (bundle.files || []).slice(0, 50).map(f => `<div class="item">${escapeHtml(f)}</div>`).join(''); |
| div.innerHTML = ` |
| <div class="bundle-header"> |
| <div class="bundle-icon">π</div> |
| <div class="bundle-title"> |
| <div class="name">${escapeHtml(bundle.display_name || bundle.bundle_name)} <span class="badge badge-success">v${escapeHtml(bundle.version || '?')}</span></div> |
| <div class="meta">${escapeHtml(bundle.bundle_id || '')} Β· ${bundle.file_count} files Β· executable: ${escapeHtml(bundle.executable || 'N/A')}</div> |
| </div> |
| </div> |
| ${filesHtml ? `<div class="file-tree">${filesHtml}</div>` : ''} |
| `; |
| bundlesEl.appendChild(div); |
| }); |
| } else if (app.opened) { |
| bundlesEl.innerHTML = '<p style="color:var(--text-muted);margin-top:12px;">No .app bundles found inside this DMG.</p>'; |
| } |
| |
| |
| const previewWrap = $('previewWrap'); |
| const previewFrame = $('previewFrame'); |
| const previewLoading = $('previewLoading'); |
| const previewFileSelect = $('previewFileSelect'); |
| previewWrap.style.display = 'none'; |
| previewFileSelect.innerHTML = ''; |
| |
| if (app.has_preview && app.html_files && app.html_files.length) { |
| previewWrap.style.display = 'block'; |
| previewLoading.style.display = 'flex'; |
| |
| |
| app.html_files.forEach((hf, idx) => { |
| const opt = document.createElement('option'); |
| opt.value = hf.path; |
| opt.textContent = hf.path; |
| if (hf.path === app.preview_entry) opt.selected = true; |
| previewFileSelect.appendChild(opt); |
| }); |
| |
| |
| const entryPath = app.preview_entry || app.html_files[0].path; |
| previewFrame.src = `/api/preview/${app.app_id}/${entryPath}`; |
| previewFrame.onload = () => { previewLoading.style.display = 'none'; }; |
| |
| |
| previewFileSelect.onchange = () => { |
| previewLoading.style.display = 'flex'; |
| previewFrame.src = `/api/preview/${app.app_id}/${previewFileSelect.value}`; |
| }; |
| |
| |
| $('previewNewTabBtn').onclick = () => { |
| window.open(`/api/preview/${app.app_id}/${previewFileSelect.value || entryPath}`, '_blank'); |
| }; |
| |
| |
| $('previewFullscreenBtn').onclick = () => { |
| const container = $('previewContainer'); |
| if (container.requestFullscreen) container.requestFullscreen(); |
| else if (container.webkitRequestFullscreen) container.webkitRequestFullscreen(); |
| }; |
| } |
| |
| result.classList.add('active'); |
| } |
| |
| async function loadApps() { |
| try { |
| const res = await fetch('/api/apps'); |
| const data = await res.json(); |
| const grid = $('appGrid'); |
| if (!data.apps || !data.apps.length) { |
| grid.innerHTML = '<div class="empty">No apps yet. Upload a DMG above to open it.</div>'; |
| return; |
| } |
| grid.innerHTML = ''; |
| data.apps.forEach(a => { |
| const date = new Date(a.created_at * 1000).toLocaleString(); |
| let status; |
| if (a.is_webapp) { |
| status = `<span class="badge" style="background:rgba(59,130,246,0.15);color:var(--accent);">Web App</span>`; |
| } else if (a.opened) { |
| status = `<span class="badge badge-success">Opened</span>`; |
| } else { |
| status = `<span class="badge badge-warn">Closed</span>`; |
| } |
| const apps = (a.apps || []).map(b => `<div style="font-size:0.82rem;color:var(--text-muted);margin-top:4px;">π ${escapeHtml(b.display_name || b.bundle_name)} ${b.version ? 'v' + b.version : ''}</div>`).join(''); |
| const sourceUrl = a.source_url ? `<div style="font-size:0.82rem;color:var(--text-muted);margin-top:4px;">π ${escapeHtml(a.source_url)}</div>` : ''; |
| const launchLink = a.has_preview |
| ? `<a href="/app/${a.app_id}" target="_blank" style="background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;border:none;">π Launch</a>` |
| : `<a href="${a.download_url}" download>β¬ Download</a>`; |
| const card = document.createElement('div'); |
| card.className = 'app-card'; |
| card.innerHTML = ` |
| <div class="info"> |
| <div class="name">${escapeHtml(a.filename)} ${status}</div> |
| <div class="meta">${escapeHtml(a.slug)} Β· ${a.size.toLocaleString()} bytes Β· ${date}</div> |
| ${apps}${sourceUrl} |
| </div> |
| <div class="actions">${launchLink}</div>`; |
| attachContextMenu(card, a.app_id); |
| grid.appendChild(card); |
| }); |
| } catch (e) { console.error(e); } |
| } |
| |
| function escapeHtml(text) { |
| const div = document.createElement('div'); |
| div.textContent = text || ''; |
| return div.innerHTML; |
| } |
| |
| |
| $('createWebappBtn').addEventListener('click', async () => { |
| const url = $('webUrl').value.trim(); |
| const appName = $('webAppName').value.trim(); |
| const bundleId = $('webBundleId').value.trim(); |
| const version = $('webVersion').value.trim(); |
| if (!url) { showToast('Enter a URL.', 'error'); return; } |
| if (!url.startsWith('http')) { showToast('URL must start with http:// or https://', 'error'); return; } |
| $('createWebappBtn').textContent = 'Packaging...'; |
| $('createWebappBtn').disabled = true; |
| try { |
| const res = await fetch('/api/create-webapp', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ url, app_name: appName, bundle_id: bundleId, version }), |
| }); |
| const data = await res.json(); |
| const resultDiv = $('webappResult'); |
| if (res.status === 201) { |
| const app = data.app; |
| resultDiv.innerHTML = ` |
| <h4 style="margin:0 0 12px;color:var(--success)">β
${escapeHtml(app.app_name)} Packaged</h4> |
| <div class="field"><span class="label">Source URL</span><span class="value">${escapeHtml(app.source_url)}</span></div> |
| <div class="field"><span class="label">Bundle ID</span><span class="value">${escapeHtml(app.bundle_id)}</span></div> |
| <div class="field"><span class="label">Version</span><span class="value">${escapeHtml(app.version)}</span></div> |
| <div class="field"><span class="label">Download</span><span class="value"><a href="${app.download_url}">${escapeHtml(app.filename)}</a></span></div> |
| <p style="margin-top:12px;color:var(--text-muted);font-size:0.85rem;">${escapeHtml(data.message)}</p> |
| `; |
| resultDiv.style.display = 'block'; |
| showToast('App packaged!'); |
| loadApps(); |
| } else { |
| showToast(data.error || 'Failed to package app.', 'error'); |
| } |
| } catch (err) { |
| showToast(String(err), 'error'); |
| } finally { |
| $('createWebappBtn').textContent = 'π¦ Package as App'; |
| $('createWebappBtn').disabled = false; |
| } |
| }); |
| |
| |
| let _appsCache = []; |
| let _kvStore = {}; |
| |
| function switchView(view) { |
| $('deployView').style.display = view === 'deploy' ? 'block' : 'none'; |
| $('microspacesView').style.display = view === 'micro' ? 'block' : 'none'; |
| $('linksView').style.display = view === 'links' ? 'block' : 'none'; |
| $('viewDeployBtn').classList.toggle('active', view === 'deploy'); |
| $('viewMicroBtn').classList.toggle('active', view === 'micro'); |
| $('viewLinksBtn').classList.toggle('active', view === 'links'); |
| if (view === 'micro') renderMicrospaces(); |
| if (view === 'links') renderLinks(); |
| } |
| |
| |
| async function renderLinks() { |
| const wrap = $('linksTableWrap'); |
| try { |
| const res = await fetch('/api/apps'); |
| const data = await res.json(); |
| const apps = data.apps || []; |
| if (!apps.length) { |
| wrap.innerHTML = '<div class="empty">No apps yet. Upload a DMG to generate links.</div>'; |
| return; |
| } |
| const origin = window.location.origin; |
| let html = `<table class="links-table"><thead><tr><th>App</th><th>App Viewer</th><th>Preview</th><th>Download</th></tr></thead><tbody>`; |
| apps.forEach(a => { |
| const appUrl = a.has_preview ? `${origin}/app/${a.app_id}` : ''; |
| const previewUrl = a.has_preview && a.preview_entry ? `${origin}/api/preview/${a.app_id}/${a.preview_entry}` : (a.has_preview ? `${origin}/api/preview/${a.app_id}` : ''); |
| const downloadUrl = `${origin}${a.download_url}`; |
| html += `<tr><td><strong>${escapeHtml(a.filename)}</strong><div style="font-size:0.78rem;color:var(--text-muted);margin-top:2px;">${a.slug}</div></td>`; |
| html += `<td>${appUrl ? `<div class="link-cell"><input value="${appUrl}" readonly onclick="this.select()"><button class="copy-btn" onclick="navigator.clipboard.writeText('${appUrl}');showToast('Copied!')">Copy</button></div>` : '<span style="color:var(--text-muted)">β</span>'}</td>`; |
| html += `<td>${previewUrl ? `<div class="link-cell"><input value="${previewUrl}" readonly onclick="this.select()"><button class="copy-btn" onclick="navigator.clipboard.writeText('${previewUrl}');showToast('Copied!')">Copy</button></div>` : '<span style="color:var(--text-muted)">β</span>'}</td>`; |
| html += `<td><div class="link-cell"><input value="${downloadUrl}" readonly onclick="this.select()"><button class="copy-btn" onclick="navigator.clipboard.writeText('${downloadUrl}');showToast('Copied!')">Copy</button></div></td></tr>`; |
| }); |
| html += '</tbody></table>'; |
| wrap.innerHTML = html; |
| } catch (e) { wrap.innerHTML = '<div class="empty">Error loading links.</div>'; console.error(e); } |
| } |
| |
| |
| let _ctxAppId = null; |
| document.addEventListener('click', () => { $('ctxMenu').style.display = 'none'; }); |
| document.addEventListener('scroll', () => { $('ctxMenu').style.display = 'none'; }, true); |
| |
| function attachContextMenu(el, appId) { |
| el.addEventListener('contextmenu', e => { |
| e.preventDefault(); |
| _ctxAppId = appId; |
| const menu = $('ctxMenu'); |
| menu.style.display = 'block'; |
| const x = Math.min(e.clientX, window.innerWidth - 220); |
| const y = Math.min(e.clientY, window.innerHeight - 200); |
| menu.style.left = x + 'px'; |
| menu.style.top = y + 'px'; |
| }); |
| } |
| function ctxOpenApp() { if (_ctxAppId) window.location.href = `/app/${_ctxAppId}`; } |
| function ctxLaunchNewTab() { if (_ctxAppId) window.open(`/app/${_ctxAppId}`, '_blank'); } |
| function ctxCopyLink(type) { |
| if (!_ctxAppId) return; |
| const app = _appsCache.find(a => a.app_id === _ctxAppId); if (!app) return; |
| const origin = window.location.origin; |
| let url = ''; |
| if (type === 'app') url = `${origin}/app/${_ctxAppId}`; |
| else if (type === 'preview') url = app.preview_entry ? `${origin}/api/preview/${_ctxAppId}/${app.preview_entry}` : `${origin}/api/preview/${_ctxAppId}`; |
| else if (type === 'download') url = `${origin}${app.download_url}`; |
| navigator.clipboard.writeText(url); showToast(`${type} link copied!`); $('ctxMenu').style.display = 'none'; |
| } |
| function ctxCopyId() { if (_ctxAppId) { navigator.clipboard.writeText(_ctxAppId); showToast('App ID copied!'); $('ctxMenu').style.display = 'none'; } } |
| |
| async function renderMicrospaces() { |
| const grid = $('microGrid'); |
| |
| if (!grid.dataset.built) { |
| grid.innerHTML = ''; |
| for (let i = 0; i < 6; i++) { |
| const isTerminal = i === 5; |
| const div = document.createElement('div'); |
| div.className = 'microspace'; |
| div.id = `micro-${i}`; |
| div.innerHTML = ` |
| <div class="microspace-header"> |
| <span>πͺ Space ${i + 1}</span> |
| ${isTerminal ? '<span>β¨οΈ Terminal</span>' : `<select id="microSel-${i}" onchange="updateMicrospace(${i})"><option value="">β Select app β</option></select>`} |
| </div> |
| <div class="microspace-body" id="microBody-${i}"> |
| ${isTerminal ? buildTerminalHTML() : '<div class="microspace-empty">Select an app above</div>'} |
| </div> |
| `; |
| grid.appendChild(div); |
| if (isTerminal) initTerminal(); |
| } |
| grid.dataset.built = '1'; |
| } |
| |
| try { |
| const res = await fetch('/api/apps'); |
| const data = await res.json(); |
| _appsCache = data.apps || []; |
| const previewApps = _appsCache.filter(a => a.has_preview); |
| for (let i = 0; i < 5; i++) { |
| const sel = $(`microSel-${i}`); |
| if (!sel) continue; |
| const prevVal = sel.value; |
| sel.innerHTML = '<option value="">β Select app β</option>'; |
| previewApps.forEach(a => { |
| const opt = document.createElement('option'); |
| opt.value = a.app_id; |
| opt.textContent = a.filename; |
| sel.appendChild(opt); |
| }); |
| if (prevVal) sel.value = prevVal; |
| } |
| } catch (e) { console.error(e); } |
| } |
| |
| function updateMicrospace(idx) { |
| const sel = $(`microSel-${idx}`); |
| const body = $(`microBody-${idx}`); |
| const appId = sel.value; |
| if (!appId) { |
| body.innerHTML = '<div class="microspace-empty">Select an app above</div>'; |
| return; |
| } |
| const app = _appsCache.find(a => a.app_id === appId); |
| const entryPath = app && app.preview_entry ? app.preview_entry : ''; |
| const src = entryPath ? `/api/preview/${appId}/${entryPath}` : `/api/preview/${appId}`; |
| body.innerHTML = `<iframe src="${src}" loading="lazy"></iframe>`; |
| } |
| |
| function buildTerminalHTML() { |
| return ` |
| <div class="terminal" id="termContainer" onclick="document.getElementById('termInput').focus()"> |
| <div class="terminal-output" id="termOutput"></div> |
| <div class="terminal-input-line"> |
| <span class="terminal-prompt">localspace$</span> |
| <input type="text" class="terminal-input" id="termInput" autocomplete="off" spellcheck="false" onkeydown="handleTerminalKey(event)"> |
| </div> |
| </div> |
| `; |
| } |
| |
| function initTerminal() { |
| const out = $('termOutput'); |
| if (!out) return; |
| out.innerHTML = ''; |
| termPrint('LocalSpace Terminal v1.0', 'info'); |
| termPrint('Type \'help\' for available commands.', 'info'); |
| termPrint(''); |
| } |
| |
| function termPrint(text, cls = '') { |
| const out = $('termOutput'); |
| if (!out) return; |
| const line = document.createElement('div'); |
| if (cls) line.className = cls; |
| line.textContent = text; |
| out.appendChild(line); |
| out.scrollTop = out.scrollHeight; |
| } |
| |
| async function handleTerminalKey(e) { |
| if (e.key !== 'Enter') return; |
| const input = $('termInput'); |
| const cmd = input.value.trim(); |
| input.value = ''; |
| termPrint(`localspace$ ${cmd}`, 'cmd'); |
| if (!cmd) return; |
| await runTerminalCommand(cmd); |
| } |
| |
| async function runTerminalCommand(cmd) { |
| const parts = cmd.split(/\s+/); |
| const action = parts[0].toLowerCase(); |
| |
| |
| if (action === 'set') { |
| if (parts.length < 3) { termPrint('Usage: set <key> <value>', 'err'); return; } |
| const key = parts[1]; const val = parts.slice(2).join(' '); |
| if (!_kvStore) _kvStore = {}; |
| _kvStore[key] = val; |
| termPrint(`Set "${key}" = "${val}"`, 'success'); |
| return; |
| } |
| if (action === 'get') { |
| if (parts.length < 2) { termPrint('Usage: get <key>', 'err'); return; } |
| const val = _kvStore?.[parts[1]]; |
| if (val === undefined) termPrint(`Key "${parts[1]}" not found.`, 'warn'); |
| else termPrint(`${parts[1]} = ${val}`, 'info'); |
| return; |
| } |
| if (action === 'del') { |
| if (parts.length < 2) { termPrint('Usage: del <key>', 'err'); return; } |
| if (_kvStore) delete _kvStore[parts[1]]; |
| termPrint(`Deleted "${parts[1]}"`, 'success'); |
| return; |
| } |
| if (action === 'keys') { |
| const keys = _kvStore ? Object.keys(_kvStore) : []; |
| if (!keys.length) { termPrint('No KV entries.', 'warn'); return; } |
| termPrint('KV Keys:', 'info'); |
| keys.forEach(k => termPrint(` ${k} = ${_kvStore[k]}`, 'muted')); |
| termPrint(`${keys.length} entries.`); |
| return; |
| } |
| if (action === 'clear_kv') { |
| _kvStore = {}; |
| termPrint('KV memory cleared.', 'success'); |
| return; |
| } |
| |
| if (action === 'help' || action === 'h') { |
| termPrint('Commands:', 'info'); |
| termPrint(' help Show this message'); |
| termPrint(' apps List all apps with previews'); |
| termPrint(' open <id> Open app preview URL'); |
| termPrint(' launch <id> Launch app in new tab'); |
| termPrint(' set <k> <v> Store KV pair (session)'); |
| termPrint(' get <k> Retrieve KV value'); |
| termPrint(' del <k> Delete KV pair'); |
| termPrint(' keys List all KV keys'); |
| termPrint(' clear_kv Clear KV memory'); |
| termPrint(' status Server status'); |
| termPrint(' clear Clear terminal'); |
| termPrint(' reload Reload app list'); |
| return; |
| } |
| |
| if (action === 'clear') { |
| $('termOutput').innerHTML = ''; |
| return; |
| } |
| |
| if (action === 'status') { |
| termPrint('Server: ONLINE', 'success'); |
| termPrint(`Apps cached: ${_appsCache.length}`); |
| const previewCount = _appsCache.filter(a => a.has_preview).length; |
| termPrint(`Apps with preview: ${previewCount}`); |
| return; |
| } |
| |
| if (action === 'apps') { |
| const previewApps = _appsCache.filter(a => a.has_preview); |
| if (!previewApps.length) { termPrint('No apps with web preview.', 'warn'); return; } |
| previewApps.forEach(a => { |
| termPrint(` ${a.app_id.substring(0, 8)}β¦ ${a.filename}`, 'info'); |
| }); |
| return; |
| } |
| |
| if (action === 'reload') { |
| await renderMicrospaces(); |
| termPrint('App list reloaded.', 'success'); |
| return; |
| } |
| |
| if (action === 'open' || action === 'launch') { |
| const id = parts[1]; |
| if (!id) { termPrint('Usage: open <app-id>', 'err'); return; } |
| const app = _appsCache.find(a => a.app_id.startsWith(id) || a.app_id === id); |
| if (!app) { termPrint(`App not found: ${id}`, 'err'); return; } |
| const entryPath = app.preview_entry || ''; |
| const url = entryPath ? `/api/preview/${app.app_id}/${entryPath}` : `/api/preview/${app.app_id}`; |
| if (action === 'launch') { |
| window.open(url, '_blank'); |
| termPrint(`Launched ${app.filename} in new tab.`, 'success'); |
| } else { |
| termPrint(`Preview URL: ${window.location.origin}${url}`, 'info'); |
| } |
| return; |
| } |
| |
| termPrint(`Unknown command: ${action}. Type 'help' for commands.`, 'err'); |
| } |
| |
| |
| let _currentSlide = 0; |
| const _slideInterval = 40000; |
| let _slideTimer = null; |
| let _timerStart = 0; |
| |
| function goToSlide(idx) { |
| const slides = document.querySelectorAll('.landing-slide'); |
| const dots = document.querySelectorAll('.landing-dot'); |
| slides.forEach((s, i) => s.classList.toggle('active', i === idx)); |
| dots.forEach((d, i) => d.classList.toggle('active', i === idx)); |
| _currentSlide = idx; |
| _timerStart = Date.now(); |
| } |
| |
| function nextSlide() { |
| const total = document.querySelectorAll('.landing-slide').length; |
| goToSlide((_currentSlide + 1) % total); |
| } |
| |
| function startCarousel() { |
| if (_slideTimer) clearInterval(_slideTimer); |
| _timerStart = Date.now(); |
| _slideTimer = setInterval(nextSlide, _slideInterval); |
| animateTimerBar(); |
| } |
| |
| function animateTimerBar() { |
| const fill = $('landingTimerFill'); |
| if (!fill) return; |
| function tick() { |
| if (!_timerStart) return; |
| const elapsed = Date.now() - _timerStart; |
| const pct = Math.min((elapsed / _slideInterval) * 100, 100); |
| fill.style.width = pct + '%'; |
| if (pct < 100) requestAnimationFrame(tick); |
| } |
| requestAnimationFrame(tick); |
| } |
| |
| |
| document.querySelectorAll('.landing-slide').forEach(s => { |
| s.addEventListener('mouseenter', () => { if (_slideTimer) clearInterval(_slideTimer); }); |
| s.addEventListener('mouseleave', () => startCarousel()); |
| }); |
| |
| startCarousel(); |
| |
| loadApps(); |
| </script> |
| </div> |
| </div> |
|
|
| |
| <div class="ctx-menu" id="ctxMenu"> |
| <div class="ctx-menu-item" onclick="ctxOpenApp()">π Open App</div> |
| <div class="ctx-menu-item" onclick="ctxLaunchNewTab()">β Open in New Tab</div> |
| <div class="ctx-menu-sep"></div> |
| <div class="ctx-menu-item" onclick="ctxCopyLink('app')">π Copy App Link</div> |
| <div class="ctx-menu-item" onclick="ctxCopyLink('preview')">π Copy Preview Link</div> |
| <div class="ctx-menu-item" onclick="ctxCopyLink('download')">β¬ Copy Download Link</div> |
| <div class="ctx-menu-sep"></div> |
| <div class="ctx-menu-item" onclick="ctxCopyId()"># Copy App ID</div> |
| </div> |
| </body> |
|
|
| </html> |