Initial Commit

This commit is contained in:
HPL-JesusCastro
2026-05-26 16:04:14 +08:00
commit 5d6f228224
62 changed files with 10152 additions and 0 deletions

251
client/src/pages/Apps.css Normal file
View File

@@ -0,0 +1,251 @@
.apps-page {
max-width: 800px;
padding: 28px 24px;
}
.apps-page__title {
font-size: 1.4rem;
font-weight: 800;
margin: 0 0 24px;
background: linear-gradient(135deg, #7c6af7 0%, #b89bf7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
@media (max-width: 480px) {
.apps-page {
padding: 16px 12px;
}
}
.apps-page__empty {
text-align: center;
color: #555;
margin-top: 48px;
font-size: 0.95rem;
}
.apps-page__list {
display: flex;
flex-direction: column;
gap: 10px;
}
.app-row {
display: flex;
align-items: center;
gap: 14px;
background: #1e1e1e;
border: 1px solid #2e2e2e;
border-radius: 12px;
padding: 14px 20px;
transition: border-color 0.2s;
}
.app-row__icon {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: cover;
flex-shrink: 0;
}
.app-row__icon-placeholder {
width: 48px;
height: 48px;
border-radius: 12px;
background: #2a2a2a;
flex-shrink: 0;
}
.app-row:hover {
border-color: #3e3e3e;
}
.app-row__info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.app-row__name {
font-size: 1rem;
font-weight: 600;
color: #e8e8e8;
}
.app-row__links {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.app-row__store {
font-size: 0.78rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
text-decoration: none;
}
.app-row__store--ios {
background: #1a2535;
color: #60a5fa;
border: 1px solid #2a4060;
}
.app-row__store--ios:hover {
background: #1e2e45;
}
.app-row__store--android {
background: #1a2a1a;
color: #6fcf6f;
border: 1px solid #2a4a2a;
}
.app-row__store--android:hover {
background: #1e341e;
}
.app-row__store--missing {
color: #444;
border: 1px solid #2e2e2e;
background: #161616;
}
.app-row__actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.app-row__actions button {
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 8px;
color: #aaa;
padding: 6px 12px;
font-size: 0.83rem;
cursor: pointer;
transition: all 0.15s;
}
.app-row__actions button:hover {
background: #333;
color: #fff;
}
.app-row__delete:hover {
background: #3a1a1a !important;
color: #f77 !important;
border-color: #5e2e2e !important;
}
/* App Modal ─────────────────────────────────────────────────────────────── */
.app-modal {
background: #1e1e1e;
color: #e8e8e8;
border: 1px solid #333;
border-radius: 16px;
padding: 28px 32px;
width: 460px;
max-width: 90vw;
box-shadow: 0 24px 80px rgba(0,0,0,0.7);
}
.app-modal::backdrop {
background: rgba(0,0,0,0.6);
backdrop-filter: blur(3px);
}
.app-modal h2 {
margin: 0 0 20px;
font-size: 1.15rem;
font-weight: 700;
}
.app-modal label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.82rem;
color: #999;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 16px;
}
.app-modal input {
background: #111;
border: 1px solid #333;
border-radius: 8px;
color: #e8e8e8;
padding: 8px 12px;
font-size: 0.95rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.app-modal input:focus {
border-color: #7c6af7;
}
.app-modal__name-row {
display: flex;
align-items: flex-end;
gap: 12px;
margin-bottom: 16px;
}
.app-modal__icon-preview {
width: 56px;
height: 56px;
border-radius: 14px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid #333;
}
.app-modal__name-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.app-modal__name-input-wrap input {
width: 100%;
}
.app-modal__spinner {
position: absolute;
right: 10px;
width: 14px;
height: 14px;
border: 2px solid #333;
border-top-color: #7c6af7;
border-radius: 50%;
animation: spin 0.7s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.app-modal__fetch-error {
font-size: 0.8rem;
color: #f97;
margin: -8px 0 14px;
}
.app-modal__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 24px;
}

97
client/src/pages/Apps.jsx Normal file
View File

@@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import AppLayout from '../components/AppLayout';
import AppModal from '../components/AppModal';
import { createApp, deleteApp, listApps, updateApp } from '../api';
import './Apps.css';
export default function Apps() {
const navigate = useNavigate();
const [apps, setApps] = useState([]);
const [editingApp, setEditingApp] = useState(null);
useEffect(() => { load(); }, []);
function sortByName(list) {
return [...list].sort((a, b) => a.name.localeCompare(b.name));
}
async function load() {
setApps(sortByName(await listApps()));
}
async function handleSave(data) {
if (editingApp === 'new') {
const created = await createApp(data);
setApps(prev => sortByName([created, ...prev]));
} else {
const updated = await updateApp(editingApp.id, data);
setApps(prev => sortByName(prev.map(a => a.id === updated.id ? updated : a)));
}
setEditingApp(null);
}
async function handleDelete(app) {
if (!confirm(`Delete "${app.name}"? Playables linked to it will become unlinked.`)) return;
await deleteApp(app.id);
setApps(prev => prev.filter(a => a.id !== app.id));
}
const sidebar = (
<>
<div className="sb-brand">Playable Showcase</div>
<div className="sb-nav">
<button className="sb-btn sb-btn--primary" onClick={() => setEditingApp('new')}>+ New Application</button>
<button className="sb-btn" onClick={() => navigate('/')}> Catalog</button>
</div>
</>
);
return (
<AppLayout sidebar={sidebar}>
<div className="apps-page">
<h1 className="apps-page__title">Applications</h1>
{apps.length === 0 && (
<p className="apps-page__empty">No applications yet. Create one to start organizing playables.</p>
)}
<div className="apps-page__list">
{apps.map(app => (
<div key={app.id} className="app-row">
{app.icon_url
? <img src={app.icon_url} alt={app.name} className="app-row__icon" onError={e => { e.target.style.display = 'none'; }} />
: <div className="app-row__icon-placeholder" />
}
<div className="app-row__info">
<span className="app-row__name">{app.name}</span>
<div className="app-row__links">
{app.ios_url
? <a href={app.ios_url} target="_blank" rel="noopener noreferrer" className="app-row__store app-row__store--ios">iOS</a>
: <span className="app-row__store app-row__store--missing">No iOS link</span>
}
{app.android_url
? <a href={app.android_url} target="_blank" rel="noopener noreferrer" className="app-row__store app-row__store--android">Android</a>
: <span className="app-row__store app-row__store--missing">No Android link</span>
}
</div>
</div>
<div className="app-row__actions">
<button onClick={() => setEditingApp(app)}> Edit</button>
<button onClick={() => handleDelete(app)} className="app-row__delete"></button>
</div>
</div>
))}
</div>
</div>
{editingApp && (
<AppModal
app={editingApp === 'new' ? null : editingApp}
onSave={handleSave}
onClose={() => setEditingApp(null)}
/>
)}
</AppLayout>
);
}

View File

@@ -0,0 +1,49 @@
.catalog {
padding: 24px 20px;
}
.catalog__search-wrap {
padding: 4px 8px;
}
.catalog__search {
width: 100%;
background: #111;
border: 1px solid #2e2e2e;
border-radius: 8px;
color: #e8e8e8;
padding: 7px 10px;
font-size: 0.85rem;
font-family: inherit;
outline: none;
box-sizing: border-box;
transition: border-color 0.15s;
}
.catalog__search:focus {
border-color: #7c6af7;
}
.catalog__search::placeholder {
color: #555;
}
.catalog__empty {
text-align: center;
color: #555;
margin-top: 48px;
font-size: 0.95rem;
}
.catalog__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
}
@media (max-width: 480px) {
.catalog__grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}

View File

@@ -0,0 +1,190 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import AdCard from '../components/AdCard';
import AppLayout from '../components/AppLayout';
import MetadataModal from '../components/MetadataModal';
import UploadPreviewModal from '../components/UploadPreviewModal';
import { deleteAd, listAds, listApps, updateAd, updateAdFile, uploadAd } from '../api';
import './Catalog.css';
export default function Catalog() {
const navigate = useNavigate();
const fileInputRef = useRef();
const uploadQueueRef = useRef([]);
const [ads, setAds] = useState([]);
const [apps, setApps] = useState([]);
const [activeAppId, setActiveAppId] = useState(null);
const [search, setSearch] = useState('');
const [editingAd, setEditingAd] = useState(null);
const [pendingAd, setPendingAd] = useState(null);
useEffect(() => {
listApps()
.then(list => setApps([...list].sort((a, b) => a.name.localeCompare(b.name))))
.catch(() => {});
}, []);
useEffect(() => { listAds(activeAppId).then(setAds).catch(() => {}); }, [activeAppId]);
// ── upload flow ──────────────────────────────────────────────────────────
function openFilePicker() { fileInputRef.current.click(); }
async function handleFileSelect(e) {
const files = [...e.target.files];
e.target.value = '';
if (!files.length) return;
uploadQueueRef.current = files;
processNext();
}
async function processNext() {
const file = uploadQueueRef.current.shift();
if (!file) return;
try {
const ad = await uploadAd(file);
setPendingAd({ ...ad, _next: processNext });
} catch (err) {
console.error('Upload failed', err);
processNext();
}
}
async function handleUploadSave(data) {
const updated = await updateAd(pendingAd.id, data);
setAds(prev => [updated, ...prev.filter(a => a.id !== updated.id)]);
const next = pendingAd._next;
setPendingAd(null);
if (next) next();
}
async function handleUploadCancel() {
deleteAd(pendingAd.id).catch(() => {});
uploadQueueRef.current = [];
setPendingAd(null);
}
// ── edit existing ────────────────────────────────────────────────────────
async function handleSave({ newFile, ...data }) {
if (newFile) await updateAdFile(editingAd.id, newFile);
const updated = await updateAd(editingAd.id, data);
setAds(prev => prev.map(a => a.id === updated.id ? updated : a));
setEditingAd(null);
}
async function handleDelete(ad) {
if (!confirm(`Delete "${ad.title}"?`)) return;
await deleteAd(ad.id);
setAds(prev => prev.filter(a => a.id !== ad.id));
}
const appsById = Object.fromEntries(apps.map(a => [a.id, a]));
// ── search filter ────────────────────────────────────────────────────────
const query = search.trim().toLowerCase();
const visibleAds = query
? ads.filter(ad => {
const appName = appsById[ad.appId]?.name?.toLowerCase() || '';
return (
ad.title.toLowerCase().includes(query) ||
(ad.developer || '').toLowerCase().includes(query) ||
appName.includes(query) ||
(ad.tags || []).some(t => t.includes(query))
);
})
: ads;
const sidebar = (
<>
<div className="sb-brand">Playable Showcase</div>
<div className="sb-nav">
<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>
</div>
<div className="sb-divider" />
<div className="catalog__search-wrap">
<input
className="catalog__search"
type="search"
placeholder="Search…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{apps.length > 0 && (
<>
<div className="sb-divider" />
<span className="sb-section-label">Filter by App</span>
<div className="sb-nav" style={{ paddingTop: 0 }}>
<button
className={`sb-btn${activeAppId === null ? ' sb-btn--active' : ''}`}
onClick={() => setActiveAppId(null)}
>
All
</button>
{apps.map(a => (
<button
key={a.id}
className={`sb-btn${activeAppId === a.id ? ' sb-btn--active' : ''}`}
onClick={() => setActiveAppId(a.id)}
>
{a.icon_url
? <img src={a.icon_url} className="sb-app-icon" alt="" onError={e => { e.target.style.display = 'none'; }} />
: <div className="sb-app-icon-placeholder" />
}
{a.name}
</button>
))}
</div>
</>
)}
</>
);
return (
<AppLayout sidebar={sidebar}>
<div className="catalog">
{visibleAds.length === 0 && (
<p className="catalog__empty">
{query ? 'No results.' : 'No playables yet. Click + Upload to add one.'}
</p>
)}
<div className="catalog__grid">
{visibleAds.map(ad => (
<AdCard
key={ad.id}
ad={ad}
app={appsById[ad.appId]}
onEdit={setEditingAd}
onDelete={handleDelete}
/>
))}
</div>
</div>
{pendingAd && (
<UploadPreviewModal
ad={pendingAd}
apps={apps}
onSave={handleUploadSave}
onClose={handleUploadCancel}
/>
)}
{editingAd && (
<MetadataModal
ad={editingAd}
apps={apps}
onSave={handleSave}
onClose={() => setEditingAd(null)}
/>
)}
</AppLayout>
);
}

