const PageMarket = { _el: null, _marketToken: null, _tab: 'dashboards', init() { this._el = document.getElementById('page-market'); this._marketToken = localStorage.getItem('turbosu-market-token'); }, async render() { this._el.innerHTML = this._template(); this._bindEvents(); this._updateUI(); this._loadItems(); }, async _loadItems() { const grid = document.getElementById('market-grid'); if (!grid) return; grid.style.display = 'grid'; grid.innerHTML = '
' + Lang.t('loading') + '
'; if (this._tab === 'mine') { if (!this._marketToken) { grid.innerHTML = this._emptyHtml(Lang.t('needLogin')); return; } return this._loadMine(grid); } const isScene = this._tab === 'scenes'; const endpoint = isScene ? '/api/market/scenes' : '/api/market/dashboards'; try { const res = await fetch(endpoint); const data = await res.json(); const items = data.items || []; let localIds = []; try { const lr = await fetch('/api/dashboards?category=all'); const ld = await lr.json(); if (Array.isArray(ld)) localIds = ld.map(d => d.id); if (isScene) { const sr = await fetch('/api/scenes'); const sl = await sr.json(); if (Array.isArray(sl)) localIds = localIds.concat(sl.map(s => s.id)); } } catch(e) { console.error('load local:', e); } if (!items.length) { grid.innerHTML = this._emptyHtml(isScene ? Lang.t('noScenesMarket') : Lang.t('noDashboardsMarket')); return; } grid.innerHTML = items.map(d => { const installed = d.manifest_id && localIds.includes(d.manifest_id); return this._cardHtml(d, isScene, installed); }).join(''); } catch (e) { console.error(e); grid.innerHTML = '
' + Lang.t('loadFailed') + (e.message?': '+e.message:'') + ' ' + Lang.t('retry') + '
'; } }, async _loadMine(grid) { grid.style.display = 'block'; try { const res = await fetch('/api/market/mine?token=' + encodeURIComponent(this._marketToken)); const data = await res.json(); if (!data.dashboards?.length && !data.scenes?.length) { grid.innerHTML = this._emptyHtml(Lang.t('noUploads')); return; } let html = ''; if (data.dashboards?.length) { html += '

' + Lang.t('myDashboards') + '

' + data.dashboards.map(d => this._myCardHtml(d, 'dashboards')).join(''); } if (data.scenes?.length) { html += '

' + Lang.t('myScenes') + '

' + data.scenes.map(d => this._myCardHtml(d, 'scenes')).join(''); } grid.innerHTML = html; } catch (e) { console.error(e); grid.innerHTML = '
' + Lang.t('loadFailed') + (e.message?': '+e.message:'') + ' ' + Lang.t('retry') + '
'; } }, _cardHtml(d, isScene, installed) { const icon = d.preview ? '' : (isScene ? '🎬' : (d.icon || '📦')); const previewUrl = d.preview ? `https://turbosu.yeij.top/api/files/${isScene?'scenes':'dashboards'}/${d.id}/${d.preview}` : ''; const bgStyle = previewUrl ? `background-image:url(${previewUrl});background-size:cover;background-position:center` : ''; const label = installed ? Lang.t('installed') : (isScene ? Lang.t('installScene') : Lang.t('install')); const btnClass = isScene ? 'btn-m-install-scene' : 'btn-m-install'; const uninstallBtn = installed ? `' : ''; return `
${icon}
${d.name||'Unnamed'}
${isScene ? (d.game_id||'') : (d.category||'')}${isScene ? '' : 'v'+(d.version||'1.0')}
${d.author||''}${Lang.t('downloadCountShort').replace('%d', d.downloads||0)}
${uninstallBtn}
`; }, _myCardHtml(d, collection) { const pending = d.dashb_pending ? '' + Lang.t('pending') + '' : '' + Lang.t('approved') + ''; const created = d.created ? new Date(d.created).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '-'; const updated = d.updated ? new Date(d.updated).toLocaleString('zh-CN', {month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}) : created; return `
${d.name||'Unnamed'} v${d.version||'1.0'}
${d.author||''} · ${Lang.t('downloadCount').replace('%d', d.downloads||0)} · ${pending}
上传: ${created}${updated!==created?' · 更新: '+updated:''}
`; }, _bindEvents() { document.getElementById('btn-refresh-market')?.addEventListener('click', () => this._loadItems()); document.getElementById('btn-m-upload')?.addEventListener('click', () => this._upload()); document.getElementById('market-grid')?.addEventListener('click', async (e) => { const btnInstall = e.target.closest('.btn-m-install'); const btnScene = e.target.closest('.btn-m-install-scene'); const btnUpdate = e.target.closest('.btn-my-update'); const btnDelete = e.target.closest('.btn-my-delete'); if (e.target.closest('.btn-m-uninstall')) { const ubtn = e.target.closest('.btn-m-uninstall'); const uname = ubtn.dataset.name||''; if (!confirm(Lang.t('confirmUninstall').replace('"$1"', uname))) return; try { const r = await fetch('/api/dashboards?category=all'); const list = (await r.json()) || []; const dash = list.find(d => d.id === uname || d.name === uname); if (dash) { await fetch('/api/dashboards/' + dash.id, { method: 'DELETE' }); Toast.show(Lang.t('uninstallSuccess'), 'success'); this._loadItems(); } else { Toast.show(Lang.t('uninstallNotFound'), 'error'); } } catch (e) { Toast.show(Lang.t('uninstallFailed'), 'error'); } return; } if (btnUpdate) { this._showUpdateModal(btnUpdate.dataset); return; } if (btnDelete) { if (!confirm(Lang.t('confirmDelete'))) return; try { await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: btnDelete.dataset.id, collection: btnDelete.dataset.coll, token: this._marketToken}) }); Toast.show(Lang.t('deleted'), 'info'); this._loadItems(); } catch (e) { Toast.show(Lang.t('deleteFailed'), 'error'); } return; } const id = (btnInstall || btnScene)?.dataset.id; const file = (btnInstall || btnScene)?.dataset.file; if (!id || !file) { Toast.show(Lang.t('installDataIncomplete'), 'error'); return; } const isScene = !!btnScene; const url = isScene ? `/api/market/scenes/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}` : `/api/market/install?id=${encodeURIComponent(id)}&filename=${encodeURIComponent(file)}`; try { const res = await fetch(url); const data = await res.json(); if (data.ok) { Toast.show(Lang.t('installedHint'), 'success', 4000); this._loadItems(); } else { Toast.show(Lang.t('installFailedMsg') + (data.error||''), 'error'); } } catch (e) { Toast.show(Lang.t('installNetworkError'), 'error'); } }); document.getElementById('tab-dashboards')?.addEventListener('click', () => { this._tab = 'dashboards'; document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px'; document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px'; document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px'; this._loadItems(); }); document.getElementById('tab-scenes')?.addEventListener('click', () => { this._tab = 'scenes'; document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px'; document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px'; document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px'; this._loadItems(); }); document.getElementById('tab-mine')?.addEventListener('click', () => { this._tab = 'mine'; document.getElementById('tab-mine').style.cssText = 'border-radius:0;border-bottom:2px solid var(--accent);color:var(--accent);background:transparent;padding:8px 16px'; document.getElementById('tab-dashboards').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px'; document.getElementById('tab-scenes').style.cssText = 'border-radius:0;border-bottom:2px solid transparent;color:var(--text-secondary);background:transparent;padding:8px 16px'; this._loadItems(); }); }, _updateUI() { const loginBtn = document.getElementById('btn-m-login'); const uploadBtn = document.getElementById('btn-m-upload'); const statusEl = document.getElementById('m-status'); if (!loginBtn || !uploadBtn) return; if (this._marketToken) { loginBtn.textContent = Lang.t('loggedIn'); loginBtn.className = 'btn btn-sm btn-secondary'; uploadBtn.style.display = ''; if (statusEl) statusEl.textContent = ' ' + Lang.t('loggedIn'); } else { loginBtn.textContent = Lang.t('login'); loginBtn.className = 'btn btn-sm btn-primary'; uploadBtn.style.display = 'none'; if (statusEl) statusEl.textContent = ' ' + Lang.t('notLoggedIn'); } }, _upload() { if (!this._marketToken) { Toast.show(Lang.t('needLogin'), 'error'); return; } const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); let previewFile = null; const previewDrop = document.createElement('div'); previewDrop.id = 'pub-preview-drop'; previewDrop.style.cssText = 'display:none;border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin-top:8px;transition:all .2s'; previewDrop.textContent = Lang.t('dropPreview'); overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-file-btn')); function pickPreview() { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none'; inp.onchange = function() { const f = this.files[0]; if (!f) return; if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; } if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; } previewFile = f; previewDrop.textContent = '\u2713 ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)'; previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)'; }; document.body.appendChild(inp); inp.click(); } ['dragenter','dragover'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--accent)'; })); ['dragleave','drop'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--border-normal)'; })); previewDrop.addEventListener('drop', function(e) { e.preventDefault(); const f = e.dataTransfer.files[0]; if (!f) return; if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; } if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; } previewFile = f; previewDrop.textContent = '\u2713 ' + f.name + ' (' + (f.size/1024/1024).toFixed(1) + ' MB)'; previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)'; }); previewDrop.addEventListener('click', pickPreview); overlay.querySelector('#pub-preview-toggle').addEventListener('change', function() { if (this.checked) { previewDrop.style.display = ''; } else { previewFile = null; previewDrop.style.display = 'none'; previewDrop.textContent = Lang.t('dropPreview'); previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)'; } }); overlay.querySelector('#pub-file-btn').addEventListener('click', () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.tsd,.tss'; input.onchange = async (e) => { const file = e.target.files[0]; if (!file) return; const isTss = file.name.endsWith('.tss'); const fd = new FormData(); fd.append('file', file); fd.append('token', this._marketToken); if (previewFile) fd.append('preview', previewFile); const res = await fetch(isTss ? '/api/market/scenes/upload' : '/api/market/upload', { method: 'POST', body: fd }); const data = await res.json(); Toast.show(data.error ? Lang.t('publishFailed') + ': '+data.error : Lang.t('publishSuccess'), data.error ? 'error' : 'success'); if (!data.error) { overlay.remove(); this._loadItems(); } }; input.click(); }); this._loadLocalItems(overlay); }, async _loadLocalItems(overlay) { const list = overlay.querySelector('#pub-local-list'); try { const dashRes = await fetch('/api/dashboards?category=all'); const dashes = (await dashRes.json()) || []; const sceneRes = await fetch('/api/scenes'); const scenes = (await sceneRes.json()) || []; const upD = dashes.filter(d => !d.is_builtin && !(d.id||'').match(/^[a-f0-9]{6}__/)); const upS = Array.isArray(scenes) ? scenes.filter(s => !s.is_builtin && !(s.id||'').match(/^[a-f0-9]{6}__/)) : []; let h = ''; if (upD.length) { h += '
' + Lang.t('localDashboards') + ' ('+upD.length+')
'; h += '
' + upD.map(d => '
'+(d.icon||'📊')+' '+d.name+'
').join('') + '
'; } if (upS.length) { h += '
' + Lang.t('localScenes') + ' ('+upS.length+')
'; h += '
' + upS.map(s => '
🎬 '+s.name+'
').join('') + '
'; } list.innerHTML = h || '

