piston/src/App.tsx

313 lines
13 KiB
TypeScript

import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { fetchRegistry, getRawFileUrl, BUILTIN_EDITIONS } from './api/registry';
import type { RegistryResponse, MetaJson } from './api/types';
import { Key, X, Download, Upload, ExternalLink } from 'lucide-react';
import UploadModal from './UploadModal';
const GITHUB_CLIENT_ID = 'Ov23lioBHF1VmiCOoYui';
function App() {
const [registry, setRegistry] = useState<RegistryResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [pat, setPat] = useState<string>('');
const [showLogin, setShowLogin] = useState(false);
const [showUpload, setShowUpload] = useState(false);
const [selectedItem, setSelectedItem] = useState<MetaJson | null>(null);
const [filter, setFilter] = useState<string | null>(null);
useEffect(() => {
const savedPat = localStorage.getItem('piston_pat');
if (savedPat) setPat(savedPat);
fetchRegistry()
.then(setRegistry)
.catch(e => setError(e.message));
}, []);
const resolvePath = (path: string) => {
if (path === '') return 'Game Root';
let p = path;
p = p.replace(/{MediaDir}/g, 'Windows64Media');
p = p.replace(/{GameHDD}/g, 'Windows64/GameHDD');
p = p.replace(/{DLCDir}/g, 'Windows64Media/DLC');
p = p.replace(/{MobDir}/g, 'Common/res/mob');
return p;
};
const [userCode, setUserCode] = useState<string | null>(null);
const [verificationUri, setVerificationUri] = useState<string | null>(null);
const [loginStatus, setLoginStatus] = useState<string>('');
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
const initiateDeviceFlow = async () => {
try {
setLoginStatus('Requesting code...');
const proxyRoute = import.meta.env.DEV ? '/github-proxy' : 'https://corsproxy.io/?url=https://github.com';
const res = await fetch(`${proxyRoute}/login/device/code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
scope: 'public_repo'
})
});
const data = await res.json();
if (data.user_code) {
setUserCode(data.user_code);
setVerificationUri(data.verification_uri);
setLoginStatus('Waiting for authorization...');
pollForToken(data.device_code, data.interval);
} else {
setLoginStatus('Failed: ' + (data.error || 'Unknown error'));
}
} catch (e: any) {
setLoginStatus('Error: ' + e.message);
}
};
const pollForToken = (dCode: string, initialIntervalSecs: number) => {
let currentInterval = initialIntervalSecs;
let timeoutId: number;
const poll = async () => {
try {
const proxyRoute = import.meta.env.DEV ? '/github-proxy' : 'https://corsproxy.io/?url=https://github.com';
const res = await fetch(`${proxyRoute}/login/oauth/access_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
device_code: dCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
})
});
const data = await res.json();
if (data.access_token) {
setPat(data.access_token);
localStorage.setItem('piston_pat', data.access_token);
closeLogin();
return;
} else if (data.error === 'authorization_pending') {
//neo: still waiting for auth ig
} else if (data.error === 'slow_down') {
if (data.interval) currentInterval = data.interval;
} else {
setLoginStatus('Login failed: ' + data.error);
return;
}
} catch (e) {
setLoginStatus('Polling error.');
return;
}
timeoutId = window.setTimeout(poll, currentInterval * 1000);
setPollingInterval(timeoutId);
};
timeoutId = window.setTimeout(poll, currentInterval * 1000);
setPollingInterval(timeoutId);
};
const closeLogin = () => {
setShowLogin(false);
setUserCode(null);
setVerificationUri(null);
setLoginStatus('');
if (pollingInterval) window.clearTimeout(pollingInterval);
};
const handleLogout = () => {
setPat('');
localStorage.removeItem('piston_pat');
};
return (
<div className="mc-container">
<header style={{
background: '#373737',
padding: '1rem 2rem',
margin: '-2rem -2rem 2rem -2rem',
borderBottom: '6px solid #1d1d1d',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<img src="./piston.png" width={45} height={45} />
<h1 style={{ margin: 0, textShadow: '2px 2px 0 #1d1d1d', color: '#fff', fontSize: '2.5rem', letterSpacing: '2px' }}>Piston</h1>
<span style={{ color: '#aaa', fontSize: '1.2rem', marginTop: '0.5rem', marginLeft: '0.5rem' }}>Emerald Workshop</span>
</div>
<div style={{ display: 'flex', gap: '1rem' }}>
{pat ? (
<>
<button className="mc-button" onClick={() => setShowUpload(true)}>
<Upload size={18} style={{ verticalAlign: 'middle', marginRight: '5px' }} /> Upload
</button>
<button className="mc-button" onClick={handleLogout}>Log out</button>
</>
) : (
<button className="mc-button" onClick={() => setShowLogin(true)}>
<Key size={18} style={{ verticalAlign: 'middle', marginRight: '5px' }} /> Login
</button>
)}
</div>
</header>
{error && (
<div style={{ background: '#ff5555', color: 'white', padding: '1rem', border: '4px solid #aa0000', marginBottom: '1rem' }}>
Error: {error}
</div>
)}
{!selectedItem ? (
<>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', overflowX: 'auto' }}>
<button
className="mc-button"
style={{ backgroundColor: filter === null ? 'var(--mc-btn-active)' : 'var(--mc-btn)' }}
onClick={() => setFilter(null)}>
All
</button>
{Array.from(new Set(registry?.packages.flatMap(pkg => Array.isArray(pkg.category) ? pkg.category : [pkg.category]) || [])).map(c => (
<button
key={c}
className="mc-button"
style={{ backgroundColor: filter === c ? 'var(--mc-btn-active)' : 'var(--mc-btn)' }}
onClick={() => setFilter(c)}>
{c}
</button>
))}
</div>
<div className="mc-grid">
{registry?.packages.filter(pkg => filter ? (Array.isArray(pkg.category) ? pkg.category.includes(filter) : pkg.category === filter) : true).map((pkg) => (
<div key={pkg.id} className="mc-card" onClick={() => setSelectedItem(pkg)} style={{ cursor: 'pointer' }}>
<img src={getRawFileUrl(pkg.id, pkg.thumbnail)} alt={pkg.name} />
<h3 style={{ margin: '0.5rem 0' }}>{pkg.name}</h3>
<div style={{ color: '#ddd', fontSize: '0.9rem' }}>by {pkg.author}</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{(Array.isArray(pkg.category) ? pkg.category : [pkg.category]).map(c => (
<span key={c} className="mc-badge">{c}</span>
))}
</div>
</div>
))}
</div>
</>
) : (
<div className="mc-detail-view">
<div className="mc-detail-header">
<button className="mc-button" onClick={() => setSelectedItem(null)}> Back to Gallery</button>
</div>
<div className="mc-detail-content">
<div className="mc-detail-left">
<img src={getRawFileUrl(selectedItem.id, selectedItem.thumbnail)} alt={selectedItem.name} className="mc-detail-img" />
<div className="mc-badges">
{(Array.isArray(selectedItem.category) ? selectedItem.category : [selectedItem.category]).map(c => (
<span key={c} className="mc-badge">{c}</span>
))}
</div>
</div>
<div className="mc-detail-right">
<h2 className="mc-title">{selectedItem.name} <span className="mc-version">v{selectedItem.version}</span></h2>
<p className="mc-author">by <strong>{selectedItem.author}</strong></p>
<a href={`emerald://workshop/${selectedItem.id}?normal`} target="_blank" rel="noreferrer" className="mc-button" style={{ backgroundColor: '#2e7d32', color: 'white', display: 'inline-flex', alignItems: 'center', gap: '0.5rem', textDecoration: 'none', marginBottom: '1rem' }}>
Open in Emerald
</a>
<div className="mc-description">
{selectedItem.description}
</div>
{selectedItem.extended_description && selectedItem.extended_description.trim() !== '' && (
<div className="mc-extended-description">
<h3>Details</h3>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{selectedItem.extended_description}
</ReactMarkdown>
</div>
)}
{selectedItem.required_versions && selectedItem.required_versions.length > 0 && (
<div style={{ marginTop: '1.5rem' }}>
<h3 style={{ fontSize: '1rem', textTransform: 'uppercase', color: '#555', textShadow: '1px 1px 0px rgba(255,255,255,0.4)', marginBottom: '0.5rem' }}>Required Editions</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{selectedItem.required_versions.map((verId) => {
const ed = BUILTIN_EDITIONS.find((e) => e.id === verId);
return (
<span key={verId} style={{ background: '#373737', color: '#fff', border: '2px solid #1d1d1d', padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}>
{ed?.name || verId}
</span>
);
})}
</div>
</div>
)}
<h3 style={{ marginTop: '2rem' }}>Downloads & Installation</h3>
<div className="mc-download-list">
{Object.entries(selectedItem.zips).map(([zipName, extractPath]) => (
<div key={zipName} className="mc-download-item">
<div className="mc-download-info">
<strong>{zipName}</strong>
<div className="mc-download-path">Extract to <code>{resolvePath(extractPath)}</code></div>
</div>
<a href={getRawFileUrl(selectedItem.id, zipName)} target="_blank" rel="noreferrer" className="mc-button mc-download-btn">
<Download size={24} />
</a>
</div>
))}
</div>
</div>
</div>
</div>
)}
{showLogin && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 100 }}>
<div className="mc-container" style={{ width: '500px', margin: 0, position: 'relative', textAlign: 'center' }}>
<button style={{ position: 'absolute', top: '-20px', right: '-20px', background: 'red', border: '4px solid #800', color: 'white', padding: '0.5rem', cursor: 'pointer' }} onClick={closeLogin}><X size={24} /></button>
<h2>GitHub Login</h2>
{!userCode ? (
<>
<p style={{ fontSize: '1.1rem', color: '#373737', marginBottom: '2rem' }}>
We use GitHub's Device Flow so you don't need to paste tokens or passwords.
</p>
<div style={{ color: '#900', fontWeight: 'bold', marginBottom: '1rem' }}>{loginStatus}</div>
<button className="mc-button" style={{ fontSize: '1.5rem' }} onClick={initiateDeviceFlow}>Login with GitHub</button>
</>
) : (
<>
<p style={{ fontSize: '1.2rem', color: '#555', marginBottom: '1rem' }}>
Your verification code is:
</p>
<div style={{ fontSize: '3rem', fontWeight: 'bold', letterSpacing: '8px', color: '#fff', background: '#373737', padding: '1rem', border: '4px solid #1d1d1d', margin: '0 2rem 2rem 2rem', userSelect: 'all' }}>
{userCode}
</div>
<p style={{ marginBottom: '1rem' }}>{loginStatus}</p>
<a href={verificationUri || 'https://github.com/login/device'} target="_blank" rel="noreferrer" className="mc-button" style={{ display: 'inline-flex', alignItems: 'center', gap: '0.5rem', textDecoration: 'none' }}>
Open GitHub <ExternalLink size={20} />
</a>
</>
)}
</div>
</div>
)}
{showUpload && <UploadModal onClose={() => setShowUpload(false)} pat={pat} />}
</div>
);
}
export default App;