@@ -80,17 +117,46 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
- {!screenshotTaken && (
-
- {screenshotStatus === 'saving' ? 'Capturing initial thumbnail…' : 'Required before saving'}
-
+
+ {screenshotStatus === 'saving' && (
+
Capturing frames…
+ )}
+ {screenshotStatus === 'error' && (
+
Capture failed — try again
+ )}
+
+ {candidates.length > 0 && (
+
+
Choose thumbnail
+
+ {candidates.map((dataUrl, i) => (
+
+ ))}
+
+
+
+ )}
+
+ {!screenshotTaken && screenshotStatus === null && candidates.length === 0 && (
+
Required before saving
)}
@@ -136,7 +202,10 @@ export default function UploadPreviewModal({ ad, apps: appsProp = [], onSave, on
@@ -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'}
diff --git a/client/src/developers.js b/client/src/developers.js
new file mode 100644
index 0000000..f542488
--- /dev/null
+++ b/client/src/developers.js
@@ -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',
+];
diff --git a/client/src/muteScript.js b/client/src/muteScript.js
new file mode 100644
index 0000000..f83b4da
--- /dev/null
+++ b/client/src/muteScript.js
@@ -0,0 +1,21 @@
+const MUTE_SCRIPT = ``;
+
+export function buildMutedHtml(html) {
+ return /]*)?>/i.test(html)
+ ? html.replace(/]*)?>/i, m => m + MUTE_SCRIPT)
+ : MUTE_SCRIPT + html;
+}
diff --git a/client/src/pages/Catalog.jsx b/client/src/pages/Catalog.jsx
index 09f4c7e..348ec98 100644
--- a/client/src/pages/Catalog.jsx
+++ b/client/src/pages/Catalog.jsx
@@ -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() {
+
@@ -172,6 +185,7 @@ export default function Catalog() {
@@ -181,10 +195,22 @@ export default function Catalog() {
setEditingAd(null)}
/>
)}
+
+ {screenshotPickAd && (
+ {
+ setAds(prev => prev.map(a => a.id === shot.id ? shot : a));
+ setScreenshotPickAd(null);
+ }}
+ onSkip={() => setScreenshotPickAd(null)}
+ />
+ )}
);
}
diff --git a/client/src/pages/Settings.css b/client/src/pages/Settings.css
new file mode 100644
index 0000000..617286b
--- /dev/null
+++ b/client/src/pages/Settings.css
@@ -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;
+}
diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx
new file mode 100644
index 0000000..90b91d9
--- /dev/null
+++ b/client/src/pages/Settings.jsx
@@ -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 = (
+ <>
+ Playable Showcase
+
+
+
+ >
+ );
+
+ return (
+
+
+
Settings
+
+
+ Developers
+ Manage the list of developers shown in upload and edit forms.
+
+
+ {error && {error}
}
+
+
+ {developers.map(dev => (
+ -
+ {dev.name}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/client/src/pages/Viewer.css b/client/src/pages/Viewer.css
index 415bf4a..86a6f33 100644
--- a/client/src/pages/Viewer.css
+++ b/client/src/pages/Viewer.css
@@ -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;
diff --git a/client/src/pages/Viewer.jsx b/client/src/pages/Viewer.jsx
index 21c1953..abbc7ab 100644
--- a/client/src/pages/Viewer.jsx
+++ b/client/src/pages/Viewer.jsx
@@ -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 Loading...
;
@@ -174,8 +182,25 @@ export default function Viewer() {
>
Rotate
+
+
+