' + Lang.t('noPublishable') + '

'; list.querySelectorAll('.pub-dash-btn').forEach(btn => btn.addEventListener('click', async () => { const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'dashboard',token:this._marketToken})}); const d = await r.json(); Toast.show(d.error?Lang.t('publishFailed')+': '+d.error:Lang.t('publishSuccess'),d.error?'error':'success'); if(!d.error){overlay.remove();this._loadItems();} })); list.querySelectorAll('.pub-scene-btn').forEach(btn => btn.addEventListener('click', async () => { const r = await fetch('/api/market/publish-local', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:btn.dataset.id,type:'scene',token:this._marketToken})}); const d = await r.json(); Toast.show(d.error?Lang.t('publishFailed')+': '+d.error:Lang.t('publishSuccess'),d.error?'error':'success'); if(!d.error){overlay.remove();this._loadItems();} })); } catch(e) { list.innerHTML = '

' + Lang.t('loadFailed') + '

'; } }, _showLogin() { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; const isLogin = !this._marketToken; overlay.innerHTML = ``; document.body.appendChild(overlay); if (this._marketToken) { overlay.querySelector('#ml-logout').addEventListener('click', () => { this._marketToken = null; localStorage.removeItem('turbosu-market-token'); overlay.remove(); this._updateUI(); Toast.show(Lang.t('loggedOut'), 'info'); }); } let mode = isLogin ? 'login' : 'register'; const tabLogin = overlay.querySelector('#ml-tab-login'); const tabRegister = overlay.querySelector('#ml-tab-register'); const nameGroup = overlay.querySelector('#ml-name-group'); const submitBtn = overlay.querySelector('#ml-submit'); function setTab(t) { mode = t; const isL = t === 'login'; tabLogin.style.cssText = `border-radius:0;border-bottom:2px solid ${isL?'var(--accent)':'transparent'};color:${isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`; tabRegister.style.cssText = `border-radius:0;border-bottom:2px solid ${!isL?'var(--accent)':'transparent'};color:${!isL?'var(--accent)':'var(--text-secondary)'};background:transparent;padding:8px 16px`; nameGroup.style.display = isL ? 'none' : ''; submitBtn.textContent = isL ? Lang.t('login') : Lang.t('register'); } tabLogin.addEventListener('click', () => setTab('login')); tabRegister.addEventListener('click', () => setTab('register')); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); const doAuth = async () => { const email = document.getElementById('ml-email').value; const pass = document.getElementById('ml-pass').value; if (!email || !pass) { Toast.show(Lang.t('enterEmailPassword'), 'error'); return; } if (mode === 'login') { const res = await fetch('/api/market/auth', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass}) }); const data = await res.json(); if (data.token) { this._marketToken = data.token; localStorage.setItem('turbosu-market-token', data.token); Toast.show(Lang.t('loginSuccess'), 'success'); overlay.remove(); this._updateUI(); } else { Toast.show(data.message || Lang.t('loginFailed'), 'error'); } } else { const name = document.getElementById('ml-name')?.value || ''; const res = await fetch('/api/market/register', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({email, password: pass, passwordConfirm: pass, name}) }); const data = await res.json(); Toast.show(data.error ? Lang.t('registerFailed') + ': ' + data.error : Lang.t('registerSuccess'), data.error ? 'error' : 'success', data.error ? 3000 : 0); if (!data.error) overlay.remove(); } }; overlay.querySelector('#ml-submit').addEventListener('click', doAuth); overlay.querySelector('#ml-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') doAuth(); }); }, _emptyHtml(msg) { return `

