update UI and QOL
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
105
client/src/components/ScreenshotPickerModal.css
Normal file
105
client/src/components/ScreenshotPickerModal.css
Normal 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;
|
||||||
|
}
|
||||||
100
client/src/components/ScreenshotPickerModal.jsx
Normal file
100
client/src/components/ScreenshotPickerModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
16
client/src/developers.js
Normal 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
21
client/src/muteScript.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
106
client/src/pages/Settings.css
Normal file
106
client/src/pages/Settings.css
Normal 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;
|
||||||
|
}
|
||||||
91
client/src/pages/Settings.jsx
Normal file
91
client/src/pages/Settings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
61
server/db.js
61
server/db.js
@@ -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 };
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
|||||||
24
server/routes/developers.js
Normal file
24
server/routes/developers.js
Normal 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;
|
||||||
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user