app-store/server/web/index.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()">&#8592; 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>&#11015; 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>