${msg||Lang.t('marketNoItems')}

`; }, _showUpdateModal(ds) { if (!this._marketToken) { Toast.show(Lang.t('needLogin'), 'error'); return; } const isScene = ds.coll === 'scenes'; const typeLabel = isScene ? Lang.t('scene') : Lang.t('dashboard'); const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ''; document.body.appendChild(overlay); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); let updateFile = null, previewFile = null; const previewDrop = document.createElement('div'); previewDrop.style.cssText = 'border:2px dashed var(--border-normal);border-radius:8px;padding:20px;text-align:center;color:var(--text-tertiary);font-size:13px;cursor:pointer;margin:8px 0;transition:all .2s'; previewDrop.textContent = Lang.t('dropPreview'); overlay.querySelector('.modal').insertBefore(previewDrop, overlay.querySelector('#pub-update-btn')); function pickPreview() { const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'image/*'; inp.style.display = 'none'; inp.onchange = function() { const f = this.files[0]; if (!f) return; if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; } if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; } previewFile = f; previewDrop.textContent = '\u2713 '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)'; previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)'; }; document.body.appendChild(inp); inp.click(); } ['dragenter','dragover'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--accent)'; })); ['dragleave','drop'].forEach(ev => previewDrop.addEventListener(ev, e => { e.preventDefault(); previewDrop.style.borderColor = 'var(--border-normal)'; })); previewDrop.addEventListener('drop', function(e) { e.preventDefault(); const f = e.dataTransfer.files[0]; if (!f) return; if (!f.type.startsWith('image/')) { Toast.show(Lang.t('imageOnly'), 'error'); return; } if (f.size > 6291456) { Toast.show(Lang.t('previewTooLarge'), 'error'); return; } previewFile = f; previewDrop.textContent = '\u2713 '+f.name+' ('+(f.size/1024/1024).toFixed(1)+' MB)'; previewDrop.style.borderColor = 'var(--accent)'; previewDrop.style.color = 'var(--accent)'; }); previewDrop.addEventListener('click', pickPreview); overlay.querySelector('#pub-preview-toggle').addEventListener('change', function() { previewDrop.style.display = this.checked ? '' : 'none'; if (!this.checked) { previewFile = null; previewDrop.textContent = Lang.t('dropPreview'); previewDrop.style.borderColor = 'var(--border-normal)'; previewDrop.style.color = 'var(--text-tertiary)'; } }); overlay.querySelector('#pub-update-btn').addEventListener('click', () => { const input = document.createElement('input'); input.type = 'file'; input.accept = isScene ? '.tss' : '.tsd'; input.onchange = function(e) { updateFile = e.target.files[0]; if (updateFile) { const btn = overlay.querySelector('#pub-update-btn'); btn.textContent = '\u2713 '+updateFile.name; btn.style.background = 'var(--accent)'; btn.style.color = '#fff'; } }; input.click(); }); overlay.querySelector('#pub-update-btn').insertAdjacentHTML('afterend', ''); overlay.querySelector('#pub-do-update').addEventListener('click', async () => { if (!updateFile && !previewFile) { Toast.show(Lang.t('selectFileOrPreview'), 'error'); return; } const fd = new FormData(); fd.append('token', this._marketToken); if (updateFile) fd.append('file', updateFile); if (previewFile) fd.append('preview', previewFile); // If no new preview given, fetch old one from record if (!previewFile && ds.preview) { try { const prevRes = await fetch('https://turbosu.yeij.top/api/files/'+ds.coll+'/'+ds.id+'/'+ds.preview); if (prevRes.ok) { const blob = await prevRes.blob(); fd.append('preview', blob, ds.preview); } } catch(e) {} } const url = isScene ? '/api/market/scenes/upload' : '/api/market/upload'; const res = await fetch(url, { method: 'POST', body: fd }); const data = await res.json(); if (!data.error && data.id) { // Delete old record await fetch('/api/market/delete', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({id: ds.id, collection: ds.coll, token: this._marketToken}) }); } Toast.show(data.error ? Lang.t('updateFailed') + data.error : Lang.t('updated'), data.error ? 'error' : 'success'); if (!data.error) { overlay.remove(); this._loadItems(); } }); }, _template() { return `

${Lang.t('marketPage')}

${Lang.t('marketDesc')} ${Lang.t('notLoggedIn')}

`; } }; window.PageMarket = PageMarket;