update UI and QOL

This commit is contained in:
HPL-JesusCastro
2026-06-03 10:36:02 +08:00
parent 5e34832234
commit f26bf647b1
22 changed files with 965 additions and 57 deletions

View File

@@ -3,6 +3,7 @@ import { isAuthenticated } from './api';
import Login from './pages/Login'; import Login from './pages/Login';
import Apps from './pages/Apps'; import Apps from './pages/Apps';
import Catalog from './pages/Catalog'; import Catalog from './pages/Catalog';
import Settings from './pages/Settings';
import Viewer from './pages/Viewer'; import Viewer from './pages/Viewer';
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
@@ -16,6 +17,7 @@ export default function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><Catalog /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><Catalog /></ProtectedRoute>} />
<Route path="/apps" element={<ProtectedRoute><Apps /></ProtectedRoute>} /> <Route path="/apps" element={<ProtectedRoute><Apps /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/viewer/:id" element={<ProtectedRoute><Viewer /></ProtectedRoute>} /> <Route path="/viewer/:id" element={<ProtectedRoute><Viewer /></ProtectedRoute>} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -92,6 +92,12 @@ export async function requestScreenshot(id) {
return res.json(); return res.json();
} }
export async function requestScreenshotCandidates(id) {
const res = await apiFetch(`${ADS_BASE}/${id}/screenshot-candidates`, { method: 'POST' });
if (!res.ok) throw new Error('Screenshot candidates failed');
return res.json();
}
export async function saveThumbnail(id, dataUrl) { export async function saveThumbnail(id, dataUrl) {
const res = await apiFetch(`${ADS_BASE}/${id}/thumbnail`, { const res = await apiFetch(`${ADS_BASE}/${id}/thumbnail`, {
method: 'POST', method: 'POST',
@@ -102,6 +108,29 @@ export async function saveThumbnail(id, dataUrl) {
return res.json(); return res.json();
} }
// ── developers ───────────────────────────────────────────────────────────────
export async function listDevelopers() {
const res = await apiFetch('/api/developers');
if (!res.ok) throw new Error('Failed to fetch developers');
return res.json();
}
export async function addDeveloper(name) {
const res = await apiFetch('/api/developers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error('Failed to add developer');
return res.json();
}
export async function removeDeveloper(id) {
const res = await apiFetch(`/api/developers/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to remove developer');
}
// ── apps ───────────────────────────────────────────────────────────────────── // ── apps ─────────────────────────────────────────────────────────────────────
export async function lookupApp(url) { export async function lookupApp(url) {

View File

@@ -14,7 +14,7 @@ export default function AdCard({ ad, app, onEdit, onDelete }) {
<div className="ad-card__thumb" onClick={() => navigate(`/viewer/${ad.id}`)}> <div className="ad-card__thumb" onClick={() => navigate(`/viewer/${ad.id}`)}>
{showThumb ? ( {showThumb ? (
<img <img
src={`/uploads/${ad.thumbnail}`} src={`/uploads/${ad.thumbnail}?t=${ad.updated_at}`}
alt={ad.title} alt={ad.title}
className="ad-card__thumb-img" className="ad-card__thumb-img"
onError={() => setThumbError(true)} onError={() => setThumbError(true)}

View File

@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import './DeviceFrame.css'; import './DeviceFrame.css';
// Screen resolution (inner display area) // Screen resolution (inner display area)
@@ -28,7 +29,65 @@ export function getFrameSize(frame, rotated) {
return { width: w + pl + pr, height: h + pt + pb }; return { width: w + pl + pr, height: h + pt + pb };
} }
export default function DeviceFrame({ frame = 'phone', filename, rotated = false, iframeRef, onIframeLoad }) { // Injected into the live iframe document to mute without reloading.
// Handles HTML5 audio/video and Web Audio API (including Emscripten/Unity's AL layer).
const MUTE_CODE = `(function(){
document.querySelectorAll("audio,video").forEach(function(e){e.muted=true;e.volume=0;});
var AC=window.AudioContext||window.webkitAudioContext;
if(AC){
if(!AC.prototype.__savedResume){AC.prototype.__savedResume=AC.prototype.resume;}
AC.prototype.resume=function(){return Promise.resolve();};
}
try{Object.keys(window).forEach(function(k){try{
var v=window[k];
if(v&&typeof v==="object"&&typeof v.suspend==="function"&&typeof v.state==="string"&&v.state==="running"){v.suspend();}
}catch(e){}});}catch(e){}
try{if(window.AL&&window.AL.currentCtx&&window.AL.currentCtx.ctx){window.AL.currentCtx.ctx.suspend();}}catch(e){}
})();`;
const UNMUTE_CODE = `(function(){
document.querySelectorAll("audio,video").forEach(function(e){e.muted=false;e.volume=1;});
var AC=window.AudioContext||window.webkitAudioContext;
if(AC&&AC.prototype.__savedResume){
AC.prototype.resume=AC.prototype.__savedResume;
delete AC.prototype.__savedResume;
}
try{Object.keys(window).forEach(function(k){try{
var v=window[k];
if(v&&typeof v==="object"&&typeof v.resume==="function"&&typeof v.state==="string"&&v.state==="suspended"){v.resume();}
}catch(e){}});}catch(e){}
try{if(window.AL&&window.AL.currentCtx&&window.AL.currentCtx.ctx){window.AL.currentCtx.ctx.resume();}}catch(e){}
})();`;
function injectIntoIframe(iframe, code) {
try {
const doc = iframe?.contentDocument;
if (!doc || doc.readyState === 'loading') return false;
const s = doc.createElement('script');
s.textContent = code;
(doc.head || doc.documentElement).appendChild(s);
s.remove();
return true;
} catch {
return false;
}
}
export default function DeviceFrame({ frame = 'phone', filename, rotated = false, muted = false, iframeRef: externalRef, onIframeLoad }) {
const innerRef = useRef();
const ref = externalRef ?? innerRef;
// When muted prop changes on an already-loaded iframe, inject immediately.
useEffect(() => {
injectIntoIframe(ref.current, muted ? MUTE_CODE : UNMUTE_CODE);
}, [muted]);
function handleLoad(e) {
// Re-apply mute state after any iframe reload (e.g. frame/rotation change).
if (muted) injectIntoIframe(e.target, MUTE_CODE);
onIframeLoad?.(e);
}
const o = rotated ? 'landscape' : 'portrait'; const o = rotated ? 'landscape' : 'portrait';
const { w: sw, h: sh } = SCREEN[frame][o]; const { w: sw, h: sh } = SCREEN[frame][o];
const [pt, pr, pb, pl] = BEZEL[frame][o]; const [pt, pr, pb, pl] = BEZEL[frame][o];
@@ -36,14 +95,12 @@ export default function DeviceFrame({ frame = 'phone', filename, rotated = false
const outerH = sh + pt + pb; const outerH = sh + pt + pb;
const isPhone = frame === 'phone'; const isPhone = frame === 'phone';
// Notch
const notchW = isPhone ? 90 : 14; const notchW = isPhone ? 90 : 14;
const notchH = isPhone ? 24 : 14; const notchH = isPhone ? 24 : 14;
const notchStyle = rotated const notchStyle = rotated
? { width: notchH, height: notchW, top: (outerH - notchW) / 2, left: (pl - notchH) / 2 } ? { width: notchH, height: notchW, top: (outerH - notchW) / 2, left: (pl - notchH) / 2 }
: { width: notchW, height: notchH, top: (pt - notchH) / 2, left: (outerW - notchW) / 2 }; : { width: notchW, height: notchH, top: (pt - notchH) / 2, left: (outerW - notchW) / 2 };
// Home bar (phone only)
const homeStyle = isPhone const homeStyle = isPhone
? rotated ? rotated
? { width: 6, height: 40, top: (outerH - 40) / 2, right: (pr - 6) / 2 } ? { width: 6, height: 40, top: (outerH - 40) / 2, right: (pr - 6) / 2 }
@@ -55,28 +112,21 @@ export default function DeviceFrame({ frame = 'phone', filename, rotated = false
className={`device-frame device-frame--${frame}`} className={`device-frame device-frame--${frame}`}
style={{ width: outerW, height: outerH }} style={{ width: outerW, height: outerH }}
> >
{/* Notch / camera dot */}
<div className="device-frame__notch" style={notchStyle} /> <div className="device-frame__notch" style={notchStyle} />
{/* Screen — exactly the device resolution.
Corner radius = outer bezel radius side bezel width, so the
screen edge follows the body curve instead of looking pasted on. */}
<div className="device-frame__screen" style={{ <div className="device-frame__screen" style={{
top: pt, left: pl, width: sw, height: sh, top: pt, left: pl, width: sw, height: sh,
borderRadius: isPhone ? 30 : 16, borderRadius: isPhone ? 30 : 16,
}}> }}>
<iframe <iframe
ref={iframeRef} ref={ref}
key={`${filename}-${frame}-${o}`} key={`${filename}-${frame}-${o}`}
src={`/uploads/${filename}`} src={`/uploads/${filename}`}
title="Playable ad" title="Playable ad"
sandbox="allow-scripts allow-same-origin allow-forms" sandbox="allow-scripts allow-same-origin allow-forms"
onLoad={onIframeLoad} onLoad={handleLoad}
style={{ width: sw, height: sh }} style={{ width: sw, height: sh }}
/> />
</div> </div>
{/* Home indicator */}
{homeStyle && <div className="device-frame__home" style={homeStyle} />} {homeStyle && <div className="device-frame__home" style={homeStyle} />}
</div> </div>
); );

View File

@@ -140,14 +140,31 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.meta-modal__replace-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.meta-modal__replace-label { .meta-modal__replace-label {
display: block;
font-size: 0.82rem; font-size: 0.82rem;
color: #999; color: #999;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
margin-bottom: 8px; }
.meta-modal__download {
font-size: 0.78rem;
color: #7c6af7;
text-decoration: none;
font-weight: 500;
transition: color 0.15s;
}
.meta-modal__download:hover {
color: #a090ff;
} }
.meta-modal__replace-row { .meta-modal__replace-row {

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import './MetadataModal.css'; import './MetadataModal.css';
export default function MetadataModal({ ad, apps = [], onSave, onClose }) { export default function MetadataModal({ ad, apps = [], developers = [], onSave, onClose }) {
const dialogRef = useRef(); const dialogRef = useRef();
const fileInputRef = useRef(); const fileInputRef = useRef();
const [title, setTitle] = useState(ad.title); const [title, setTitle] = useState(ad.title);
@@ -92,7 +92,10 @@ export default function MetadataModal({ ad, apps = [], onSave, onClose }) {
<label> <label>
Developer Developer
<input value={developer} onChange={e => setDeveloper(e.target.value)} placeholder="Developer or team name" /> <select value={developer} onChange={e => setDeveloper(e.target.value)} className="meta-modal__select">
<option value=""> select developer </option>
{developers.map(d => <option key={d.id} value={d.name}>{d.name}</option>)}
</select>
</label> </label>
<div className="meta-modal__row2"> <div className="meta-modal__row2">
@@ -112,7 +115,16 @@ export default function MetadataModal({ ad, apps = [], onSave, onClose }) {
</label> </label>
<div className="meta-modal__replace"> <div className="meta-modal__replace">
<div className="meta-modal__replace-header">
<span className="meta-modal__replace-label">Replace HTML</span> <span className="meta-modal__replace-label">Replace HTML</span>
<a
href={`/uploads/${ad.filename}`}
download={`${ad.title}.html`}
className="meta-modal__download"
>
Download current
</a>
</div>
<div className="meta-modal__replace-row"> <div className="meta-modal__replace-row">
<input <input
ref={fileInputRef} ref={fileInputRef}

View File

@@ -0,0 +1,105 @@
.spm {
background: #1e1e1e;
color: #e8e8e8;
border: 1px solid #333;
border-radius: 16px;
padding: 28px 32px;
width: 480px;
max-width: 94vw;
max-height: 90dvh;
overflow-y: auto;
box-shadow: 0 24px 80px rgba(0,0,0,0.7);
}
.spm::backdrop {
background: rgba(0,0,0,0.6);
backdrop-filter: blur(3px);
}
.spm__title {
margin: 0 0 6px;
font-size: 1.1rem;
font-weight: 700;
}
.spm__desc {
margin: 0 0 20px;
font-size: 0.85rem;
color: #777;
}
.spm__body {
min-height: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.spm__loading,
.spm__error {
font-size: 0.88rem;
color: #666;
}
.spm__error {
color: #f77;
}
.spm__candidates {
display: flex;
gap: 10px;
width: 100%;
}
.spm__candidate {
flex: 1;
aspect-ratio: 9 / 19.5;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
border: 2px solid #2e2e2e;
background: #000;
padding: 0;
position: relative;
transition: border-color 0.15s, transform 0.1s;
}
.spm__candidate:hover:not(:disabled) {
border-color: #555;
transform: scale(1.03);
}
.spm__candidate--selected {
border-color: #7c6af7;
}
.spm__candidate img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.spm__candidate-badge {
position: absolute;
top: 4px;
right: 4px;
background: #7c6af7;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.spm__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}

View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
import { requestScreenshotCandidates, saveThumbnail } from '../api';
import './ScreenshotPickerModal.css';
export default function ScreenshotPickerModal({ ad, onDone, onSkip }) {
const dialogRef = useRef();
const startedRef = useRef(false);
const [candidates, setCandidates] = useState([]);
const [status, setStatus] = useState('loading'); // loading | selecting | saving | error
const [selectedIdx, setSelectedIdx] = useState(null);
useEffect(() => {
dialogRef.current?.showModal();
return () => dialogRef.current?.close();
}, []);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
fetchCandidates();
}, []);
async function fetchCandidates() {
setStatus('loading');
setCandidates([]);
setSelectedIdx(null);
try {
const { candidates: c } = await requestScreenshotCandidates(ad.id);
setCandidates(c);
setStatus('selecting');
} catch {
setStatus('error');
}
}
async function handleSelect(dataUrl, idx) {
setSelectedIdx(idx);
setStatus('saving');
try {
const updated = await saveThumbnail(ad.id, dataUrl);
onDone(updated);
} catch {
setStatus('error');
}
}
return (
<dialog ref={dialogRef} className="spm" onClose={onSkip}>
<h2 className="spm__title">Update Thumbnail</h2>
<p className="spm__desc">Choose a frame from the new playable</p>
<div className="spm__body">
{status === 'loading' && (
<div className="spm__loading">Capturing frames</div>
)}
{status === 'error' && (
<div className="spm__error">Capture failed.</div>
)}
{(status === 'selecting' || status === 'saving') && (
<div className="spm__candidates">
{candidates.map((dataUrl, i) => (
<button
key={i}
type="button"
className={`spm__candidate${selectedIdx === i ? ' spm__candidate--selected' : ''}`}
onClick={() => handleSelect(dataUrl, i)}
disabled={status === 'saving'}
title={`Frame ${i + 1}`}
>
<img src={dataUrl} alt={`Frame ${i + 1}`} />
{selectedIdx === i && (
<span className="spm__candidate-badge">
{status === 'saving' ? '…' : '✓'}
</span>
)}
</button>
))}
</div>
)}
</div>
<div className="spm__footer">
<button
type="button"
className="btn-secondary"
onClick={fetchCandidates}
disabled={status === 'loading' || status === 'saving'}
>
Retake
</button>
<button type="button" className="btn-secondary" onClick={onSkip}>
Skip
</button>
</div>
</dialog>
);
}

View File

@@ -32,6 +32,7 @@
justify-content: center; justify-content: center;
gap: 14px; gap: 14px;
padding: 24px 20px; padding: 24px 20px;
overflow-y: auto;
} }
.upm__frame { .upm__frame {
@@ -58,6 +59,94 @@
margin: 0; margin: 0;
} }
.upm__shot-hint--error {
color: #f77;
}
.upm__candidates-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.upm__candidates-label {
font-size: 0.7rem;
color: #555;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
align-self: flex-start;
}
.upm__candidates {
display: flex;
gap: 8px;
width: 100%;
}
.upm__candidate {
flex: 1;
aspect-ratio: 9 / 19.5;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
border: 2px solid #2e2e2e;
background: #000;
padding: 0;
position: relative;
transition: border-color 0.15s, transform 0.1s;
}
.upm__candidate:hover:not(:disabled) {
border-color: #555;
transform: scale(1.03);
}
.upm__candidate--selected {
border-color: #7c6af7;
}
.upm__candidate img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.upm__candidate-check {
position: absolute;
top: 4px;
right: 4px;
background: #7c6af7;
color: #fff;
font-size: 0.65rem;
font-weight: 700;
border-radius: 50%;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.upm__retake {
background: none;
border: none;
color: #444;
font-size: 0.72rem;
cursor: pointer;
padding: 0;
align-self: flex-end;
transition: color 0.15s;
}
.upm__retake:hover {
color: #777;
}
/* ── Right: form ───────────────────────────────────────────────────────── */ /* ── Right: form ───────────────────────────────────────────────────────── */
.upm__form { .upm__form {

View File

@@ -1,9 +1,10 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { createApp, requestScreenshot } from '../api'; import { createApp, requestScreenshotCandidates, saveThumbnail } from '../api';
import { buildMutedHtml } from '../muteScript';
import AppModal from './AppModal'; import AppModal from './AppModal';
import './UploadPreviewModal.css'; import './UploadPreviewModal.css';
export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, onClose }) { export default function UploadPreviewModal({ ad, apps: appsProp = [], developers = [], onSave, onClose }) {
const dialogRef = useRef(); const dialogRef = useRef();
const initialScreenshotStarted = useRef(false); const initialScreenshotStarted = useRef(false);
@@ -20,14 +21,27 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
const [creatingApp, setCreatingApp] = useState(false); const [creatingApp, setCreatingApp] = useState(false);
const [screenshotTaken, setScreenshotTaken] = useState(false); const [screenshotTaken, setScreenshotTaken] = useState(false);
const [screenshotStatus, setScreenshotStatus] = useState(null); const [screenshotStatus, setScreenshotStatus] = useState(null);
const [candidates, setCandidates] = useState([]);
const [selectedCandidateIdx, setSelectedCandidateIdx] = useState(null);
const [savingCandidate, setSavingCandidate] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [srcdoc, setSrcdoc] = useState(null);
useEffect(() => { useEffect(() => {
dialogRef.current?.showModal(); dialogRef.current?.showModal();
return () => dialogRef.current?.close(); return () => dialogRef.current?.close();
}, []); }, []);
// Fetch and inject mute script so audio is silenced in the preview
useEffect(() => {
fetch(`/uploads/${ad.filename}`)
.then(r => r.text())
.then(html => setSrcdoc(buildMutedHtml(html)))
.catch(() => setSrcdoc(undefined));
}, [ad.filename]);
useEffect(() => { useEffect(() => {
if (initialScreenshotStarted.current) return; if (initialScreenshotStarted.current) return;
initialScreenshotStarted.current = true; initialScreenshotStarted.current = true;
@@ -43,14 +57,33 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
function removeTag(t) { setTags(tags.filter(x => x !== t)); } function removeTag(t) { setTags(tags.filter(x => x !== t)); }
async function handleScreenshot() { async function handleScreenshot() {
setCandidates([]);
setSelectedCandidateIdx(null);
setScreenshotTaken(false);
setScreenshotStatus('saving'); setScreenshotStatus('saving');
try { try {
await requestScreenshot(ad.id); const { candidates: c } = await requestScreenshotCandidates(ad.id);
setCandidates(c);
setScreenshotStatus('selecting');
} catch {
setScreenshotStatus('error');
setTimeout(() => setScreenshotStatus(null), 2500);
}
}
async function handleSelectCandidate(dataUrl, idx) {
if (savingCandidate) return;
setSavingCandidate(true);
try {
await saveThumbnail(ad.id, dataUrl);
setSelectedCandidateIdx(idx);
setScreenshotTaken(true); setScreenshotTaken(true);
setScreenshotStatus('done'); setScreenshotStatus('done');
} catch { } catch {
setScreenshotStatus('error'); setScreenshotStatus('error');
setTimeout(() => setScreenshotStatus(null), 2500); setTimeout(() => setScreenshotStatus(null), 2500);
} finally {
setSavingCandidate(false);
} }
} }
@@ -72,6 +105,10 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
} }
} }
const iframeProps = srcdoc !== undefined && srcdoc !== null
? { srcdoc }
: { src: `/uploads/${ad.filename}` };
return ( return (
<dialog ref={dialogRef} className="upm" onClose={onClose}> <dialog ref={dialogRef} className="upm" onClose={onClose}>
<div className="upm__layout"> <div className="upm__layout">
@@ -80,17 +117,46 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
<div className="upm__preview"> <div className="upm__preview">
<div className="upm__frame"> <div className="upm__frame">
<iframe <iframe
{...iframeProps}
src={`/uploads/${ad.filename}`}
title="Playable preview" title="Playable preview"
sandbox="allow-scripts allow-same-origin allow-forms" sandbox="allow-scripts allow-same-origin allow-forms"
className="upm__iframe" className="upm__iframe"
/> />
</div> </div>
{!screenshotTaken && (
<p className="upm__shot-hint"> {screenshotStatus === 'saving' && (
{screenshotStatus === 'saving' ? 'Capturing initial thumbnail…' : 'Required before saving'} <p className="upm__shot-hint">Capturing frames</p>
</p> )}
{screenshotStatus === 'error' && (
<p className="upm__shot-hint upm__shot-hint--error">Capture failed try again</p>
)}
{candidates.length > 0 && (
<div className="upm__candidates-wrap">
<span className="upm__candidates-label">Choose thumbnail</span>
<div className="upm__candidates">
{candidates.map((dataUrl, i) => (
<button
key={i}
type="button"
className={`upm__candidate${selectedCandidateIdx === i ? ' upm__candidate--selected' : ''}`}
onClick={() => handleSelectCandidate(dataUrl, i)}
disabled={savingCandidate}
title={`Frame ${i + 1}`}
>
<img src={dataUrl} alt={`Frame ${i + 1}`} />
{selectedCandidateIdx === i && <span className="upm__candidate-check"></span>}
</button>
))}
</div>
<button type="button" className="upm__retake" onClick={handleScreenshot}>
Retake
</button>
</div>
)}
{!screenshotTaken && screenshotStatus === null && candidates.length === 0 && (
<p className="upm__shot-hint">Required before saving</p>
)} )}
</div> </div>
@@ -136,7 +202,10 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
<label> <label>
Developer Developer
<input value={developer} onChange={e => setDeveloper(e.target.value)} placeholder="Developer or team name" /> <select value={developer} onChange={e => setDeveloper(e.target.value)} className="upm__select">
<option value=""> select developer </option>
{developers.map(d => <option key={d.id} value={d.name}>{d.name}</option>)}
</select>
</label> </label>
<div className="upm__row2"> <div className="upm__row2">
@@ -164,7 +233,7 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
onClick={handleSave} onClick={handleSave}
disabled={saving || !screenshotTaken} disabled={saving || !screenshotTaken}
className="btn-primary" className="btn-primary"
title={!screenshotTaken ? 'Take a screenshot first' : ''} title={!screenshotTaken ? 'Select a thumbnail first' : ''}
> >
{saving ? 'Saving…' : 'Save'} {saving ? 'Saving…' : 'Save'}
</button> </button>

16
client/src/developers.js Normal file
View File

@@ -0,0 +1,16 @@
export const DEVELOPERS = [
'Belgica, Sean',
'Caamino, Adrian',
'Castro, Jesus',
'Coronel, Will',
'Esteron, Jericson',
'Fajardo, Jules',
'Florentino, Marcus',
'Lenomta, MJ',
'Malana, Marco',
'Mocorro, Jet',
'Mondejar, Kevin',
'Obzunar, Nic',
'Tecson, Nat',
'Tesalona, Allen',
];

21
client/src/muteScript.js Normal file
View File

@@ -0,0 +1,21 @@
const MUTE_SCRIPT = `<script>(function(){
var O=window.AudioContext||window.webkitAudioContext;
if(O){
var F=function(o){var c=new O(o);c.resume=function(){return Promise.resolve();};c.suspend();return c;};
F.prototype=O.prototype;
window.AudioContext=window.webkitAudioContext=F;
}
var mo=new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){
if(n.nodeType===1){if(n.matches&&n.matches("audio,video"))n.muted=true;if(n.querySelectorAll)n.querySelectorAll("audio,video").forEach(function(e){e.muted=true;});}
});});});
document.addEventListener("DOMContentLoaded",function(){
document.querySelectorAll("audio,video").forEach(function(e){e.muted=true;});
mo.observe(document.documentElement,{childList:true,subtree:true});
});
})()</script>`;
export function buildMutedHtml(html) {
return /<head(\s[^>]*)?>/i.test(html)
? html.replace(/<head(\s[^>]*)?>/i, m => m + MUTE_SCRIPT)
: MUTE_SCRIPT + html;
}

View File

@@ -3,8 +3,9 @@ import { useNavigate } from 'react-router-dom';
import AdCard from '../components/AdCard'; import AdCard from '../components/AdCard';
import AppLayout from '../components/AppLayout'; import AppLayout from '../components/AppLayout';
import MetadataModal from '../components/MetadataModal'; import MetadataModal from '../components/MetadataModal';
import ScreenshotPickerModal from '../components/ScreenshotPickerModal';
import UploadPreviewModal from '../components/UploadPreviewModal'; import UploadPreviewModal from '../components/UploadPreviewModal';
import { deleteAd, listAds, listApps, updateAd, updateAdFile, uploadAd } from '../api'; import { deleteAd, listAds, listApps, listDevelopers, saveThumbnail, updateAd, updateAdFile, uploadAd } from '../api';
import './Catalog.css'; import './Catalog.css';
export default function Catalog() { export default function Catalog() {
@@ -14,15 +15,18 @@ export default function Catalog() {
const [ads, setAds] = useState([]); const [ads, setAds] = useState([]);
const [apps, setApps] = useState([]); const [apps, setApps] = useState([]);
const [developers, setDevelopers] = useState([]);
const [activeAppId, setActiveAppId] = useState(null); const [activeAppId, setActiveAppId] = useState(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [editingAd, setEditingAd] = useState(null); const [editingAd, setEditingAd] = useState(null);
const [pendingAd, setPendingAd] = useState(null); const [pendingAd, setPendingAd] = useState(null);
const [screenshotPickAd, setScreenshotPickAd] = useState(null);
useEffect(() => { useEffect(() => {
listApps() listApps()
.then(list => setApps([...list].sort((a, b) => a.name.localeCompare(b.name)))) .then(list => setApps([...list].sort((a, b) => a.name.localeCompare(b.name))))
.catch(() => {}); .catch(() => {});
listDevelopers().then(setDevelopers).catch(() => {});
}, []); }, []);
useEffect(() => { listAds(activeAppId).then(setAds).catch(() => {}); }, [activeAppId]); useEffect(() => { listAds(activeAppId).then(setAds).catch(() => {}); }, [activeAppId]);
@@ -53,6 +57,10 @@ export default function Catalog() {
async function handleUploadSave(data) { async function handleUploadSave(data) {
const updated = await updateAd(pendingAd.id, data); const updated = await updateAd(pendingAd.id, data);
setAds(prev => [updated, ...prev.filter(a => a.id !== updated.id)]); setAds(prev => [updated, ...prev.filter(a => a.id !== updated.id)]);
// Refresh apps in case a new one was created inside the upload modal
listApps()
.then(list => setApps([...list].sort((a, b) => a.name.localeCompare(b.name))))
.catch(() => {});
const next = pendingAd._next; const next = pendingAd._next;
setPendingAd(null); setPendingAd(null);
if (next) next(); if (next) next();
@@ -67,10 +75,14 @@ export default function Catalog() {
// ── edit existing ──────────────────────────────────────────────────────── // ── edit existing ────────────────────────────────────────────────────────
async function handleSave({ newFile, ...data }) { async function handleSave({ newFile, ...data }) {
if (newFile) await updateAdFile(editingAd.id, newFile); const adId = editingAd.id;
const updated = await updateAd(editingAd.id, data); if (newFile) await updateAdFile(adId, newFile);
const updated = await updateAd(adId, data);
setAds(prev => prev.map(a => a.id === updated.id ? updated : a)); setAds(prev => prev.map(a => a.id === updated.id ? updated : a));
setEditingAd(null); setEditingAd(null);
if (newFile) {
setScreenshotPickAd(updated);
}
} }
async function handleDelete(ad) { async function handleDelete(ad) {
@@ -104,6 +116,7 @@ export default function Catalog() {
<input ref={fileInputRef} type="file" accept=".html" multiple style={{ display: 'none' }} onChange={handleFileSelect} /> <input ref={fileInputRef} type="file" accept=".html" multiple style={{ display: 'none' }} onChange={handleFileSelect} />
<button className="sb-btn sb-btn--primary" onClick={openFilePicker}>+ Upload</button> <button className="sb-btn sb-btn--primary" onClick={openFilePicker}>+ Upload</button>
<button className="sb-btn" onClick={() => navigate('/apps')}>Applications</button> <button className="sb-btn" onClick={() => navigate('/apps')}>Applications</button>
<button className="sb-btn" onClick={() => navigate('/settings')}>Settings</button>
</div> </div>
<div className="sb-divider" /> <div className="sb-divider" />
@@ -172,6 +185,7 @@ export default function Catalog() {
<UploadPreviewModal <UploadPreviewModal
ad={pendingAd} ad={pendingAd}
apps={apps} apps={apps}
developers={developers}
onSave={handleUploadSave} onSave={handleUploadSave}
onClose={handleUploadCancel} onClose={handleUploadCancel}
/> />
@@ -181,10 +195,22 @@ export default function Catalog() {
<MetadataModal <MetadataModal
ad={editingAd} ad={editingAd}
apps={apps} apps={apps}
developers={developers}
onSave={handleSave} onSave={handleSave}
onClose={() => setEditingAd(null)} onClose={() => setEditingAd(null)}
/> />
)} )}
{screenshotPickAd && (
<ScreenshotPickerModal
ad={screenshotPickAd}
onDone={shot => {
setAds(prev => prev.map(a => a.id === shot.id ? shot : a));
setScreenshotPickAd(null);
}}
onSkip={() => setScreenshotPickAd(null)}
/>
)}
</AppLayout> </AppLayout>
); );
} }

View File

@@ -0,0 +1,106 @@
.settings-page {
padding: 32px 40px;
max-width: 560px;
}
.settings-page__title {
font-size: 1.3rem;
font-weight: 700;
color: #e8e8e8;
margin: 0 0 28px;
}
.settings-section {
background: #1e1e1e;
border: 1px solid #2a2a2a;
border-radius: 12px;
padding: 24px;
}
.settings-section__title {
font-size: 0.95rem;
font-weight: 700;
color: #e8e8e8;
margin: 0 0 6px;
}
.settings-section__desc {
font-size: 0.8rem;
color: #666;
margin: 0 0 18px;
}
.settings-add-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.settings-add-input {
flex: 1;
background: #111;
border: 1px solid #2e2e2e;
border-radius: 8px;
color: #e8e8e8;
padding: 8px 12px;
font-size: 0.92rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.settings-add-input:focus {
border-color: #7c6af7;
}
.settings-add-btn {
white-space: nowrap;
padding: 8px 16px;
}
.settings-error {
font-size: 0.82rem;
color: #f77;
margin: 0 0 12px;
}
.settings-dev-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.settings-dev-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 7px;
background: #161616;
border: 1px solid #252525;
}
.settings-dev-name {
font-size: 0.88rem;
color: #d0d0d0;
}
.settings-dev-remove {
background: none;
border: none;
color: #444;
cursor: pointer;
font-size: 0.8rem;
padding: 2px 6px;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
line-height: 1;
}
.settings-dev-remove:hover {
color: #f77;
background: #2a1515;
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import AppLayout from '../components/AppLayout';
import { addDeveloper, listDevelopers, removeDeveloper } from '../api';
import './Settings.css';
export default function Settings() {
const navigate = useNavigate();
const [developers, setDevelopers] = useState([]);
const [input, setInput] = useState('');
const [error, setError] = useState('');
const [adding, setAdding] = useState(false);
useEffect(() => {
listDevelopers().then(setDevelopers).catch(() => {});
}, []);
async function handleAdd(e) {
e.preventDefault();
const name = input.trim();
if (!name) return;
setError('');
setAdding(true);
try {
const dev = await addDeveloper(name);
setDevelopers(prev => [...prev, dev].sort((a, b) => a.name.localeCompare(b.name)));
setInput('');
} catch (err) {
setError(err.message || 'Failed to add developer');
} finally {
setAdding(false);
}
}
async function handleRemove(dev) {
if (!confirm(`Remove "${dev.name}" from the developer list?`)) return;
await removeDeveloper(dev.id);
setDevelopers(prev => prev.filter(d => d.id !== dev.id));
}
const sidebar = (
<>
<div className="sb-brand">Playable Showcase</div>
<div className="sb-nav">
<button className="sb-btn" onClick={() => navigate('/')}> Catalog</button>
</div>
</>
);
return (
<AppLayout sidebar={sidebar}>
<div className="settings-page">
<h1 className="settings-page__title">Settings</h1>
<section className="settings-section">
<h2 className="settings-section__title">Developers</h2>
<p className="settings-section__desc">Manage the list of developers shown in upload and edit forms.</p>
<form className="settings-add-row" onSubmit={handleAdd}>
<input
className="settings-add-input"
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Last, First"
/>
<button type="submit" className="btn-primary settings-add-btn" disabled={adding || !input.trim()}>
{adding ? 'Adding…' : '+ Add'}
</button>
</form>
{error && <p className="settings-error">{error}</p>}
<ul className="settings-dev-list">
{developers.map(dev => (
<li key={dev.id} className="settings-dev-row">
<span className="settings-dev-name">{dev.name}</span>
<button
type="button"
className="settings-dev-remove"
onClick={() => handleRemove(dev)}
title="Remove"
>
</button>
</li>
))}
</ul>
</section>
</div>
</AppLayout>
);
}

View File

@@ -178,11 +178,22 @@
} }
.viewer__edit-btn { .viewer__edit-btn {
width: 100%; flex: 1;
padding: 8px; padding: 8px;
font-size: 0.85rem; font-size: 0.85rem;
} }
.viewer__download-btn {
flex: 1;
padding: 8px 4px;
font-size: 0.78rem;
text-align: center;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
@media (max-width: 680px) { @media (max-width: 680px) {
.viewer__stage { .viewer__stage {
min-height: 100dvh; min-height: 100dvh;

View File

@@ -1,9 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { getAd, listApps, updateAd, updateAdFile } from '../api'; import { getAd, listApps, listDevelopers, updateAd, updateAdFile } from '../api';
import AppLayout from '../components/AppLayout'; import AppLayout from '../components/AppLayout';
import DeviceFrame, { FRAMES, getFrameSize } from '../components/DeviceFrame'; import DeviceFrame, { FRAMES, getFrameSize } from '../components/DeviceFrame';
import MetadataModal from '../components/MetadataModal'; import MetadataModal from '../components/MetadataModal';
import ScreenshotPickerModal from '../components/ScreenshotPickerModal';
import './Viewer.css'; import './Viewer.css';
const VIEWER_LAYOUT = { const VIEWER_LAYOUT = {
@@ -20,9 +21,12 @@ export default function Viewer() {
const navigate = useNavigate(); const navigate = useNavigate();
const [ad, setAd] = useState(null); const [ad, setAd] = useState(null);
const [apps, setApps] = useState([]); const [apps, setApps] = useState([]);
const [developers, setDevelopers] = useState([]);
const [frame, setFrame] = useState('phone'); const [frame, setFrame] = useState('phone');
const [rotated, setRotated] = useState(false); const [rotated, setRotated] = useState(false);
const [muted, setMuted] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [pickingScreenshot, setPickingScreenshot] = useState(false);
const stageRef = useRef(); const stageRef = useRef();
const [stageDims, setStageDims] = useState(() => ({ const [stageDims, setStageDims] = useState(() => ({
width: window.innerWidth - VIEWER_LAYOUT.sidebarWidth, width: window.innerWidth - VIEWER_LAYOUT.sidebarWidth,
@@ -32,6 +36,7 @@ export default function Viewer() {
useEffect(() => { useEffect(() => {
getAd(id).then(setAd).catch(() => navigate('/')); getAd(id).then(setAd).catch(() => navigate('/'));
listApps().then(setApps).catch(() => {}); listApps().then(setApps).catch(() => {});
listDevelopers().then(setDevelopers).catch(() => {});
}, [id, navigate]); }, [id, navigate]);
useEffect(() => { useEffect(() => {
@@ -78,6 +83,9 @@ export default function Viewer() {
const updated = await updateAd(id, data); const updated = await updateAd(id, data);
setAd(updated); setAd(updated);
setEditing(false); setEditing(false);
if (newFile) {
setPickingScreenshot(true);
}
} }
if (!ad) return <div className="viewer__loading">Loading...</div>; if (!ad) return <div className="viewer__loading">Loading...</div>;
@@ -174,8 +182,25 @@ export default function Viewer() {
> >
Rotate Rotate
</button> </button>
<button
className={`viewer__ctrl-btn${muted ? ' active' : ''}`}
onClick={() => setMuted(m => !m)}
title={muted ? 'Unmute' : 'Mute'}
>
{muted ? 'Unmute' : 'Mute'}
</button>
</div> </div>
<div className="viewer__ctrl-row">
<button className="btn-primary viewer__edit-btn" onClick={() => setEditing(true)}>Edit</button> <button className="btn-primary viewer__edit-btn" onClick={() => setEditing(true)}>Edit</button>
<a
href={`/uploads/${ad.filename}`}
download={`${ad.title}.html`}
className="viewer__ctrl-btn viewer__download-btn"
title="Download HTML"
>
HTML
</a>
</div>
</div> </div>
</> </>
); );
@@ -194,12 +219,21 @@ export default function Viewer() {
frame={frame} frame={frame}
filename={ad.filename} filename={ad.filename}
rotated={rotated} rotated={rotated}
muted={muted}
/> />
</div> </div>
</div> </div>
{editing && ( {editing && (
<MetadataModal ad={ad} apps={apps} onSave={handleSave} onClose={() => setEditing(false)} /> <MetadataModal ad={ad} apps={apps} developers={developers} onSave={handleSave} onClose={() => setEditing(false)} />
)}
{pickingScreenshot && (
<ScreenshotPickerModal
ad={ad}
onDone={shot => { setAd(shot); setPickingScreenshot(false); }}
onSkip={() => setPickingScreenshot(false)}
/>
)} )}
</AppLayout> </AppLayout>
); );

View File

@@ -128,4 +128,63 @@ function deleteApp(id) {
return app; return app;
} }
module.exports = { listAds, getAd, insertAd, updateAd, updateAdFile, updateAdThumbnail, deleteAd, listApps, getApp, insertApp, updateApp, deleteApp }; // ── developers ───────────────────────────────────────────────────────────────
const DEVS_DB_PATH = process.env.DEVS_DB_PATH || path.join(__dirname, '..', 'data', 'developers.json');
const DEFAULT_DEVELOPERS = [
'Belgica, Sean',
'Caamino, Adrian',
'Castro, Jesus',
'Coronel, Will',
'Esteron, Jericson',
'Fajardo, Jules',
'Florentino, Marcus',
'Lenomta, MJ',
'Malana, Marco',
'Mocorro, Jet',
'Mondejar, Kevin',
'Obzunar, Nic',
'Tecson, Nat',
'Tesalona, Allen',
];
function readDevsDb() {
try { return JSON.parse(fs.readFileSync(DEVS_DB_PATH, 'utf8')); }
catch { return null; }
}
function writeDevsDb(devs) {
fs.mkdirSync(path.dirname(DEVS_DB_PATH), { recursive: true });
fs.writeFileSync(DEVS_DB_PATH, JSON.stringify(devs, null, 2), 'utf8');
}
function listDevelopers() {
const stored = readDevsDb();
if (stored) return stored;
// Seed from defaults on first access
const seeded = DEFAULT_DEVELOPERS.map(name => ({ id: crypto.randomUUID(), name }));
writeDevsDb(seeded);
return seeded;
}
function addDeveloper(name) {
const devs = listDevelopers();
if (devs.some(d => d.name.toLowerCase() === name.toLowerCase())) return null;
const dev = { id: crypto.randomUUID(), name };
devs.push(dev);
devs.sort((a, b) => a.name.localeCompare(b.name));
writeDevsDb(devs);
return dev;
}
function removeDeveloper(id) {
const devs = listDevelopers();
const idx = devs.findIndex(d => d.id === id);
if (idx === -1) return null;
const [dev] = devs.splice(idx, 1);
writeDevsDb(devs);
return dev;
}
module.exports = { listAds, getAd, insertAd, updateAd, updateAdFile, updateAdThumbnail, deleteAd, listApps, getApp, insertApp, updateApp, deleteApp, listDevelopers, addDeveloper, removeDeveloper };

View File

@@ -31,6 +31,7 @@ app.use('/api/auth', require('./routes/auth').router);
// Protected API routes // Protected API routes
app.use('/api/apps', requireAuth, require('./routes/apps')); app.use('/api/apps', requireAuth, require('./routes/apps'));
app.use('/api/developers', requireAuth, require('./routes/developers'));
app.use('/api/ads', requireAuth, require('./routes/upload')); app.use('/api/ads', requireAuth, require('./routes/upload'));
app.use('/api/ads', requireAuth, require('./routes/ads')); app.use('/api/ads', requireAuth, require('./routes/ads'));

View File

@@ -4,7 +4,7 @@ const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const multer = require('multer'); const multer = require('multer');
const { listAds, getAd, updateAd, updateAdFile, updateAdThumbnail, deleteAd } = require('../db'); const { listAds, getAd, updateAd, updateAdFile, updateAdThumbnail, deleteAd } = require('../db');
const { captureScreenshot } = require('../utils/screenshot'); const { captureScreenshot, captureScreenshotCandidates } = require('../utils/screenshot');
const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '..', '..', 'uploads'); const UPLOADS_DIR = process.env.UPLOADS_DIR || path.join(__dirname, '..', '..', 'uploads');
@@ -91,6 +91,22 @@ router.post('/:id/screenshot', async (req, res) => {
} }
}); });
router.post('/:id/screenshot-candidates', async (req, res) => {
const ad = getAd(req.params.id);
if (!ad) return res.status(404).json({ error: 'Not found' });
try {
const port = process.env.PORT || 3000;
const url = `http://127.0.0.1:${port}/uploads/${ad.filename}`;
const buffers = await captureScreenshotCandidates(url);
const candidates = buffers.map(buf => 'data:image/png;base64,' + buf.toString('base64'));
res.json({ candidates });
} catch (err) {
console.error('Screenshot candidates failed:', err.message);
res.status(500).json({ error: 'Screenshot capture failed' });
}
});
router.post('/:id/file', uploadFile.single('file'), (req, res) => { router.post('/:id/file', uploadFile.single('file'), (req, res) => {
const ad = getAd(req.params.id); const ad = getAd(req.params.id);
if (!ad) return res.status(404).json({ error: 'Not found' }); if (!ad) return res.status(404).json({ error: 'Not found' });

View File

@@ -0,0 +1,24 @@
const express = require('express');
const { listDevelopers, addDeveloper, removeDeveloper } = require('../db');
const router = express.Router();
router.get('/', (_req, res) => {
res.json(listDevelopers());
});
router.post('/', (req, res) => {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name is required' });
const dev = addDeveloper(name.trim());
if (!dev) return res.status(409).json({ error: 'Developer already exists' });
res.status(201).json(dev);
});
router.delete('/:id', (req, res) => {
const dev = removeDeveloper(req.params.id);
if (!dev) return res.status(404).json({ error: 'Not found' });
res.status(204).end();
});
module.exports = router;

View File

@@ -2,7 +2,8 @@ const puppeteer = require('puppeteer');
const fs = require('fs'); const fs = require('fs');
const FINAL_SETTLE_MS = 800; const FINAL_SETTLE_MS = 800;
const CANDIDATE_DELAYS_MS = [0, 500, 500]; const BURST_DELAYS_MS = [0, 500, 500];
const CANDIDATE_GAPS_MS = [2000, 2000]; // delays between candidates: T+0, T+2s, T+4s
const EDGE_PATHS = [ const EDGE_PATHS = [
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
@@ -17,11 +18,10 @@ function getBrowserExecutablePath() {
return EDGE_PATHS.find(p => fs.existsSync(p)); return EDGE_PATHS.find(p => fs.existsSync(p));
} }
async function captureScreenshot(url, width = 390, height = 844) { function getLaunchArgs() {
const executablePath = getBrowserExecutablePath(); return {
const browser = await puppeteer.launch({
headless: true, headless: true,
executablePath, executablePath: getBrowserExecutablePath(),
args: [ args: [
'--no-sandbox', '--no-sandbox',
'--disable-setuid-sandbox', '--disable-setuid-sandbox',
@@ -29,11 +29,17 @@ async function captureScreenshot(url, width = 390, height = 844) {
'--enable-webgl', '--enable-webgl',
'--ignore-gpu-blocklist', '--ignore-gpu-blocklist',
], ],
}); };
}
async function withPage(url, width, height, fn) {
const browser = await puppeteer.launch(getLaunchArgs());
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.setViewport({ width, height, deviceScaleFactor: 1 }); // isMobile ensures the CSS viewport meta tag is respected the same way a
// mobile browser would, so playables that size themselves via vw/vh or
// window.innerWidth match what the device frame shows.
await page.setViewport({ width, height, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
page.on('console', msg => console.log(`[screenshot:${msg.type()}] ${msg.text()}`)); page.on('console', msg => console.log(`[screenshot:${msg.type()}] ${msg.text()}`));
// Unity clears WebGL buffers by default, so force readable buffers before // Unity clears WebGL buffers by default, so force readable buffers before
@@ -51,6 +57,14 @@ async function captureScreenshot(url, width = 390, height = 844) {
console.log(`[screenshot] loading ${url}`); console.log(`[screenshot] loading ${url}`);
await page.goto(url, { waitUntil: 'load', timeout: 60000 }); await page.goto(url, { waitUntil: 'load', timeout: 60000 });
// Hide scrollbars and fire resize so JS-based layout callbacks re-run
// with the correct viewport dimensions.
await page.evaluate(() => {
document.documentElement.style.overflow = 'hidden';
if (document.body) document.body.style.overflow = 'hidden';
window.dispatchEvent(new Event('resize'));
});
try { try {
await page.waitForFunction(() => { await page.waitForFunction(() => {
const canvases = Array.from(document.querySelectorAll('canvas')) const canvases = Array.from(document.querySelectorAll('canvas'))
@@ -69,21 +83,37 @@ async function captureScreenshot(url, width = 390, height = 844) {
console.log(`[screenshot] canvas readiness timed out: ${err.message}`); console.log(`[screenshot] canvas readiness timed out: ${err.message}`);
} }
// HTML overlays and Unity's first fully composited frame can lag behind
// canvas readiness, so capture a short burst and keep the richest PNG.
await wait(FINAL_SETTLE_MS); await wait(FINAL_SETTLE_MS);
let bestBuffer = null; return await fn(page);
for (const delay of CANDIDATE_DELAYS_MS) {
if (delay) await wait(delay);
const buffer = await page.screenshot({ type: 'png' });
if (!bestBuffer || buffer.length > bestBuffer.length) bestBuffer = buffer;
}
console.log(`[screenshot] selected ${bestBuffer.length} bytes`);
return bestBuffer;
} finally { } finally {
await browser.close(); await browser.close();
} }
} }
module.exports = { captureScreenshot }; async function captureScreenshot(url, width = 390, height = 844) {
return withPage(url, width, height, async (page) => {
let bestBuffer = null;
for (const delay of BURST_DELAYS_MS) {
if (delay) await wait(delay);
const buffer = await page.screenshot({ type: 'png' });
if (!bestBuffer || buffer.length > bestBuffer.length) bestBuffer = buffer;
}
console.log(`[screenshot] selected ${bestBuffer.length} bytes`);
return bestBuffer;
});
}
async function captureScreenshotCandidates(url, width = 390, height = 844) {
return withPage(url, width, height, async (page) => {
const buffers = [];
buffers.push(await page.screenshot({ type: 'png' }));
for (const gap of CANDIDATE_GAPS_MS) {
await wait(gap);
buffers.push(await page.screenshot({ type: 'png' }));
}
console.log(`[screenshot] captured ${buffers.length} candidates`);
return buffers;
});
}
module.exports = { captureScreenshot, captureScreenshotCandidates };