Puter Apps

=== assets/css/styles.css === :root{ --bg: #0f172a; /* slate-900 */ --bg-elev: #111827; /* gray-900 */ --card: #0b1220; /* darker card */ --text: #e5e7eb; /* gray-200 */ --muted: #9ca3af; /* gray-400 */ --primary: #4f46e5; /* indigo-600 */ --primary-700: #4338ca; --accent: #22d3ee; /* cyan-400 */ --success: #10b981; --danger: #ef4444; --border: #1f2937; /* gray-800 */ --shadow: 0 6px 30px rgba(0,0,0,.35); --radius: 14px; } * { box-sizing: border-box; } html, body { height: 100%; } body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; background: linear-gradient(180deg, var(--bg) 0%, #0b1220 100%); color: var(--text); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } .visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } .app-header { position: sticky; top: 0; z-index: 5; backdrop-filter: saturate(150%) blur(8px); background: rgba(11,18,32,.6); border-bottom: 1px solid var(--border); } .header-top { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; } .app-title { margin: 0; font-size: 20px; font-weight: 700; } .title-link { color: var(--text); text-decoration: none; } .title-link:hover { color: var(--accent); } .header-actions { display: flex; gap: 8px; } .icon-btn { appearance: none; border: 1px solid var(--border); background: #0b1220; color: var(--text); border-radius: 12px; padding: 8px; cursor: pointer; transition: transform .08s ease, background .2s ease, border-color .2s ease; } .icon-btn:hover { background: #0d1526; border-color: #2a364d; } .icon-btn:active { transform: translateY(1px); } .controls { padding: 0 16px 12px 16px; display: grid; gap: 12px; } .control-group { display: flex; flex-direction: column; gap: 6px; } .control-label { color: var(--muted); font-size: 12px; } input[type="search"], .control-select { width: 100%; background: #0b1220; border: 1px solid var(--border); color: var(--text); border-radius: 12px; padding: 10px 12px; outline: none; transition: border-color .2s ease, box-shadow .2s ease; } input[type="search"]:focus, .control-select:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(79,70,229,.25); } .control-row { display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; } .switch { position: relative; display: inline-block; width: 48px; height: 28px; } .switch input { display:none; } .slider { position: absolute; cursor: pointer; top:0; left:0; right:0; bottom:0; background: #1f2937; transition: .2s; border-radius: 100px; border:1px solid var(--border); } .slider:before { position: absolute; content:""; height: 22px; width: 22px; left: 3px; top: 2px; background: #111827; transition: .2s; border-radius: 50%; } .switch input:checked + .slider { background: var(--primary); border-color: var(--primary); } .switch input:checked + .slider:before { transform: translateX(20px); } .app-main { padding: 16px; } .progress-section { margin-bottom: 12px; } .progress-header { display:flex; align-items:center; justify-content: space-between; margin-bottom: 8px; } .progress-bar { width: 100%; height: 10px; border-radius: 999px; background: #0b1220; border: 1px solid var(--border); overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--primary) 0%, var(--accent) 100%); width: 0%; transition: width .3s ease; } .hidden { display: none !important; } .logs-container { margin-top: 8px; background: #0b1220; border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; } .logs-container summary { cursor: pointer; color: var(--muted); } .logs { margin-top: 8px; max-height: 160px; overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; line-height: 1.4; } .log-entry { padding: 3px 0; border-bottom: 1px dashed #1a2335; } .log-entry:last-child { border-bottom: none; } .log-time { color: var(--muted); margin-right: 8px; } .empty-state { background: #0b1220; border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; text-align: center; color: var(--muted); } .apps-grid { display: grid; grid-template-columns: 1fr; gap: 12px; } @media (min-width: 560px) { .apps-grid { grid-template-columns: 1fr 1fr; } } @media (min-width: 920px) { .apps-grid { grid-template-columns: 1fr 1fr 1fr; } } .app-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); cursor: pointer; outline: none; transition: transform .15s ease, border-color .2s ease, background .2s ease; } .app-card:focus { border-color: var(--primary); } .app-card:hover { transform: translateY(-1px); background: #0c1528; } .card-top { display:flex; gap: 12px; padding: 12px; } .thumb { width: 64px; height: 64px; flex-shrink: 0; border-radius: 10px; overflow: hidden; background: #0b1220; border: 1px solid var(--border); display:flex; align-items:center; justify-content:center; } .thumb-img { width: 100%; height: 100%; object-fit: cover; } .card-main { flex: 1; min-width: 0; } .app-name { margin: 0 0 6px 0; font-size: 16px; line-height: 1.2; } .app-desc { margin: 0 0 8px 0; color: var(--muted); font-size: 13px; line-height: 1.3; max-height: 2.6em; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .app-meta { display:flex; flex-wrap: wrap; gap: 6px; } .meta-pill { font-size: 11px; color: #cbd5e1; background: #0d1526; border: 1px solid #1a2335; padding: 3px 8px; border-radius: 999px; white-space: nowrap; } .card-fav { position: absolute; top: 8px; right: 8px; background: transparent; border: none; color: #64748b; cursor: pointer; padding: 6px; border-radius: 10px; transition: background .2s ease, color .2s ease; } .card-fav:hover { background: #0b1220; color: #eab308; } .card-fav.fav { color: #f59e0b; } .overlay { position: fixed; inset: 0; background: rgba(2,6,23,.6); backdrop-filter: blur(2px); opacity: 0; transition: opacity .2s ease; } .overlay.show { opacity: 1; } .detail-card { position: fixed; left: 0; right: 0; bottom: 0; background: var(--bg-elev); border-top-left-radius: 18px; border-top-right-radius: 18px; border: 1px solid var(--border); transform: translateY(105%); transition: transform .28s ease; box-shadow: var(--shadow); max-height: 86vh; display: flex; flex-direction: column; } .detail-card.show { transform: translateY(0); } .detail-header { display:flex; align-items:center; justify-content: space-between; padding: 14px 14px 0 14px; } .detail-title { margin: 0; font-size: 18px; } .detail-meta { padding: 8px 14px 0 14px; display:flex; gap: 6px; flex-wrap: wrap; } .detail-image-wrap { padding: 10px 14px 0 14px; } .detail-image-wrap img { width: 100%; height: auto; border-radius: 12px; border: 1px solid var(--border); } .detail-description { padding: 8px 14px 12px 14px; color: var(--text); font-size: 14px; line-height: 1.4; } .detail-actions { display:flex; gap: 10px; padding: 12px 14px 16px 14px; align-items: center; } .primary-btn, .secondary-btn { appearance: none; border: 1px solid transparent; border-radius: 12px; padding: 10px 14px; font-weight: 600; cursor: pointer; transition: filter .15s ease, transform .08s ease, background .2s ease, border-color .2s ease; } .primary-btn { background: var(--primary); color: white; } .primary-btn:hover { background: var(--primary-700); } .secondary-btn { background: #0b1220; border-color: var(--border); color: var(--text); } .secondary-btn:hover { border-color: #2a364d; background: #0d1526; } .link-btn { background: transparent; color: var(--accent); border: none; cursor: pointer; padding: 0; } .close-btn { background: transparent; border: none; color: var(--muted); cursor: pointer; padding: 8px; border-radius: 8px; } .close-btn:hover { background: #0b1220; color: var(--text); } .app-footer { border-top: 1px solid var(--border); padding: 16px; text-align: center; color: var(--muted); } .app-footer a { color: var(--accent); text-decoration: none; } .footer-note { font-size: 12px; margin-top: 6px; } .app-card .card-top { position: relative; } .app-card.favorite { border-color: #a16207; box-shadow: 0 6px 30px rgba(245, 158, 11, .12); } .card-fav svg path { fill: currentColor; } /* Toast */ .toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: #0b1220; color: var(--text); border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; box-shadow: var(--shadow); opacity: 0; pointer-events: none; transition: opacity .2s ease, transform .2s ease; } .toast.show { opacity: 1; transform: translateX(-50%) translateY(-6px); } /* Utilities */ .meta-pill.small { font-size: 10px; padding: 2px 6px; } .row-inline { display: inline-flex; align-items: center; gap: 8px; } === assets/js/app.js === /** * Puter Apps Feed * Mobile-first web app that fetches apps from https://puter.com/sitemap.xml, * stores them locally, shows live progress, supports favorites, filters, sorting, * and expands app cards with details and actions. */ const CONFIG = { sitemapUrl: 'https://puter.com/sitemap.xml', siteBase: 'https://puter.com', corsProxy: (url) => `https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`, concurrency: 6, requestTimeoutMs: 20000, logToDom: true, }; const STORAGE_KEYS = { apps: 'puter.apps', favs: 'puter.favorites', state: 'puter.state', logs: 'puter.logs', }; const dom = { appsContainer: document.getElementById('appsContainer'), emptyState: document.getElementById('emptyState'), progressSection: document.getElementById('progressSection'), progressText: document.getElementById('progressText'), progressFill: document.getElementById('progressFill'), logsContainer: document.getElementById('logsContainer'), categoryFilter: document.getElementById('categoryFilter'), sortSelect: document.getElementById('sortSelect'), favoritesOnly: document.getElementById('favoritesOnly'), refreshBtn: document.getElementById('refreshBtn'), searchInput: document.getElementById('searchInput'), cancelFetchBtn: document.getElementById('cancelFetchBtn'), detailCard: document.getElementById('appDetailCard'), detailOverlay: document.getElementById('detailOverlay'), detailTitle: document.getElementById('detailTitle'), detailDesc: document.getElementById('detailDesc'), detailCategory: document.getElementById('detailCategory'), detailUsers: document.getElementById('detailUsers'), detailModified: document.getElementById('detailModified'), detailImage: document.getElementById('detailImage'), detailCloseBtn: document.getElementById('detailCloseBtn'), openAppBtn: document.getElementById('openAppBtn'), copyAppBtn: document.getElementById('copyAppBtn'), favoriteBtn: document.getElementById('favoriteBtn'), favoriteIcon: document.getElementById('favoriteIcon'), cardTemplate: document.getElementById('appCardTemplate'), }; const state = { appsMap: new Map(), // id -> app categories: new Set(), favorites: new Set(), isFetching: false, abortController: null, fetchStartTs: 0, lastFetchError: null, lastFetchedCount: 0, filters: { search: '', category: '__all__', sort: 'newest', favoritesOnly: false, } }; function log(level, message, data) { const ts = new Date().toISOString(); const entry = { ts, level, message, data: data ?? null }; const logs = getStorage(STORAGE_KEYS.logs, []); logs.push(entry); setStorage(STORAGE_KEYS.logs, logs.slice(-1000)); const pretty = `[${ts}] ${level.toUpperCase()}: ${message}`; console[level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'](pretty, data ?? ''); if (CONFIG.logToDom) { const el = document.createElement('div'); el.className = 'log-entry'; el.innerHTML = `${ts.split('T')[1].replace('Z','')} ${escapeHtml(message)}`; dom.logsContainer?.appendChild(el); dom.logsContainer?.scrollTo({ top: dom.logsContainer.scrollHeight, behavior: 'smooth' }); } } function info(m, d) { log('info', m, d); } function warn(m, d) { log('warn', m, d); } function error(m, d) { log('error', m, d); } function getStorage(key, fallback) { try { const v = localStorage.getItem(key); if (!v) return fallback; return JSON.parse(v); } catch (e) { warn('Storage read failed', e); return fallback; } } function setStorage(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { warn('Storage write failed', e); } } function escapeHtml(str) { return String(str).replace(/[&<>"']/g, s => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[s])); } function debounce(fn, ms=250) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } // App store class AppStore { constructor() { this.appsMap = new Map(); this.favorites = new Set(); this.load(); } load() { const appsArr = getStorage(STORAGE_KEYS.apps, []); this.appsMap = new Map(appsArr.map(a => [a.id, a])); const favsArr = getStorage(STORAGE_KEYS.favs, []); this.favorites = new Set(favsArr); info(`Loaded ${this.appsMap.size} apps and ${this.favorites.size} favorites from storage`); } persist() { setStorage(STORAGE_KEYS.apps, Array.from(this.appsMap.values())); setStorage(STORAGE_KEYS.favs, Array.from(this.favorites)); } upsertApps(apps) { let added = 0, updated = 0; for (const app of apps) { const existing = this.appsMap.get(app.id); if (existing) { this.appsMap.set(app.id, { ...existing, ...app }); updated++; } else { this.appsMap.set(app.id, app); added++; } } if (added || updated) { this.persist(); info(`Saved ${added} added / ${updated} updated apps`); } } setFavorite(id, isFav) { if (isFav) this.favorites.add(id); else this.favorites.delete(id); this.persist(); } isFavorite(id) { return this.favorites.has(id); } list() { return Array.from(this.appsMap.values()); } } const store = new AppStore(); function parseXmlToJson(xmlText) { const parser = new DOMParser(); const xml = parser.parseFromString(xmlText, "text/xml"); const errorNode = xml.querySelector("parsererror"); if (errorNode) throw new Error("Invalid XML"); return xml; } function extractAppLinks(sitemapXml) { // For puter.com, app URLs likely under /apps/* const urls = Array.from(sitemapXml.querySelectorAll('urlset url > loc')) .map(n => n.textContent.trim()) .filter(loc => /\/apps?\//i.test(loc)); // unique const unique = Array.from(new Set(urls)); return unique; } async function fetchTextWithProxy(url, signal) { const proxied = CONFIG.corsProxy(url); const res = await fetch(proxied, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.text(); } function toAbsoluteUrl(maybeUrl, base) { try { return new URL(maybeUrl, base).toString(); } catch { return maybeUrl; } } function parseOgImage(doc, base) { const og = doc.querySelector('meta[property="og:image"], meta[name="og:image"]'); if (og && og.content) return toAbsoluteUrl(og.content, base); // fallback to first large image const img = doc.querySelector('img[src]'); if (img) return toAbsoluteUrl(img.getAttribute('src'), base); return null; } function textContent(selector) { return (doc, base) => { const el = doc.querySelector(selector); if (el && el.textContent) return el.textContent.trim(); const meta = doc.querySelector(`meta[property="${selector}"], meta[name="${selector}"]`); if (meta && meta.content) return meta.content.trim(); return ''; }; } const getTitle = (() => { const fn = textContent('og:title'); return (doc, base) => { let t = fn(doc, base); if (!t) { const h1 = doc.querySelector('h1'); t = h1 ? h1.textContent.trim() : ''; } return t; }; })(); const getDescription = (() => { const fn = textContent('og:description'); return (doc, base) => { let d = fn(doc, base); if (!d) { const p = doc.querySelector('p'); d = p ? p.textContent.trim() : ''; } return d; }; })(); function parseUsersFromDoc(doc) { // Try several selectors/patterns const candidates = []; doc.querySelectorAll('[class*="user"], [id*="user"]').forEach(el => { const t = el.textContent.trim(); if (/\d/.test(t) && /user/i.test(el.className + ' ' + el.id)) candidates.push(t); }); if (candidates.length) return candidates[0]; // Fallback to any number + user const re = /([\d,.]+)\s*(users?|使用|用户)/i; const bodyText = doc.body ? doc.body.textContent : ''; const m = bodyText.match(re); return m ? m[0] : ''; } function extractCategories(appPath) { // From path: /apps/word-processor => category "word-processor" // From /apps/sub-dir/app => sub-dir const parts = appPath.split('/').filter(Boolean); const idx = parts.findIndex(p => p === 'apps' || p === 'app'); if (idx >= 0 && parts.length > idx + 1) { return [parts[idx + 1].toLowerCase()]; } return []; } function formatDate(iso) { if (!iso) return ''; try { const d = new Date(iso); return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); } catch { return ''; } } function classifyAppFromUrl(url) { try { const u = new URL(url); const parts = u.pathname.split('/').filter(Boolean); const idx = parts.findIndex(p => p === 'apps' || p === 'app'); if (idx >= 0 && parts.length > idx + 1) { return { id: url, name: '', description: '', url, image: '', totalUsers: '', modified: '', categories: [parts[idx + 1].toLowerCase()], fetchedAt: new Date().toISOString() }; } } catch {} return null; } async function fetchAppDetails(url, signal) { const html = await fetchTextWithProxy(url, signal); const doc = parseXmlToJson(html); const image = parseOgImage(doc, url); const title = getTitle(doc, url); const desc = getDescription(doc, url); const users = parseUsersFromDoc(doc); return { image, title, description: desc, totalUsers: users }; } function setProgress(pct, text) { dom.progressFill.style.width = `${Math.max(0, Math.min(100, pct))}%`; if (text) dom.progressText.textContent = text; } function toast(msg, timeout = 2200) { const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg; document.body.appendChild(t); requestAnimationFrame(() => t.classList.add('show')); setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 200); }, timeout); } // Rendering function getFilteredSortedApps() { const apps = store.list(); const { search, category, sort, favoritesOnly } = state.filters; let result = apps.filter(a => { if (favoritesOnly && !store.isFavorite(a.id)) return false; if (category !== '__all__' && !(a.categories || []).includes(category)) return false; if (search) { const hay = `${a.name} ${a.description} ${(a.categories || []).join(' ')}`.toLowerCase(); if (!hay.includes(search.toLowerCase())) return false; } return true; }); switch (sort) { case 'oldest': result.sort((a, b) => new Date(a.modified || a.fetchedAt) - new Date(b.modified || b.fetchedAt)); break; case 'name': result.sort((a, b) => a.name.localeCompare(b.name)); break; case 'users': result.sort((a, b) => (b.totalUsers || '').localeCompare(a.totalUsers || '')); break; case 'newest': default: result.sort((a, b) => new Date(b.modified || b.fetchedAt) - new Date(a.modified || a.fetchedAt)); } return result; } function renderCategories() { const apps = store.list(); const catSet = new Set(); apps.forEach(a => (a.categories || []).forEach(c => catSet.add(c))); const cats = Array.from(catSet).sort(); dom.categoryFilter.innerHTML = '' + cats.map(c => ``).join(''); // Keep current selection if exists dom.categoryFilter.value = state.filters.category; } function renderApps() { const apps = getFilteredSortedApps(); dom.appsContainer.innerHTML = ''; dom.emptyState.classList.toggle('hidden', apps.length > 0); for (const app of apps) { const node = dom.cardTemplate.content.firstElementChild.cloneNode(true); node.dataset.id = app.id; node.querySelector('.app-name').textContent = app.name || 'Untitled App'; node.querySelector('.app-desc').textContent = app.description || 'No description.'; node.querySelector('.thumb-img').src = app.image || ''; node.querySelector('.thumb-img').alt = `${app.name || 'App'} preview`; const metaEls = { category: node.querySelector('.meta-pill.category'), users: node.querySelector('.meta-pill.users'), modified: node.querySelector('.meta-pill.modified'), }; metaEls.category.textContent = (app.categories && app.categories[0]) ? app.categories[0] : 'General'; metaEls.users.textContent = app.totalUsers || ''; metaEls.modified.textContent = app.modified ? `Updated ${formatDate(app.modified)}` : ''; // Favorite const favBtn = node.querySelector('.card-fav'); const fav = store.isFavorite(app.id); favBtn.classList.toggle('fav', fav); if (fav) favBtn.setAttribute('aria-pressed', 'true'); favBtn.addEventListener('click', (e) => { e.stopPropagation(); const isFav = !store.isFavorite(app.id); store.setFavorite(app.id, isFav); favBtn.classList.toggle('fav', isFav); if (state.filters.favoritesOnly) renderApps(); }); // Click to open details node.addEventListener('click', () => openDetails(app.id)); node.addEventListener('keyup', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') openDetails(app.id); }); dom.appsContainer.appendChild(node); } } // Details panel let currentDetailId = null; function openDetails(id) { const app = store.appsMap.get(id); if (!app) return; currentDetailId = id; dom.detailTitle.textContent = app.name || 'Untitled App'; dom.detailDesc.textContent = app.description || 'No description available.'; dom.detailCategory.textContent = (app.categories && app.categories[0]) ? app.categories[0] : 'General'; dom.detailUsers.textContent = app.totalUsers || ''; dom.detailModified.textContent = app.modified ? `Updated ${formatDate(app.modified)}` : ''; dom.detailImage.src = app.image || ''; dom.detailImage.alt = `${app.name || 'App'} preview`; dom.detailImage.onerror = () => { dom.detailImage.style.display = 'none'; }; const isFav = store.isFavorite(app.id); dom.favoriteBtn.setAttribute('aria-pressed', isFav ? 'true' : 'false'); dom.favoriteIcon.style.color = isFav ? '#f59e0b' : '#64748b'; dom.detailOverlay.classList.remove('hidden'); dom.detailCard.classList.remove('hidden'); // Force reflow then show requestAnimationFrame(() => { dom.detailOverlay.classList.add('show'); dom.detailCard.classList.add('show'); }); // Actions dom.openAppBtn.onclick = () => window.open(app.url, '_blank', 'noopener'); dom.copyAppBtn.onclick = async () => { try { await navigator.clipboard.writeText(app.url); toast('App address copied'); } catch { toast('Copy failed'); } }; dom.favoriteBtn.onclick = () => { const nowFav = !store.isFavorite(app.id); store.setFavorite(app.id, nowFav); dom.favoriteIcon.style.color = nowFav ? '#f59e0b' : '#64748b'; // update card favorite state const card = dom.appsContainer.querySelector(`[data-id="${CSS.escape(app.id)}"]`); if (card) { const favBtn = card.querySelector('.card-fav'); favBtn.classList.toggle('fav', nowFav); } if (state.filters.favoritesOnly) renderApps(); }; } function closeDetails() { currentDetailId = null; dom.detailOverlay.classList.remove('show'); dom.detailCard.classList.remove('show'); setTimeout(() => { dom.detailOverlay.classList.add('hidden'); dom.detailCard.classList.add('hidden'); }, 250); } dom.detailCloseBtn.addEventListener('click', closeDetails); dom.detailOverlay.addEventListener('click', closeDetails); document.addEventListener('keyup', (e) => { if (e.key === 'Escape' && !dom.detailCard.classList.contains('hidden')) closeDetails(); }); // UI bindings dom.refreshBtn.addEventListener('click', () => fetchAllApps(true)); dom.cancelFetchBtn.addEventListener('click', () => { if (state.abortController) { state.abortController.abort(); state.abortController = null; info('Fetch cancelled by user'); } }); dom.categoryFilter.addEventListener('change', () => { state.filters.category = dom.categoryFilter.value; renderApps(); }); dom.sortSelect.addEventListener('change', () => { state.filters.sort = dom.sortSelect.value; renderApps(); }); dom.favoritesOnly.addEventListener('change', () => { state.filters.favoritesOnly = dom.favoritesOnly.checked; renderApps(); }); dom.searchInput.addEventListener('input', debounce((e) => { state.filters.search = e.target.value; renderApps(); }, 200)); // Fetch logic async function fetchAllApps(force = false) { if (state.isFetching) return; state.isFetching = true; state.lastFetchError = null; state.fetchStartTs = Date.now(); state.abortController = new AbortController(); const { signal } = state.abortController; dom.progressSection.classList.remove('hidden'); setProgress(0, 'Fetching sitemap...'); dom.logsContainer.innerHTML = ''; info('Starting fetchAllApps', { force }); try { // 1) Sitemap XML info('Fetching sitemap', CONFIG.sitemapUrl); const sitemapText = await fetchTextWithProxy(CONFIG.sitemapUrl, signal); const sitemapXml = parseXmlToJson(sitemapText); const appUrls = extractAppLinks(sitemapXml); info(`Found ${appUrls.length} app URLs in sitemap`); if (!appUrls.length) throw new Error('No app URLs found in sitemap'); // 2) Create base app entries (local classification) const baseApps = appUrls .map(classifyAppFromUrl) .filter(Boolean); // Save immediately so UI can show something if details fail store.upsertApps(baseApps); renderCategories(); renderApps(); // 3) Fetch details with concurrency control let completed = 0; const total = appUrls.length; const queue = [...appUrls]; const next = async () => { if (!queue.length) return; const url = queue.shift(); try { const details = await fetchAppDetails(url, signal); // map to app id const id = url; const existing = store.appsMap.get(id) || {}; const updated = { ...existing, ...details, fetchedAt: new Date().toISOString(), }; store.upsertApps([updated]); // Live update UI item by item renderApps(); } catch (e) { warn(`Failed to fetch details for ${url}`, e); } finally { completed++; const pct = Math.round((completed / total) * 100); setProgress(pct, `Fetching details ${completed}/${total}`); } }; const workers = Array.from({ length: CONFIG.concurrency }, () => next()); await Promise.all(workers); const tookMs = Date.now() - state.fetchStartTs; info(`Fetch complete. ${store.appsMap.size} apps stored. Took ${tookMs} ms`); setProgress(100, 'Done'); toast('Apps updated'); } catch (e) { if (e.name === 'AbortError') { warn('Fetch aborted'); toast('Fetch cancelled'); } else { error('Fetch failed', e); state.lastFetchError = e; toast('Some apps may not be up to date'); } } finally { state.isFetching = false; state.abortController = null; // Hide progress after a short delay setTimeout(() => dom.progressSection.classList.add('hidden'), 600); renderCategories(); renderApps(); } } // Init function init() { // Load favorites into state state.favorites = store.favorites; // Restore filters const savedState = getStorage(STORAGE_KEYS.state, null); if (savedState) { state.filters = { ...state.filters, ...savedState.filters }; dom.sortSelect.value = state.filters.sort; dom.favoritesOnly.checked = state.filters.favoritesOnly; dom.searchInput.value = state.filters.search; } renderCategories(); renderApps(); // Save filter state on change const saveState = debounce(() => { setStorage(STORAGE_KEYS.state, { filters: state.filters }); }, 200); ['change', 'input'].forEach(evt => { dom.categoryFilter.addEventListener(evt, saveState); dom.sortSelect.addEventListener(evt, saveState); dom.favoritesOnly.addEventListener(evt, saveState); dom.searchInput.addEventListener(evt, saveState); }); // Auto fetch on first load fetchAllApps(false); } init(); // Expose for debugging window._PuterApps = { store, state, fetchAllApps }; /** * Notes: * - Cross-origin HTML fetching is performed via CORS proxy (AllOrigins). * - If fetching fails, previously stored apps are shown. * - App "description" and preview "image" are extracted from the app page via meta tags and heuristics. * - All data is persisted locally in localStorage. */