View File

@@ -0,0 +1,68 @@
.login-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0e0e0e;
}
.login-card {
background: #181818;
border: 1px solid #2a2a2a;
border-radius: 16px;
padding: 48px 40px 40px;
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.login-logo img {
width: 56px;
height: 56px;
}
.login-card h1 {
font-size: 1.25rem;
font-weight: 600;
color: #e8e8e8;
text-align: center;
}
.login-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.login-form input[type='password'] {
width: 100%;
background: #111;
border: 1px solid #333;
border-radius: 8px;
padding: 10px 14px;
color: #e8e8e8;
font-size: 1rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.login-form input[type='password']:focus {
border-color: #7c6af7;
}
.login-form .btn-primary {
width: 100%;
}
.login-error {
margin: 0;
font-size: 0.85rem;
color: #f87171;
text-align: center;
}

View File

@@ -0,0 +1,50 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { login } from '../api';
import './Login.css';
export default function Login() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleSubmit(e) {
e.preventDefault();
setError('');
setLoading(true);
try {
await login(password);
navigate('/', { replace: true });
} catch {
setError('Incorrect password');
} finally {
setLoading(false);
}
}
return (
<div className="login-wrapper">
<div className="login-card">
<div className="login-logo">
<img src="/favicon.svg" alt="Logo" />
</div>
<h1>Playable Showcase</h1>
<form onSubmit={handleSubmit} className="login-form">
<input
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
autoFocus
autoComplete="current-password"
/>
{error && <p className="login-error">{error}</p>}
<button type="submit" className="btn-primary" disabled={loading || !password}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
.upload-page {
max-width: 640px;
margin: 0 auto;
padding: 32px 24px;
}
.upload-page__header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 32px;
}
.upload-page__header h1 {
flex: 1;
font-size: 1.8rem;
font-weight: 800;
margin: 0;
background: linear-gradient(135deg, #7c6af7 0%, #b89bf7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.upload-page__back {
background: none;
border: none;
color: #9d8ef7;
cursor: pointer;
font-size: 0.9rem;
padding: 0;
white-space: nowrap;
}
.upload-page__back:hover {
color: #b8aaf9;
}
.upload-page__zone {
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-page__warning {
text-align: center;
color: #888;
font-size: 0.88rem;
margin: 0;
}
.upload-page__link {
background: none;
border: none;
color: #9d8ef7;
cursor: pointer;
font-size: inherit;
padding: 0;
text-decoration: underline;
}
.upload-page__status {
text-align: center;
color: #9d8ef7;
font-size: 0.9rem;
margin-top: 48px;
animation: pulse 1.2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.upload-page__done {
text-align: center;
margin-top: 64px;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.upload-page__done-msg {
font-size: 1.1rem;
color: #6fcf6f;
margin: 0;
}
.upload-page__done-actions {
display: flex;
gap: 12px;
}

105
client/src/pages/Upload.jsx Normal file
View File

@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import DropZone from '../components/DropZone';
import MetadataModal from '../components/MetadataModal';
import { deleteAd, listApps, updateAd, updateAdFile, uploadAd } from '../api';
import './Upload.css';
export default function Upload() {
const navigate = useNavigate();
const [apps, setApps] = useState([]);
const [uploading, setUploading] = useState(false);
const [editingAd, setEditingAd] = useState(null);
const [doneCount, setDoneCount] = useState(0);
const uploadQueueRef = useRef([]);
useEffect(() => {
listApps().then(setApps).catch(() => {});
}, []);
async function handleFiles(files) {
setUploading(true);
setDoneCount(0);
uploadQueueRef.current = [...files];
processNext();
}
async function processNext() {
const file = uploadQueueRef.current.shift();
if (!file) { setUploading(false); return; }
try {
const ad = await uploadAd(file);
setEditingAd({ ...ad, _afterSave: processNext });
} catch (err) {
console.error('Upload failed', err);
processNext();
}
}
async function handleSave({ newFile, ...data }) {
if (newFile) await updateAdFile(editingAd.id, newFile);
await updateAd(editingAd.id, data);
setDoneCount(c => c + 1);
const after = editingAd._afterSave;
setEditingAd(null);
if (after) after();
}
function handleClose() {
// The file was already uploaded — delete it since the user cancelled
if (editingAd) deleteAd(editingAd.id).catch(() => {});
// Discard any remaining queued files
uploadQueueRef.current = [];
setEditingAd(null);
setUploading(false);
}
return (
<div className="upload-page">
<header className="upload-page__header">
<button className="upload-page__back" onClick={() => navigate('/')}> Catalog</button>
<h1>Upload Playables</h1>
</header>
{!uploading && doneCount === 0 && (
<div className="upload-page__zone">
<DropZone onFiles={handleFiles} />
{apps.length === 0 && (
<p className="upload-page__warning">
No applications configured yet.{' '}
<button className="upload-page__link" onClick={() => navigate('/apps')}>
Create one first
</button>{' '}
so you can link playables.
</p>
)}
</div>
)}
{uploading && !editingAd && (
<p className="upload-page__status">Uploading</p>
)}
{!uploading && doneCount > 0 && (
<div className="upload-page__done">
<p className="upload-page__done-msg">
{doneCount} playable{doneCount !== 1 ? 's' : ''} uploaded successfully.
</p>
<div className="upload-page__done-actions">
<button className="btn-secondary" onClick={() => { setDoneCount(0); }}>Upload more</button>
<button className="btn-primary" onClick={() => navigate('/')}>View in Catalog</button>
</div>
</div>
)}
{editingAd && (
<MetadataModal
ad={editingAd}
apps={apps}
onSave={handleSave}
onClose={handleClose}
/>
)}
</div>
);
}

190
client/src/pages/Viewer.css Normal file
View File

@@ -0,0 +1,190 @@
.viewer__loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
color: #666;
}
/* Stage fills the main content area (flex: 1 since parent is flex column) */
.viewer__stage {
flex: 1;
min-height: 0;
position: relative;
overflow: hidden;
}
.viewer__frame-wrapper {
position: absolute;
top: 50%;
left: 50%;
transform-origin: center center;
}
/* ── Sidebar content ──────────────────────────────────────────────────────── */
.viewer__sb-info {
padding: 4px 14px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.viewer__sb-title {
font-size: 0.88rem;
font-weight: 600;
color: #e8e8e8;
word-break: break-word;
}
.viewer__sb-developer {
font-size: 0.78rem;
color: #666;
}
.viewer__app-row {
display: flex;
align-items: center;
gap: 8px;
}
.viewer__app-icon {
width: 32px;
height: 32px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.viewer__app-icon--placeholder {
background: #2a2a2a;
}
.viewer__app-name {
font-size: 0.85rem;
font-weight: 600;
color: #e8e8e8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewer__store-links {
display: flex;
flex-direction: column;
gap: 5px;
margin-top: 2px;
}
.viewer__store-link {
font-size: 0.75rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 6px;
text-decoration: none;
text-align: center;
transition: opacity 0.15s;
}
.viewer__store-link:hover { opacity: 0.8; }
.viewer__store-link--ios {
background: #1c1c2e;
color: #a78bfa;
border: 1px solid #3730a3;
}
.viewer__store-link--android {
background: #0f2310;
color: #6fcf6f;
border: 1px solid #1a4a1a;
}
.viewer__sb-mono {
font-size: 0.78rem;
color: #888;
font-family: monospace;
word-break: break-all;
}
.viewer__tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.viewer__spacer {
flex: 1;
}
/* ── Controls section (bottom of sidebar) ─────────────────────────────────── */
.viewer__controls {
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.viewer__frame-btns {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.viewer__frame-btn {
flex: 1;
background: #252525;
border: 1px solid #333;
color: #aaa;
border-radius: 6px;
padding: 5px 6px;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.viewer__frame-btn.active,
.viewer__frame-btn:hover {
background: #2a2440;
color: #9d8ef7;
border-color: #5a4ea8;
}
.viewer__ctrl-row {
display: flex;
gap: 5px;
}
.viewer__ctrl-btn {
flex: 1;
background: #252525;
border: 1px solid #333;
color: #aaa;
border-radius: 6px;
padding: 6px 4px;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.viewer__ctrl-btn:hover,
.viewer__ctrl-btn.active {
background: #2a2440;
color: #9d8ef7;
border-color: #5a4ea8;
}
.viewer__edit-btn {
width: 100%;
padding: 8px;
font-size: 0.85rem;
}
@media (max-width: 680px) {
.viewer__stage {
min-height: 100dvh;
}
}

206
client/src/pages/Viewer.jsx Normal file
View File

@@ -0,0 +1,206 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { getAd, listApps, updateAd, updateAdFile } from '../api';
import AppLayout from '../components/AppLayout';
import DeviceFrame, { FRAMES, getFrameSize } from '../components/DeviceFrame';
import MetadataModal from '../components/MetadataModal';
import './Viewer.css';
const VIEWER_LAYOUT = {
sidebarWidth: 220,
mobileBreakpoint: 680,
desktopFrameMargin: 32,
mobileFrameMargin: 8,
fallbackScale: 0.8,
scaleTransitionMs: 180,
};
export default function Viewer() {
const { id } = useParams();
const navigate = useNavigate();
const [ad, setAd] = useState(null);
const [apps, setApps] = useState([]);
const [frame, setFrame] = useState('phone');
const [rotated, setRotated] = useState(false);
const [editing, setEditing] = useState(false);
const stageRef = useRef();
const [stageDims, setStageDims] = useState(() => ({
width: window.innerWidth - VIEWER_LAYOUT.sidebarWidth,
height: window.innerHeight,
}));
useEffect(() => {
getAd(id).then(setAd).catch(() => navigate('/'));
listApps().then(setApps).catch(() => {});
}, [id, navigate]);
useEffect(() => {
if (!stageRef.current) return undefined;
let rafId = null;
const measure = () => {
if (rafId) cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const rect = stageRef.current?.getBoundingClientRect();
if (!rect) return;
setStageDims({ width: rect.width, height: rect.height });
});
};
const ro = new ResizeObserver(measure);
ro.observe(stageRef.current);
window.addEventListener('resize', measure);
window.addEventListener('orientationchange', measure);
measure();
return () => {
if (rafId) cancelAnimationFrame(rafId);
ro.disconnect();
window.removeEventListener('resize', measure);
window.removeEventListener('orientationchange', measure);
};
}, []);
const scale = useMemo(() => {
const { width: fw, height: fh } = getFrameSize(frame, rotated);
const { width: sw, height: sh } = stageDims;
if (!sw || !sh) return VIEWER_LAYOUT.fallbackScale;
const margin = sw <= VIEWER_LAYOUT.mobileBreakpoint
? VIEWER_LAYOUT.mobileFrameMargin
: VIEWER_LAYOUT.desktopFrameMargin;
return Math.min(1, (sw - margin) / fw, (sh - margin) / fh);
}, [stageDims, frame, rotated]);
async function handleSave({ newFile, ...data }) {
if (newFile) await updateAdFile(id, newFile);
const updated = await updateAd(id, data);
setAd(updated);
setEditing(false);
}
if (!ad) return <div className="viewer__loading">Loading...</div>;
const app = apps.find(a => a.id === ad.appId);
const sidebar = (
<>
<div className="sb-brand">Playable Showcase</div>
<div className="sb-nav">
<button className="sb-btn" onClick={() => navigate('/')}>Back</button>
</div>
<div className="sb-divider" />
<span className="sb-section-label">Playable</span>
<div className="viewer__sb-info">
<span className="viewer__sb-title">{ad.title}</span>
{ad.developer && <span className="viewer__sb-developer">{ad.developer}</span>}
</div>
{app && (
<>
<div className="sb-divider" />
<span className="sb-section-label">Application</span>
<div className="viewer__sb-info">
<div className="viewer__app-row">
{app.icon_url
? <img src={app.icon_url} className="viewer__app-icon" alt="" onError={e => { e.target.style.display = 'none'; }} />
: <div className="viewer__app-icon viewer__app-icon--placeholder" />
}
<span className="viewer__app-name">{app.name}</span>
</div>
{(app.ios_url || app.android_url) && (
<div className="viewer__store-links">
{app.ios_url && (
<a href={app.ios_url} target="_blank" rel="noreferrer" className="viewer__store-link viewer__store-link--ios">
App Store
</a>
)}
{app.android_url && (
<a href={app.android_url} target="_blank" rel="noreferrer" className="viewer__store-link viewer__store-link--android">
Google Play
</a>
)}
</div>
)}
</div>
</>
)}
{ad.repo && (
<>
<div className="sb-divider" />
<span className="sb-section-label">Repo</span>
<div className="viewer__sb-info">
<span className="viewer__sb-mono">{ad.repo}@{ad.branch || 'main'}</span>
</div>
</>
)}
{ad.tags?.length > 0 && (
<>
<div className="sb-divider" />
<span className="sb-section-label">Tags</span>
<div className="viewer__sb-info">
<div className="viewer__tags">
{ad.tags.map(t => <span key={t} className="tag">{t}</span>)}
</div>
</div>
</>
)}
<div className="viewer__spacer" />
<div className="sb-divider" />
<div className="viewer__controls">
<span className="sb-section-label" style={{ padding: '8px 14px 4px' }}>Frame</span>
<div className="viewer__frame-btns">
{Object.entries(FRAMES).map(([key, { label }]) => (
<button
key={key}
className={`viewer__frame-btn${frame === key ? ' active' : ''}`}
onClick={() => { setFrame(key); setRotated(false); }}
>
{label}
</button>
))}
</div>
<div className="viewer__ctrl-row">
<button
className={`viewer__ctrl-btn${rotated ? ' active' : ''}`}
onClick={() => setRotated(r => !r)}
title="Rotate"
>
Rotate
</button>
</div>
<button className="btn-primary viewer__edit-btn" onClick={() => setEditing(true)}>Edit</button>
</div>
</>
);
return (
<AppLayout sidebar={sidebar}>
<div className="viewer__stage" ref={stageRef}>
<div
className="viewer__frame-wrapper"
style={{
transform: `translate(-50%, -50%) scale(${scale})`,
transition: `transform ${VIEWER_LAYOUT.scaleTransitionMs}ms ease`,
}}
>
<DeviceFrame
frame={frame}
filename={ad.filename}
rotated={rotated}
/>
</div>
</div>
{editing && (
<MetadataModal ad={ad} apps={apps} onSave={handleSave} onClose={() => setEditing(false)} />
)}
</AppLayout>
);
}