diff --git a/client/src/App.jsx b/client/src/App.jsx index 3dc9cbe..c2ae8ed 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/client/src/api.js b/client/src/api.js index 937420b..d5d572c 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -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) { diff --git a/client/src/components/AdCard.jsx b/client/src/components/AdCard.jsx index d2815ab..5801434 100644 --- a/client/src/components/AdCard.jsx +++ b/client/src/components/AdCard.jsx @@ -14,7 +14,7 @@ export default function AdCard({ ad, app, onEdit, onDelete }) {
navigate(`/viewer/${ad.id}`)}> {showThumb ? ( {ad.title} setThumbError(true)} diff --git a/client/src/components/DeviceFrame.jsx b/client/src/components/DeviceFrame.jsx index e0d9360..575f3eb 100644 --- a/client/src/components/DeviceFrame.jsx +++ b/client/src/components/DeviceFrame.jsx @@ -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 */}
- - {/* 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. */}