mirror of
https://github.com/LCE-Hub/piston.git
synced 2026-07-18 00:07:22 +00:00
313 lines
13 KiB
TypeScript
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;
|