update UI and QOL
This commit is contained in:
@@ -3,6 +3,7 @@ import { isAuthenticated } from './api';
|
||||
import Login from './pages/Login';
|
||||
import Apps from './pages/Apps';
|
||||
import Catalog from './pages/Catalog';
|
||||
import Settings from './pages/Settings';
|
||||
import Viewer from './pages/Viewer';
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
@@ -16,6 +17,7 @@ export default function App() {
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><Catalog /></ProtectedRoute>} />
|
||||
<Route path="/apps" element={<ProtectedRoute><Apps /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
<Route path="/viewer/:id" element={<ProtectedRoute><Viewer /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -92,6 +92,12 @@ export async function requestScreenshot(id) {
|
||||
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) {
|
||||
const res = await apiFetch(`${ADS_BASE}/${id}/thumbnail`, {
|
||||
method: 'POST',
|
||||
@@ -102,6 +108,29 @@ export async function saveThumbnail(id, dataUrl) {
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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}`)}>
|
||||
{showThumb ? (
|
||||
<img
|
||||
src={`/uploads/${ad.thumbnail}`}
|
||||
src={`/uploads/${ad.thumbnail}?t=${ad.updated_at}`}
|
||||
alt={ad.title}
|
||||
className="ad-card__thumb-img"
|
||||
onError={() => setThumbError(true)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import './DeviceFrame.css';
|
||||
|
||||
// Screen resolution (inner display area)
|
||||
@@ -28,7 +29,65 @@ export function getFrameSize(frame, rotated) {
|
||||
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 { w: sw, h: sh } = SCREEN[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 isPhone = frame === 'phone';
|
||||
|
||||
// Notch
|
||||
const notchW = isPhone ? 90 : 14;
|
||||
const notchH = isPhone ? 24 : 14;
|
||||
const notchStyle = rotated
|
||||
? { width: notchH, height: notchW, top: (outerH - notchW) / 2, left: (pl - notchH) / 2 }
|
||||
: { width: notchW, height: notchH, top: (pt - notchH) / 2, left: (outerW - notchW) / 2 };
|
||||
|
||||
// Home bar (phone only)
|
||||
const homeStyle = isPhone
|
||||
? rotated
|
||||
? { 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}`}
|
||||
style={{ width: outerW, height: outerH }}
|
||||
>
|
||||
{/* Notch / camera dot */}
|
||||
<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={{
|
||||
top: pt, left: pl, width: sw, height: sh,
|
||||
borderRadius: isPhone ? 30 : 16,
|
||||
}}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
ref={ref}
|
||||
key={`${filename}-${frame}-${o}`}
|
||||
src={`/uploads/${filename}`}
|
||||
title="Playable ad"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
onLoad={onIframeLoad}
|
||||
onLoad={handleLoad}
|
||||
style={{ width: sw, height: sh }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Home indicator */}
|
||||
{homeStyle && <div className="device-frame__home" style={homeStyle} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -140,14 +140,31 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-modal__replace-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.meta-modal__replace-label {
|
||||
display: block;
|
||||
font-size: 0.82rem;
|
||||
color: #999;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import './MetadataModal.css';
|
||||
|
||||
export default function MetadataModal({ ad, apps = [], onSave, onClose }) {
|
||||
export default function MetadataModal({ ad, apps = [], developers = [], onSave, onClose }) {
|
||||
const dialogRef = useRef();
|
||||
const fileInputRef = useRef();
|
||||
const [title, setTitle] = useState(ad.title);
|
||||
@@ -92,7 +92,10 @@ export default function MetadataModal({ ad, apps = [], onSave, onClose }) {
|
||||
|
||||
<label>
|
||||
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>
|
||||
|
||||
<div className="meta-modal__row2">
|
||||
@@ -112,7 +115,16 @@ export default function MetadataModal({ ad, apps = [], onSave, onClose }) {
|
||||
</label>
|
||||
|
||||
<div className="meta-modal__replace">
|
||||
<span className="meta-modal__replace-label">Replace HTML</span>
|
||||
<div className="meta-modal__replace-header">
|
||||
<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">
|
||||
<input
|
||||
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;
|
||||
gap: 14px;
|
||||
padding: 24px 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.upm__frame {
|
||||
@@ -58,6 +59,94 @@
|
||||
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 ───────────────────────────────────────────────────────── */
|
||||
|
||||
.upm__form {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 './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 initialScreenshotStarted = useRef(false);
|
||||
|
||||
@@ -20,14 +21,27 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
|
||||
const [creatingApp, setCreatingApp] = useState(false);
|
||||
const [screenshotTaken, setScreenshotTaken] = useState(false);
|
||||
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 [error, setError] = useState('');
|
||||
|
||||
const [srcdoc, setSrcdoc] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
dialogRef.current?.showModal();
|
||||
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(() => {
|
||||
if (initialScreenshotStarted.current) return;
|
||||
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)); }
|
||||
|
||||
async function handleScreenshot() {
|
||||
setCandidates([]);
|
||||
setSelectedCandidateIdx(null);
|
||||
setScreenshotTaken(false);
|
||||
setScreenshotStatus('saving');
|
||||
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);
|
||||
setScreenshotStatus('done');
|
||||
} catch {
|
||||
setScreenshotStatus('error');
|
||||
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 (
|
||||
<dialog ref={dialogRef} className="upm" onClose={onClose}>
|
||||
<div className="upm__layout">
|
||||
@@ -80,17 +117,46 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
|
||||
<div className="upm__preview">
|
||||
<div className="upm__frame">
|
||||
<iframe
|
||||
|
||||
src={`/uploads/${ad.filename}`}
|
||||
{...iframeProps}
|
||||
title="Playable preview"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
className="upm__iframe"
|
||||
/>
|
||||
</div>
|
||||
{!screenshotTaken && (
|
||||
<p className="upm__shot-hint">
|
||||
{screenshotStatus === 'saving' ? 'Capturing initial thumbnail…' : 'Required before saving'}
|
||||
</p>
|
||||
|
||||
{screenshotStatus === 'saving' && (
|
||||
<p className="upm__shot-hint">Capturing frames…</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>
|
||||
|
||||
@@ -136,7 +202,10 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
|
||||
|
||||
<label>
|
||||
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>
|
||||
|
||||
<div className="upm__row2">
|
||||
@@ -164,7 +233,7 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
|
||||
onClick={handleSave}
|
||||
disabled={saving || !screenshotTaken}
|
||||
className="btn-primary"
|
||||
title={!screenshotTaken ? 'Take a screenshot first' : ''}
|
||||
title={!screenshotTaken ? 'Select a thumbnail first' : ''}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</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 AppLayout from '../components/AppLayout';
|
||||
import MetadataModal from '../components/MetadataModal';
|
||||
import ScreenshotPickerModal from '../components/ScreenshotPickerModal';
|
||||
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';
|
||||
|
||||
export default function Catalog() {
|
||||
@@ -14,15 +15,18 @@ export default function Catalog() {
|
||||
|
||||
const [ads, setAds] = useState([]);
|
||||
const [apps, setApps] = useState([]);
|
||||
const [developers, setDevelopers] = useState([]);
|
||||
const [activeAppId, setActiveAppId] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingAd, setEditingAd] = useState(null);
|
||||
const [pendingAd, setPendingAd] = useState(null);
|
||||
const [screenshotPickAd, setScreenshotPickAd] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
listApps()
|
||||
.then(list => setApps([...list].sort((a, b) => a.name.localeCompare(b.name))))
|
||||
.catch(() => {});
|
||||
listDevelopers().then(setDevelopers).catch(() => {});
|
||||
}, []);
|
||||
useEffect(() => { listAds(activeAppId).then(setAds).catch(() => {}); }, [activeAppId]);
|
||||
|
||||
@@ -53,6 +57,10 @@ export default function Catalog() {
|
||||
async function handleUploadSave(data) {
|
||||
const updated = await updateAd(pendingAd.id, data);
|
||||
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;
|
||||
setPendingAd(null);
|
||||
if (next) next();
|
||||
@@ -67,10 +75,14 @@ export default function Catalog() {
|
||||
// ── edit existing ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSave({ newFile, ...data }) {
|
||||
if (newFile) await updateAdFile(editingAd.id, newFile);
|
||||
const updated = await updateAd(editingAd.id, data);
|
||||
const adId = editingAd.id;
|
||||
if (newFile) await updateAdFile(adId, newFile);
|
||||
const updated = await updateAd(adId, data);
|
||||
setAds(prev => prev.map(a => a.id === updated.id ? updated : a));
|
||||
setEditingAd(null);
|
||||
if (newFile) {
|
||||
setScreenshotPickAd(updated);
|
||||
}
|
||||
}
|
||||
|
||||
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} />
|
||||
<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('/settings')}>Settings</button>
|
||||
</div>
|
||||
|
||||
<div className="sb-divider" />
|
||||
@@ -172,6 +185,7 @@ export default function Catalog() {
|
||||
<UploadPreviewModal
|
||||
ad={pendingAd}
|
||||
apps={apps}
|
||||
developers={developers}
|
||||
onSave={handleUploadSave}
|
||||
onClose={handleUploadCancel}
|
||||
/>
|
||||
@@ -181,10 +195,22 @@ export default function Catalog() {
|
||||
<MetadataModal
|
||||
ad={editingAd}
|
||||
apps={apps}
|
||||
developers={developers}
|
||||
onSave={handleSave}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
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) {
|
||||
.viewer__stage {
|
||||
min-height: 100dvh;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
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 DeviceFrame, { FRAMES, getFrameSize } from '../components/DeviceFrame';
|
||||
import MetadataModal from '../components/MetadataModal';
|
||||
import ScreenshotPickerModal from '../components/ScreenshotPickerModal';
|
||||
import './Viewer.css';
|
||||
|
||||
const VIEWER_LAYOUT = {
|
||||
@@ -20,9 +21,12 @@ export default function Viewer() {
|
||||
const navigate = useNavigate();
|
||||
const [ad, setAd] = useState(null);
|
||||
const [apps, setApps] = useState([]);
|
||||
const [developers, setDevelopers] = useState([]);
|
||||
const [frame, setFrame] = useState('phone');
|
||||
const [rotated, setRotated] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [pickingScreenshot, setPickingScreenshot] = useState(false);
|
||||
const stageRef = useRef();
|
||||
const [stageDims, setStageDims] = useState(() => ({
|
||||
width: window.innerWidth - VIEWER_LAYOUT.sidebarWidth,
|
||||
@@ -32,6 +36,7 @@ export default function Viewer() {
|
||||
useEffect(() => {
|
||||
getAd(id).then(setAd).catch(() => navigate('/'));
|
||||
listApps().then(setApps).catch(() => {});
|
||||
listDevelopers().then(setDevelopers).catch(() => {});
|
||||
}, [id, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,6 +83,9 @@ export default function Viewer() {
|
||||
const updated = await updateAd(id, data);
|
||||
setAd(updated);
|
||||
setEditing(false);
|
||||
if (newFile) {
|
||||
setPickingScreenshot(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ad) return <div className="viewer__loading">Loading...</div>;
|
||||
@@ -174,8 +182,25 @@ export default function Viewer() {
|
||||
>
|
||||
Rotate
|
||||
</button>
|
||||
<button
|
||||
className={`viewer__ctrl-btn${muted ? ' active' : ''}`}
|
||||
onClick={() => setMuted(m => !m)}
|
||||
title={muted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{muted ? 'Unmute' : 'Mute'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="viewer__ctrl-row">
|
||||
<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>
|
||||
<button className="btn-primary viewer__edit-btn" onClick={() => setEditing(true)}>Edit</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -194,12 +219,21 @@ export default function Viewer() {
|
||||
frame={frame}
|
||||
filename={ad.filename}
|
||||
rotated={rotated}
|
||||
muted={muted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
);
|
||||
|
||||
61
server/db.js
61
server/db.js
@@ -128,4 +128,63 @@ function deleteApp(id) {
|
||||
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
|
||||
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/ads'));
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
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');
|
||||
|
||||
@@ -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) => {
|
||||
const ad = getAd(req.params.id);
|
||||
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 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 = [
|
||||
'C:\\Program Files (x86)\\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));
|
||||
}
|
||||
|
||||
async function captureScreenshot(url, width = 390, height = 844) {
|
||||
const executablePath = getBrowserExecutablePath();
|
||||
const browser = await puppeteer.launch({
|
||||
function getLaunchArgs() {
|
||||
return {
|
||||
headless: true,
|
||||
executablePath,
|
||||
executablePath: getBrowserExecutablePath(),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
@@ -29,11 +29,17 @@ async function captureScreenshot(url, width = 390, height = 844) {
|
||||
'--enable-webgl',
|
||||
'--ignore-gpu-blocklist',
|
||||
],
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function withPage(url, width, height, fn) {
|
||||
const browser = await puppeteer.launch(getLaunchArgs());
|
||||
try {
|
||||
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()}`));
|
||||
|
||||
// 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}`);
|
||||
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 {
|
||||
await page.waitForFunction(() => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 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);
|
||||
let bestBuffer = null;
|
||||
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;
|
||||
return await fn(page);
|
||||
} finally {
|
||||
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