204 lines
11 KiB
HTML
204 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Local App Store</title>
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
:root{--primary:#1B96FF;--primary-dark:#006EC4;--bg:#FAFAFA;--surface:#FFF;--text:#1C1C1C;--text-secondary:#666;--border:#E0E0E0;--shadow:0 2px 8px rgba(0,0,0,.1)}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text)}
|
|
header{background:var(--primary);color:#fff;padding:16px 24px;position:sticky;top:0;z-index:100;box-shadow:var(--shadow)}
|
|
header h1{font-size:20px;font-weight:600;display:flex;align-items:center;gap:10px}
|
|
header h1 svg{width:28px;height:28px}
|
|
.header-right{margin-left:auto;display:flex;align-items:center;gap:16px}
|
|
.status{font-size:13px;opacity:.8;display:flex;align-items:center;gap:6px}
|
|
.status-dot{width:8px;height:8px;border-radius:50%;background:#4CAF50;animation:pulse 2s infinite}
|
|
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
.search-wrap{display:flex;gap:10px;max-width:500px}
|
|
.search-wrap input{flex:1;padding:10px 14px;border:none;border-radius:8px;font-size:14px;outline:none}
|
|
.search-wrap button{padding:10px 16px;border:none;border-radius:8px;background:var(--primary-dark);color:#fff;cursor:pointer;font-size:14px;font-weight:500}
|
|
.search-wrap button:hover{opacity:.9}
|
|
.container{max-width:1100px;margin:0 auto;padding:24px}
|
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px}
|
|
.card{background:var(--surface);border-radius:12px;padding:20px;cursor:pointer;transition:transform .2s,box-shadow .2s;box-shadow:var(--shadow);display:flex;flex-direction:column;align-items:center;text-align:center}
|
|
.card:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.15)}
|
|
.card-icon{width:72px;height:72px;border-radius:16px;object-fit:contain;margin-bottom:12px;background:#f0f0f0}
|
|
.card-name{font-size:15px;font-weight:600;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
.card-version{font-size:12px;color:var(--text-secondary)}
|
|
.card-size{font-size:11px;color:var(--text-secondary);margin-top:2px}
|
|
.empty{text-align:center;padding:60px 20px;color:var(--text-secondary)}
|
|
.empty svg{width:64px;height:64px;margin-bottom:16px;opacity:.3}
|
|
.empty h2{font-size:18px;margin-bottom:8px;color:var(--text)}
|
|
.empty p{font-size:14px}
|
|
.loader{text-align:center;padding:40px}
|
|
.loader span{display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--primary);animation:bounce .6s infinite alternate}
|
|
.loader span:nth-child(2){animation-delay:.2s}
|
|
.loader span:nth-child(3){animation-delay:.4s}
|
|
@keyframes bounce{to{transform:translateY(-12px)}}
|
|
.back-btn{display:inline-flex;align-items:center;gap:4px;padding:8px 16px;border:none;border-radius:8px;background:var(--surface);color:var(--text);cursor:pointer;font-size:14px;margin-bottom:20px;box-shadow:var(--shadow)}
|
|
.back-btn:hover{background:#f0f0f0}
|
|
.detail{background:var(--surface);border-radius:16px;padding:32px;box-shadow:var(--shadow);max-width:700px;margin:0 auto}
|
|
.detail-header{display:flex;gap:24px;align-items:flex-start;margin-bottom:24px}
|
|
.detail-icon{width:96px;height:96px;border-radius:20px;object-fit:contain;background:#f0f0f0;flex-shrink:0}
|
|
.detail-title{font-size:24px;font-weight:700;margin-bottom:4px}
|
|
.detail-meta{font-size:14px;color:var(--text-secondary);line-height:1.8}
|
|
.detail-desc{margin:20px 0;padding:16px;background:#f8f9fa;border-radius:10px;font-size:14px;line-height:1.7;white-space:pre-wrap}
|
|
.permissions{margin:20px 0}
|
|
.permissions h3{font-size:14px;font-weight:600;margin-bottom:10px;color:var(--text-secondary)}
|
|
.permissions ul{list-style:none;display:flex;flex-wrap:wrap;gap:6px}
|
|
.permissions li{padding:4px 12px;background:#e8f0fe;border-radius:20px;font-size:12px;color:var(--primary-dark)}
|
|
.screenshots{margin:24px 0}
|
|
.screenshots h3{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--text-secondary)}
|
|
.screenshots-scroll{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px}
|
|
.screenshots-scroll img{height:160px;border-radius:8px;object-fit:cover}
|
|
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 32px;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}
|
|
.btn-primary{background:var(--primary);color:#fff}
|
|
.btn-primary:hover{background:var(--primary-dark)}
|
|
.btn-secondary{background:#f0f0f0;color:var(--text)}
|
|
.btn-secondary:hover{background:#e0e0e0}
|
|
.actions{display:flex;gap:12px;margin-top:24px}
|
|
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:10px;color:#fff;font-size:14px;box-shadow:0 4px 20px rgba(0,0,0,.2);transform:translateY(100px);opacity:0;transition:all .3s;z-index:1000}
|
|
.toast.show{transform:translateY(0);opacity:1}
|
|
.toast.success{background:#4CAF50}
|
|
.toast.error{background:#f44336}
|
|
.scan-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.4);border-radius:6px;background:transparent;color:#fff;cursor:pointer;font-size:12px}
|
|
.scan-btn:hover{background:rgba(255,255,255,.15)}
|
|
@media(max-width:600px){.grid{grid-template-columns:repeat(2,1fr)}.detail-header{flex-direction:column;align-items:center;text-align:center}.header-right{flex-direction:column;gap:8px}.search-wrap{width:100%}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
|
Local App Store
|
|
</h1>
|
|
<div class="header-right">
|
|
<div class="search-wrap">
|
|
<input type="text" id="searchInput" placeholder="Search apps..." />
|
|
<button onclick="doSearch()">Search</button>
|
|
</div>
|
|
<div class="status"><span class="status-dot"></span><span id="statusText">Connected</span></div>
|
|
<button class="scan-btn" onclick="doScan()">Scan repos</button>
|
|
</div>
|
|
</header>
|
|
<div class="container">
|
|
<div id="homeView">
|
|
<div class="grid" id="appGrid"></div>
|
|
<div class="empty" id="emptyState" style="display:none">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 2h12v20H6V2zm5 5v4h4V7h-4zm-2 0v4h2V7H9zm6 8v4h4v-4h-4zm-6 0v4h4v-4H9z"/></svg>
|
|
<h2>No apps found</h2>
|
|
<p>Add APKs to the repos/ folder and click "Scan repos"</p>
|
|
</div>
|
|
<div class="loader" id="loader" style="display:none"><span></span><span></span><span></span></div>
|
|
</div>
|
|
<div id="detailView" style="display:none"></div>
|
|
</div>
|
|
<div class="toast" id="toast"></div>
|
|
<script>
|
|
const API='/api';
|
|
let allApps=[];
|
|
let currentApp=null;
|
|
|
|
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')doSearch()});
|
|
|
|
async function fetchJSON(url){const r=await fetch(url);if(!r.ok)throw new Error(`${r.status} ${r.statusText}`);return r.json()}
|
|
|
|
function showToast(msg,type='success'){const t=document.getElementById('toast');t.textContent=msg;t.className=`toast ${type} show`;setTimeout(()=>t.classList.remove('show'),3000)}
|
|
|
|
function formatSize(bytes){if(!bytes)return'N/A';if(bytes>1e6)return(bytes/1e6).toFixed(1)+' MB';if(bytes>1e3)return(bytes/1e3).toFixed(1)+' KB';return bytes+' B'}
|
|
|
|
function renderIcon(app){return`${API}/apps/${app.id}/icon`}
|
|
|
|
async function loadApps(){
|
|
document.getElementById('loader').style.display='block';
|
|
document.getElementById('appGrid').style.display='none';
|
|
document.getElementById('emptyState').style.display='none';
|
|
try{
|
|
const data=await fetchJSON(`${API}/apps?limit=200`);
|
|
allApps=data.apps||[];
|
|
renderHome(allApps);
|
|
}catch(e){
|
|
showToast('Failed to load apps: '+e.message,'error');
|
|
document.getElementById('statusText').textContent='Disconnected';
|
|
}finally{
|
|
document.getElementById('loader').style.display='none';
|
|
}
|
|
}
|
|
|
|
function renderHome(apps){
|
|
const grid=document.getElementById('appGrid');
|
|
const empty=document.getElementById('emptyState');
|
|
if(apps.length===0){grid.style.display='none';empty.style.display='block';return}
|
|
grid.style.display='grid';empty.style.display='none';
|
|
grid.innerHTML=apps.map(app=>`<div class="card" onclick="showDetail('${app.id}')">
|
|
<img class="card-icon" src="${renderIcon(app)}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect fill=%22%23e0e0e0%22 width=%22100%22 height=%22100%22/><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23999%22 font-size=%2240%22>${app.name[0]}</text></svg>'" />
|
|
<div class="card-name">${app.name}</div>
|
|
<div class="card-version">v${app.versionName}</div>
|
|
<div class="card-size">${formatSize(app.size)}</div>
|
|
</div>`).join('');
|
|
}
|
|
|
|
async function showDetail(id){
|
|
currentApp=allApps.find(a=>a.id===id);
|
|
if(!currentApp){try{currentApp=await fetchJSON(`${API}/apps/${id}`)}catch(e){showToast('App not found','error');return}}
|
|
document.getElementById('homeView').style.display='none';
|
|
const dv=document.getElementById('detailView');
|
|
dv.style.display='block';
|
|
const app=currentApp;
|
|
let html=`<button class="back-btn" onclick="goBack()">← Back</button>`;
|
|
html+=`<div class="detail">
|
|
<div class="detail-header">
|
|
<img class="detail-icon" src="${renderIcon(app)}" onerror="this.style.display='none'" />
|
|
<div>
|
|
<div class="detail-title">${app.name}</div>
|
|
<div class="detail-meta">
|
|
Version: ${app.versionName} (${app.versionCode})<br/>
|
|
Package: ${app.packageName}<br/>
|
|
Size: ${formatSize(app.size)}
|
|
${app.minSdk?`<br/>Min SDK: ${app.minSdk}`:''}
|
|
${app.targetSdk?`<br/>Target SDK: ${app.targetSdk}`:''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
if(app.description)html+=`<div class="detail-desc">${app.description}</div>`;
|
|
if(app.permissions&&app.permissions.length){
|
|
html+=`<div class="permissions"><h3>Permissions</h3><ul>${app.permissions.map(p=>`<li>${p}</li>`).join('')}</ul></div>`;
|
|
}
|
|
if(app.screenshots&&app.screenshots.length){
|
|
html+=`<div class="screenshots"><h3>Screenshots</h3><div class="screenshots-scroll">${app.screenshots.map(s=>`<img src="${API}/apps/${app.id}/screenshots/${s}" />`).join('')}</div></div>`;
|
|
}
|
|
html+=`<div class="actions">
|
|
<a class="btn btn-primary" href="${API}/apps/${app.id}/download" download>⬇ Download APK</a>
|
|
</div></div>`;
|
|
dv.innerHTML=html;
|
|
}
|
|
|
|
function goBack(){
|
|
document.getElementById('detailView').style.display='none';
|
|
document.getElementById('homeView').style.display='block';
|
|
currentApp=null;
|
|
}
|
|
|
|
function doSearch(){
|
|
const q=document.getElementById('searchInput').value.trim().toLowerCase();
|
|
if(!q){renderHome(allApps);return}
|
|
const filtered=allApps.filter(a=>a.name.toLowerCase().includes(q)||a.description.toLowerCase().includes(q)||a.packageName.toLowerCase().includes(q));
|
|
renderHome(filtered);
|
|
}
|
|
|
|
async function doScan(){
|
|
showToast('Scanning repository...');
|
|
try{
|
|
await fetchJSON(`${API}/scan`);
|
|
await loadApps();
|
|
showToast('Scan complete');
|
|
}catch(e){
|
|
showToast('Scan failed: '+e.message,'error');
|
|
}
|
|
}
|
|
|
|
loadApps();
|
|
</script>
|
|
</body>
|
|
</html> |