Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import glob | |
| import gradio as gr | |
| from huggingface_hub import snapshot_download | |
| DATASET_REPO_ID = "PeacebinfLow/mindseye-android-os-data" # change if needed | |
| def load_apps_from_dataset(): | |
| local_dir = snapshot_download( | |
| repo_id=DATASET_REPO_ID, | |
| repo_type="dataset", | |
| local_dir="dataset_cache", | |
| local_dir_use_symlinks=False | |
| ) | |
| app_files = glob.glob(os.path.join(local_dir, "apps", "*", "*.json")) | |
| apps = [] | |
| errors = [] | |
| for fp in app_files: | |
| try: | |
| with open(fp, "r", encoding="utf-8") as f: | |
| obj = json.load(f) | |
| # minimal sanity check | |
| if "id" in obj and "name" in obj and "category" in obj: | |
| apps.append(obj) | |
| else: | |
| errors.append(f"Missing required fields in: {fp}") | |
| except Exception as e: | |
| errors.append(f"{fp}: {e}") | |
| # stable ordering | |
| apps.sort(key=lambda a: (a.get("category", ""), a.get("name", ""))) | |
| return apps, errors | |
| def build_html(apps, source_label="snapshot_download"): | |
| apps_json = json.dumps(apps, ensure_ascii=False) | |
| return f""" | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | |
| <title>MindsEye Android OS</title> | |
| <style> | |
| * {{ margin:0; padding:0; box-sizing:border-box; }} | |
| body {{ | |
| background: radial-gradient(1200px 700px at 50% 30%, rgba(167,139,250,0.25), transparent 60%), | |
| linear-gradient(135deg, #0a0e1a 0%, #1a0b2e 50%, #0a0e1a 100%); | |
| min-height: 100vh; | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| padding: 24px; | |
| color: #fff; | |
| }} | |
| .stage {{ | |
| width: 100%; | |
| max-width: 1200px; | |
| display:flex; | |
| align-items:flex-end; | |
| justify-content:center; | |
| gap: 24px; | |
| }} | |
| .footer-bar {{ | |
| position: fixed; | |
| left: 0; right: 0; bottom: 0; | |
| padding: 10px 14px; | |
| background: rgba(10,13,21,0.75); | |
| backdrop-filter: blur(10px); | |
| border-top: 1px solid rgba(255,255,255,0.08); | |
| display:flex; | |
| justify-content:center; | |
| gap: 10px; | |
| font-size: 12px; | |
| color: rgba(255,255,255,0.7); | |
| }} | |
| .btn {{ | |
| border: 1px solid rgba(255,255,255,0.14); | |
| background: rgba(255,255,255,0.06); | |
| color: rgba(255,255,255,0.85); | |
| padding: 10px 14px; | |
| border-radius: 12px; | |
| cursor:pointer; | |
| transition: transform .15s ease, background .15s ease; | |
| user-select:none; | |
| }} | |
| .btn:hover {{ transform: scale(1.02); background: rgba(255,255,255,0.09); }} | |
| /* Phone */ | |
| .phone {{ | |
| width: 390px; | |
| height: 860px; | |
| border-radius: 52px; | |
| background: linear-gradient(145deg, #1a1d2e, #0f1219); | |
| box-shadow: 0 35px 90px rgba(0,0,0,0.55), inset 0 0 40px rgba(255,255,255,0.03); | |
| border: 10px solid #0a0d15; | |
| position: relative; | |
| overflow: hidden; | |
| }} | |
| .notch {{ | |
| position:absolute; | |
| top: 14px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| width: 140px; | |
| height: 34px; | |
| background: #0a0d15; | |
| border-radius: 0 0 22px 22px; | |
| z-index: 20; | |
| }} | |
| /* Status bar */ | |
| .status-bar {{ | |
| position: relative; | |
| z-index: 10; | |
| padding: 40px 18px 12px; | |
| display:flex; | |
| align-items:center; | |
| justify-content:space-between; | |
| color: rgba(255,255,255,0.85); | |
| font-size: 12.5px; | |
| letter-spacing: .2px; | |
| }} | |
| .status-center {{ | |
| font-weight: 650; | |
| opacity: 0.95; | |
| }} | |
| .status-right {{ | |
| display:flex; | |
| align-items:center; | |
| gap: 8px; | |
| }} | |
| /* Screens wrapper */ | |
| .screens {{ | |
| position: relative; | |
| height: calc(100% - 120px); | |
| overflow: hidden; | |
| }} | |
| .screen {{ | |
| position:absolute; | |
| inset: 0; | |
| padding: 18px; | |
| overflow-y: auto; | |
| -webkit-overflow-scrolling: touch; | |
| }} | |
| .screen::-webkit-scrollbar {{ width: 4px; }} | |
| .screen::-webkit-scrollbar-thumb {{ background: rgba(255,255,255,0.18); border-radius: 10px; }} | |
| /* Transition states */ | |
| .screen.hidden {{ | |
| pointer-events:none; | |
| opacity: 0; | |
| }} | |
| .anim-in-right {{ | |
| animation: inRight .30s ease-out; | |
| }} | |
| .anim-out-right {{ | |
| animation: outRight .30s ease-in; | |
| }} | |
| .anim-home {{ | |
| animation: homePop .22s ease-out; | |
| }} | |
| @keyframes inRight {{ | |
| from {{ transform: translateX(18%); opacity: 0; }} | |
| to {{ transform: translateX(0%); opacity: 1; }} | |
| }} | |
| @keyframes outRight {{ | |
| from {{ transform: translateX(0%); opacity: 1; }} | |
| to {{ transform: translateX(18%); opacity: 0; }} | |
| }} | |
| @keyframes homePop {{ | |
| from {{ transform: scale(.98); opacity: .65; }} | |
| to {{ transform: scale(1); opacity: 1; }} | |
| }} | |
| /* Launcher header */ | |
| .hero {{ | |
| display:flex; | |
| flex-direction:column; | |
| align-items:center; | |
| gap: 10px; | |
| padding-top: 6px; | |
| padding-bottom: 14px; | |
| }} | |
| .hero-icon {{ | |
| width: 78px; | |
| height: 78px; | |
| border-radius: 20px; | |
| background: linear-gradient(135deg, #a78bfa, #22d3ee); | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| font-size: 34px; | |
| box-shadow: 0 14px 30px rgba(167,139,250,0.25); | |
| border: 1px solid rgba(255,255,255,0.12); | |
| }} | |
| .hero h1 {{ | |
| font-size: 28px; | |
| font-weight: 800; | |
| line-height: 1; | |
| }} | |
| .hero p {{ | |
| font-size: 13px; | |
| color: rgba(255,255,255,0.6); | |
| }} | |
| .search {{ | |
| width: 100%; | |
| margin-top: 8px; | |
| background: rgba(255,255,255,0.07); | |
| border: 1px solid rgba(255,255,255,0.10); | |
| border-radius: 14px; | |
| padding: 12px 14px; | |
| display:flex; | |
| gap: 10px; | |
| align-items:center; | |
| color: rgba(255,255,255,0.75); | |
| }} | |
| .search input {{ | |
| width: 100%; | |
| background: transparent; | |
| border: none; | |
| outline: none; | |
| color: rgba(255,255,255,0.90); | |
| font-size: 13px; | |
| }} | |
| .section-title {{ | |
| margin-top: 16px; | |
| margin-bottom: 10px; | |
| display:flex; | |
| align-items:center; | |
| gap: 10px; | |
| font-size: 11px; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| color: rgba(255,255,255,0.55); | |
| font-weight: 650; | |
| }} | |
| .badge {{ | |
| font-size: 10px; | |
| padding: 2px 8px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(255,255,255,0.10); | |
| background: rgba(255,255,255,0.07); | |
| color: rgba(255,255,255,0.75); | |
| }} | |
| .grid {{ | |
| display:grid; | |
| grid-template-columns: repeat(3, 1fr); | |
| gap: 14px; | |
| }} | |
| .app-btn {{ | |
| border: none; | |
| background: transparent; | |
| cursor:pointer; | |
| display:flex; | |
| flex-direction:column; | |
| align-items:center; | |
| gap: 8px; | |
| padding: 8px 4px; | |
| border-radius: 14px; | |
| transition: transform .15s ease, background .15s ease; | |
| }} | |
| .app-btn:hover {{ background: rgba(167,139,250,0.10); }} | |
| .app-btn:active {{ transform: scale(.96); }} | |
| .icon {{ | |
| width: 58px; | |
| height: 58px; | |
| border-radius: 18px; | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| font-size: 22px; | |
| border: 1px solid rgba(255,255,255,0.12); | |
| box-shadow: 0 10px 24px rgba(0,0,0,0.35); | |
| }} | |
| .name {{ | |
| font-size: 11px; | |
| color: rgba(255,255,255,0.92); | |
| text-align:center; | |
| line-height: 1.15; | |
| font-weight: 600; | |
| }} | |
| /* App screen */ | |
| .app-header {{ | |
| display:flex; | |
| align-items:center; | |
| gap: 12px; | |
| padding: 6px 4px 10px; | |
| }} | |
| .app-header .icon {{ | |
| width: 54px; height: 54px; | |
| }} | |
| .app-title {{ | |
| display:flex; | |
| flex-direction:column; | |
| gap: 2px; | |
| }} | |
| .app-title h2 {{ | |
| font-size: 18px; | |
| font-weight: 800; | |
| line-height: 1.1; | |
| }} | |
| .app-title .sub {{ | |
| font-size: 12px; | |
| color: rgba(255,255,255,0.55); | |
| }} | |
| .card {{ | |
| margin-top: 12px; | |
| padding: 14px; | |
| border-radius: 16px; | |
| border: 1px solid rgba(167,139,250,0.22); | |
| background: rgba(15, 23, 42, 0.55); | |
| backdrop-filter: blur(10px); | |
| }} | |
| .card h3 {{ | |
| font-size: 12px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| color: rgba(255,255,255,0.65); | |
| margin-bottom: 8px; | |
| }} | |
| .card p {{ | |
| font-size: 13px; | |
| color: rgba(255,255,255,0.78); | |
| line-height: 1.45; | |
| }} | |
| .list {{ | |
| margin-top: 8px; | |
| padding-left: 18px; | |
| color: rgba(255,255,255,0.75); | |
| font-size: 13px; | |
| line-height: 1.5; | |
| }} | |
| .chips {{ | |
| display:flex; | |
| flex-wrap:wrap; | |
| gap: 8px; | |
| margin-top: 10px; | |
| }} | |
| .chip {{ | |
| border: 1px solid rgba(255,255,255,0.14); | |
| background: rgba(255,255,255,0.06); | |
| padding: 8px 10px; | |
| border-radius: 999px; | |
| font-size: 12px; | |
| color: rgba(255,255,255,0.85); | |
| cursor:pointer; | |
| transition: transform .15s ease, background .15s ease; | |
| user-select:none; | |
| }} | |
| .chip:hover {{ transform: scale(1.02); background: rgba(34,211,238,0.10); }} | |
| .repo-link {{ | |
| display:block; | |
| margin-top: 10px; | |
| padding: 10px 12px; | |
| border-radius: 12px; | |
| border: 1px solid rgba(34,211,238,0.22); | |
| background: rgba(34,211,238,0.08); | |
| color: rgba(255,255,255,0.88); | |
| text-decoration:none; | |
| font-size: 13px; | |
| }} | |
| .repo-link:hover {{ | |
| background: rgba(34,211,238,0.12); | |
| }} | |
| /* Nav bar */ | |
| .nav {{ | |
| position:absolute; | |
| left:0; right:0; bottom:0; | |
| height: 78px; | |
| background: rgba(10, 13, 21, 0.92); | |
| border-top: 1px solid rgba(255,255,255,0.06); | |
| display:flex; | |
| align-items:center; | |
| justify-content:space-around; | |
| padding: 0 28px; | |
| z-index: 15; | |
| backdrop-filter: blur(16px); | |
| }} | |
| .nav .navbtn {{ | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 999px; | |
| border: 1px solid rgba(255,255,255,0.14); | |
| background: rgba(255,255,255,0.07); | |
| color: rgba(255,255,255,0.88); | |
| cursor:pointer; | |
| transition: transform .15s ease, background .15s ease; | |
| display:flex; | |
| align-items:center; | |
| justify-content:center; | |
| font-size: 18px; | |
| user-select:none; | |
| }} | |
| .nav .navbtn:hover {{ | |
| transform: scale(1.05); | |
| background: rgba(255,255,255,0.10); | |
| }} | |
| .nav .home {{ | |
| background: linear-gradient(135deg, rgba(167,139,250,0.95), rgba(34,211,238,0.85)); | |
| border: 1px solid rgba(255,255,255,0.16); | |
| color: #0b1020; | |
| font-weight: 900; | |
| }} | |
| /* Recents */ | |
| .recent-card {{ | |
| border: 1px solid rgba(255,255,255,0.12); | |
| background: rgba(255,255,255,0.06); | |
| border-radius: 18px; | |
| padding: 14px; | |
| margin-top: 12px; | |
| cursor:pointer; | |
| transition: transform .15s ease, background .15s ease; | |
| }} | |
| .recent-card:hover {{ | |
| transform: scale(1.01); | |
| background: rgba(167,139,250,0.10); | |
| }} | |
| /* Color map */ | |
| .c-purple {{ background: linear-gradient(135deg, #a78bfa, #7c3aed); }} | |
| .c-cyan {{ background: linear-gradient(135deg, #22d3ee, #3b82f6); }} | |
| .c-amber {{ background: linear-gradient(135deg, #fbbf24, #f59e0b); }} | |
| .c-emerald{{ background: linear-gradient(135deg, #10b981, #059669); }} | |
| .c-rose {{ background: linear-gradient(135deg, #fb7185, #e11d48); }} | |
| .c-indigo {{ background: linear-gradient(135deg, #818cf8, #4f46e5); }} | |
| .c-orange {{ background: linear-gradient(135deg, #fb923c, #f97316); }} | |
| .c-violet {{ background: linear-gradient(135deg, #8b5cf6, #7c3aed); }} | |
| .c-blue {{ background: linear-gradient(135deg, #60a5fa, #2563eb); }} | |
| .c-teal {{ background: linear-gradient(135deg, #2dd4bf, #0d9488); }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="stage"> | |
| <div class="phone" id="phone"> | |
| <div class="notch"></div> | |
| <div class="status-bar"> | |
| <div id="timeText">--:--</div> | |
| <div class="status-center" id="statusTitle">MindsEye</div> | |
| <div class="status-right"> | |
| <span>📶</span> | |
| <span id="batteryText">87%</span> | |
| </div> | |
| </div> | |
| <div class="screens"> | |
| <div class="screen" id="launcherScreen"></div> | |
| <div class="screen hidden" id="appScreen"></div> | |
| <div class="screen hidden" id="recentsScreen"></div> | |
| </div> | |
| <div class="nav"> | |
| <div class="navbtn" id="btnBack" title="Back">◀</div> | |
| <div class="navbtn home" id="btnHome" title="Home">⬤</div> | |
| <div class="navbtn" id="btnRecents" title="Recents">▢</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="footer-bar"> | |
| <button class="btn" id="refreshBtn">Refresh Dataset</button> | |
| <div>Loaded {len(apps)} apps • source: {source_label}</div> | |
| </div> | |
| <script> | |
| window.MINDSEYE_APPS = {apps_json}; | |
| </script> | |
| <script> | |
| // ------------ STATE ------------ | |
| const state = {{ | |
| current: "home", // "home" | "app:<id>" | "recents" | |
| backstack: [], // stack of previous screens | |
| recents: [], // list of appIds, most recent first | |
| scroll: new Map(), // key -> scrollTop | |
| query: "", | |
| }}; | |
| const apps = window.MINDSEYE_APPS || []; | |
| const appById = new Map(apps.map(a => [a.id, a])); | |
| const categoriesOrder = [ | |
| "google-integration", | |
| "core-system", | |
| "binary-runtime", | |
| "protocol", | |
| "mindscript", | |
| "sql-cloud" | |
| ]; | |
| const categoryLabels = {{ | |
| "google-integration": "GOOGLE INTEGRATION", | |
| "core-system": "CORE SYSTEM", | |
| "binary-runtime": "BINARY & RUNTIME", | |
| "protocol": "PROTOCOL", | |
| "mindscript": "MINDSCRIPT", | |
| "sql-cloud": "SQL & CLOUD" | |
| }}; | |
| // ------------ DOM ------------ | |
| const launcherScreen = document.getElementById("launcherScreen"); | |
| const appScreen = document.getElementById("appScreen"); | |
| const recentsScreen = document.getElementById("recentsScreen"); | |
| const statusTitle = document.getElementById("statusTitle"); | |
| const btnBack = document.getElementById("btnBack"); | |
| const btnHome = document.getElementById("btnHome"); | |
| const btnRecents = document.getElementById("btnRecents"); | |
| const refreshBtn = document.getElementById("refreshBtn"); | |
| // ------------ UTILS ------------ | |
| function colorClass(color) {{ | |
| const c = (color || "").toLowerCase(); | |
| const map = {{ | |
| purple: "c-purple", | |
| cyan: "c-cyan", | |
| amber: "c-amber", | |
| emerald: "c-emerald", | |
| rose: "c-rose", | |
| indigo: "c-indigo", | |
| orange: "c-orange", | |
| violet: "c-violet", | |
| blue: "c-blue", | |
| teal: "c-teal" | |
| }}; | |
| return map[c] || "c-purple"; | |
| }} | |
| function setTimeLive() {{ | |
| const t = new Date(); | |
| const hh = String(t.getHours()).padStart(2,"0"); | |
| const mm = String(t.getMinutes()).padStart(2,"0"); | |
| document.getElementById("timeText").textContent = `${{hh}}:${{mm}}`; | |
| }} | |
| setTimeLive(); | |
| setInterval(setTimeLive, 1000 * 10); | |
| // ------------ RENDER: LAUNCHER ------------ | |
| function renderLauncher() {{ | |
| statusTitle.textContent = "MindsEye"; | |
| const q = state.query.trim().toLowerCase(); | |
| const filtered = apps.filter(a => {{ | |
| if (!q) return true; | |
| return ( | |
| (a.name || "").toLowerCase().includes(q) || | |
| (a.id || "").toLowerCase().includes(q) || | |
| (a.description || "").toLowerCase().includes(q) || | |
| (a.tags || []).join(" ").toLowerCase().includes(q) | |
| ); | |
| }}); | |
| const grouped = new Map(); | |
| for (const cat of categoriesOrder) grouped.set(cat, []); | |
| for (const a of filtered) {{ | |
| const cat = a.category || "other"; | |
| if (!grouped.has(cat)) grouped.set(cat, []); | |
| grouped.get(cat).push(a); | |
| }} | |
| // header + search + sections | |
| let html = ` | |
| <div class="hero"> | |
| <div class="hero-icon">🧠</div> | |
| <h1>MindsEye OS</h1> | |
| <p>${{apps.length}}+ Cognitive Automation Modules</p> | |
| <div class="search"> | |
| <span>🔍</span> | |
| <input id="searchInput" placeholder="Search apps..." value="${{escapeHtml(state.query)}}" /> | |
| </div> | |
| </div> | |
| `; | |
| for (const cat of categoriesOrder) {{ | |
| const list = grouped.get(cat) || []; | |
| if (!list.length) continue; | |
| html += ` | |
| <div class="section-title"> | |
| ${{categoryLabels[cat] || cat}} | |
| <span class="badge">${{list.length}}</span> | |
| </div> | |
| <div class="grid"> | |
| ${{list.map(a => ` | |
| <button class="app-btn" data-open-app="${{a.id}}"> | |
| <div class="icon ${{colorClass(a.color)}}">${{escapeHtml(a.icon || "⬡")}}</div> | |
| <div class="name">${{escapeHtml(a.name || a.id)}}</div> | |
| </button> | |
| `).join("")}} | |
| </div> | |
| `; | |
| }} | |
| launcherScreen.innerHTML = html; | |
| const input = document.getElementById("searchInput"); | |
| input.addEventListener("input", (e) => {{ | |
| state.query = e.target.value || ""; | |
| // keep user in launcher while typing | |
| renderLauncher(); | |
| }}); | |
| }} | |
| // ------------ RENDER: APP SCREEN ------------ | |
| function renderApp(appId) {{ | |
| const app = appById.get(appId); | |
| if (!app) return; | |
| statusTitle.textContent = app.name || "App"; | |
| const conns = Array.isArray(app.connections) ? app.connections : []; | |
| const features = Array.isArray(app.features) ? app.features : []; | |
| const tags = Array.isArray(app.tags) ? app.tags : []; | |
| const connChips = conns | |
| .filter(c => c && c.to && appById.has(c.to)) | |
| .map(c => {{ | |
| const target = appById.get(c.to); | |
| return `<div class="chip" data-open-app="${{c.to}}"> | |
| ${{escapeHtml(target.name || c.to)}} • ${{escapeHtml(c.type || "link")}} | |
| </div>`; | |
| }}) | |
| .join(""); | |
| const tagsChips = tags.slice(0, 10).map(t => `<div class="chip" style="cursor:default;">#${{escapeHtml(t)}}</div>`).join(""); | |
| appScreen.innerHTML = ` | |
| <div class="app-header"> | |
| <div class="icon ${{colorClass(app.color)}}">${{escapeHtml(app.icon || "⬡")}}</div> | |
| <div class="app-title"> | |
| <h2>${{escapeHtml(app.name || app.id)}}</h2> | |
| <div class="sub">${{escapeHtml(app.category || "")}} • v${{escapeHtml(app.version || "1.0.0")}}</div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3>Overview</h3> | |
| <p>${{escapeHtml(app.description || "No description yet.")}}</p> | |
| ${{features.length ? ` | |
| <ul class="list"> | |
| ${{features.map(f => `<li>${{escapeHtml(f)}}</li>`).join("")}} | |
| </ul> | |
| ` : ""}} | |
| ${{app.github_repo ? ` | |
| <a class="repo-link" href="${{app.github_repo}}" target="_blank" rel="noreferrer"> | |
| Open GitHub Repo → | |
| </a> | |
| ` : ""}} | |
| </div> | |
| <div class="card"> | |
| <h3>Connections</h3> | |
| <p>Tap a connection to navigate through the ecosystem (Android-style app jumping).</p> | |
| <div class="chips"> | |
| ${{connChips || `<span style="color:rgba(255,255,255,.55);font-size:13px;">No connections defined.</span>`}} | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3>Tags</h3> | |
| <div class="chips"> | |
| ${{tagsChips || `<span style="color:rgba(255,255,255,.55);font-size:13px;">No tags yet.</span>`}} | |
| </div> | |
| </div> | |
| `; | |
| // restore scroll if known | |
| const key = `app:${{appId}}`; | |
| if (state.scroll.has(key)) {{ | |
| appScreen.scrollTop = state.scroll.get(key); | |
| }} else {{ | |
| appScreen.scrollTop = 0; | |
| }} | |
| }} | |
| // ------------ RENDER: RECENTS ------------ | |
| function renderRecents() {{ | |
| statusTitle.textContent = "Recents"; | |
| const items = state.recents | |
| .filter(id => appById.has(id)) | |
| .slice(0, 5); | |
| let html = `<div class="hero" style="padding-top:10px;"> | |
| <h1 style="font-size:22px;">Recent Apps</h1> | |
| <p>Last opened apps. Tap to reopen.</p> | |
| </div>`; | |
| if (!items.length) {{ | |
| html += `<div class="card"><p>No recent apps yet. Open something first.</p></div>`; | |
| }} else {{ | |
| for (const id of items) {{ | |
| const a = appById.get(id); | |
| html += ` | |
| <div class="recent-card" data-open-app="${{id}}"> | |
| <div style="display:flex;align-items:center;gap:12px;"> | |
| <div class="icon ${{colorClass(a.color)}}" style="width:52px;height:52px;">${{escapeHtml(a.icon || "⬡")}}</div> | |
| <div> | |
| <div style="font-weight:800;">${{escapeHtml(a.name || a.id)}}</div> | |
| <div style="font-size:12px;color:rgba(255,255,255,0.6);margin-top:2px;"> | |
| ${{escapeHtml(a.category || "")}} • v${{escapeHtml(a.version || "1.0.0")}} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| }} | |
| }} | |
| recentsScreen.innerHTML = html; | |
| const key = "recents"; | |
| if (state.scroll.has(key)) {{ | |
| recentsScreen.scrollTop = state.scroll.get(key); | |
| }} else {{ | |
| recentsScreen.scrollTop = 0; | |
| }} | |
| }} | |
| // ------------ NAVIGATION CORE ------------ | |
| function showScreen(which, animClass=null) {{ | |
| // save scroll positions | |
| saveScroll(); | |
| // hide all | |
| launcherScreen.classList.add("hidden"); | |
| appScreen.classList.add("hidden"); | |
| recentsScreen.classList.add("hidden"); | |
| // clear animations | |
| launcherScreen.classList.remove("anim-in-right","anim-out-right","anim-home"); | |
| appScreen.classList.remove("anim-in-right","anim-out-right","anim-home"); | |
| recentsScreen.classList.remove("anim-in-right","anim-out-right","anim-home"); | |
| const el = which === "home" ? launcherScreen : (which === "recents" ? recentsScreen : appScreen); | |
| el.classList.remove("hidden"); | |
| if (animClass) el.classList.add(animClass); | |
| }} | |
| function saveScroll() {{ | |
| // current screen scroll memory | |
| if (state.current === "home") {{ | |
| state.scroll.set("home", launcherScreen.scrollTop); | |
| }} else if (state.current === "recents") {{ | |
| state.scroll.set("recents", recentsScreen.scrollTop); | |
| }} else if (state.current.startsWith("app:")) {{ | |
| state.scroll.set(state.current, appScreen.scrollTop); | |
| }} | |
| }} | |
| function openApp(appId) {{ | |
| // push current to backstack | |
| state.backstack.push(state.current); | |
| state.current = `app:${{appId}}`; | |
| // recents update | |
| state.recents = [appId, ...state.recents.filter(x => x !== appId)]; | |
| state.recents = state.recents.slice(0, 10); | |
| renderApp(appId); | |
| showScreen("app", "anim-in-right"); | |
| }} | |
| function goHome() {{ | |
| state.current = "home"; | |
| // optional: keep backstack? Android typically clears to home but back still returns to prior task sometimes. | |
| // We'll keep it simple: clearing backstack = clean UX for portfolio. | |
| state.backstack = []; | |
| renderLauncher(); | |
| showScreen("home", "anim-home"); | |
| // restore scroll | |
| if (state.scroll.has("home")) launcherScreen.scrollTop = state.scroll.get("home"); | |
| }} | |
| function goRecents() {{ | |
| state.backstack.push(state.current); | |
| state.current = "recents"; | |
| renderRecents(); | |
| showScreen("recents", "anim-in-right"); | |
| }} | |
| function goBack() {{ | |
| if (!state.backstack.length) {{ | |
| // if you're on app screen with no history, go home | |
| if (state.current !== "home") return goHome(); | |
| return; | |
| }} | |
| const prev = state.backstack.pop(); | |
| state.current = prev; | |
| if (prev === "home") {{ | |
| renderLauncher(); | |
| showScreen("home", "anim-out-right"); | |
| if (state.scroll.has("home")) launcherScreen.scrollTop = state.scroll.get("home"); | |
| }} else if (prev === "recents") {{ | |
| renderRecents(); | |
| showScreen("recents", "anim-out-right"); | |
| }} else if (prev.startsWith("app:")) {{ | |
| const id = prev.split(":")[1]; | |
| renderApp(id); | |
| showScreen("app", "anim-out-right"); | |
| }} | |
| }} | |
| // ------------ EVENT DELEGATION ------------ | |
| document.addEventListener("click", (e) => {{ | |
| const t = e.target.closest("[data-open-app]"); | |
| if (t) {{ | |
| const id = t.getAttribute("data-open-app"); | |
| if (id) openApp(id); | |
| }} | |
| }}); | |
| btnBack.addEventListener("click", goBack); | |
| btnHome.addEventListener("click", goHome); | |
| btnRecents.addEventListener("click", goRecents); | |
| // "Refresh Dataset" - hard reload is simplest in Spaces | |
| refreshBtn.addEventListener("click", () => window.location.reload()); | |
| // ------------ GESTURES (Android-ish) ------------ | |
| // Swipe from left edge -> back | |
| // Swipe up from bottom -> home | |
| let startX = 0, startY = 0, startT = 0, tracking = false; | |
| const phone = document.getElementById("phone"); | |
| phone.addEventListener("touchstart", (e) => {{ | |
| const touch = e.touches[0]; | |
| startX = touch.clientX; | |
| startY = touch.clientY; | |
| startT = Date.now(); | |
| tracking = true; | |
| }}, {{passive:true}}); | |
| phone.addEventListener("touchend", (e) => {{ | |
| if (!tracking) return; | |
| tracking = false; | |
| const touch = e.changedTouches[0]; | |
| const dx = touch.clientX - startX; | |
| const dy = touch.clientY - startY; | |
| const dt = Date.now() - startT; | |
| // thresholds | |
| const LEFT_EDGE = 50; | |
| const BACK_SWIPE = 80; | |
| const HOME_SWIPE = 90; | |
| // back: start from left edge and swipe right | |
| if (startX <= LEFT_EDGE && dx >= BACK_SWIPE && Math.abs(dy) < 90 && dt < 700) {{ | |
| goBack(); | |
| return; | |
| }} | |
| // home: swipe up from bottom area | |
| const rect = phone.getBoundingClientRect(); | |
| const bottomZone = rect.bottom - 70; | |
| if (startY >= bottomZone && (-dy) >= HOME_SWIPE && Math.abs(dx) < 120 && dt < 700) {{ | |
| goHome(); | |
| return; | |
| }} | |
| }}, {{passive:true}}); | |
| // ------------ HTML ESCAPE ------------ | |
| function escapeHtml(s) {{ | |
| return String(s ?? "") | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| }} | |
| // ------------ INIT ------------ | |
| renderLauncher(); | |
| showScreen("home"); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| with gr.Blocks() as demo: | |
| apps, errors = load_apps_from_dataset() | |
| html = build_html(apps) | |
| gr.HTML(html) | |
| if errors: | |
| gr.Markdown("### Dataset Load Warnings") | |
| gr.Markdown("```log\n" + "\n".join(errors) + "\n```") | |
| demo.launch() | |