Initial Commit
This commit is contained in:
251
client/src/pages/Apps.css
Normal file
251
client/src/pages/Apps.css
Normal 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
97
client/src/pages/Apps.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
client/src/pages/Catalog.css
Normal file
49
client/src/pages/Catalog.css
Normal 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;
|
||||
}
|
||||
}
|
||||
190
client/src/pages/Catalog.jsx
Normal file
190
client/src/pages/Catalog.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
client/src/pages/Login.css
Normal file
68
client/src/pages/Login.css
Normal 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;
|
||||
}
|
||||
50
client/src/pages/Login.jsx
Normal file
50
client/src/pages/Login.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
client/src/pages/Upload.css
Normal file
92
client/src/pages/Upload.css
Normal 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
105
client/src/pages/Upload.jsx
Normal 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
190
client/src/pages/Viewer.css
Normal 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
206
client/src/pages/Viewer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user