feat: MakerWorld Creator-Center Statistik-Tab (Cookie-basierter HTML-Fetch, __NEXT_DATA__-Extraktion)

This commit is contained in:
2026-07-11 02:07:36 +02:00
parent debb31ce4c
commit fd34c0d6ec
3 changed files with 611 additions and 3 deletions

View File

@@ -3933,7 +3933,7 @@ export default function App() {
<main ref={mainRef} style={mainStyle}>
{active==='dashboard' && <Dashboard key={dashboardKey} toast={toast} mobile={mobile} setActive={setActive} setDevToolsNav={setDevToolsNav} unreadMsgs={unreadMsgs} unreadBoard={unreadBoard} unreadPromoted={unreadPromoted} onBoardRead={()=>{ setUnreadBoard(0); setUnreadPromoted(0); }} unreadChangelog={unreadChangelog} onChangelogRead={()=>setUnreadChangelog(0)} unackedFavs={unackedFavs} onClearUnackedFavs={()=>setUnackedFavs({count:0,isAdmin:false})} user={user}/>}
{active==='admin' && <AdminPanel toast={toast} mobile={mobile} user={user} nav={devToolsNav}/>}
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive} {...(activeTool.id==='devtools'?{nav:devToolsNav}:{})} {...(activeTool.id==='media' ? {initTab:mediaInitTab, onInitTabConsumed:()=>setMediaInitTab(null)} : {})}/>}
{activeTool && <activeTool.component toast={toast} mobile={mobile} setActive={setActive} user={user} {...(activeTool.id==='devtools'?{nav:devToolsNav}:{})} {...(activeTool.id==='media' ? {initTab:mediaInitTab, onInitTabConsumed:()=>setMediaInitTab(null)} : {})}/>}
</main>
</div>
{mobile && (

View File

@@ -197,7 +197,254 @@ function OrdersInPeriod({ fromDate, toDate }) {
);
}
export default function Statistik({ mobile }) {
// MakerWorld Creator-Center Statistik
function MakerworldTab({ isAdmin, toast }) {
const [state, setState] = useState(null); // { configured, data, error, fetchedAt, needsCode }
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [showModels, setShowModels] = useState(false);
const [loginStatus, setLoginStatus] = useState(null);
const [credForm, setCredForm] = useState({ username:'', password:'' });
const [savingCreds, setSavingCreds] = useState(false);
const [codeInput, setCodeInput] = useState('');
const [submittingCode, setSubmittingCode] = useState(false);
const [cookieInput, setCookieInput] = useState('');
const [savingCookie, setSavingCookie] = useState(false);
const [showCookieFallback, setShowCookieFallback] = useState(false);
const load = () => {
setLoading(true);
api('/tools/makerworld/stats').then(setState).catch(()=>{}).finally(()=>setLoading(false));
};
const loadLoginStatus = () => {
if (!isAdmin) return;
api('/tools/makerworld/login-status').then(setLoginStatus).catch(()=>{});
};
useEffect(() => { load(); loadLoginStatus(); }, []);
// Solange auf einen Code gewartet wird, alle paar Sekunden pollen
useEffect(() => {
if (!state?.needsCode) return;
const iv = setInterval(() => { load(); loadLoginStatus(); }, 5000);
return () => clearInterval(iv);
}, [state?.needsCode]);
const refresh = async () => {
setRefreshing(true);
try {
const r = await api('/tools/makerworld/refresh', { method:'POST' });
setState(p => ({ ...p, ...r }));
loadLoginStatus();
if (r.needsCode) toast?.('MakerWorld verlangt einen E-Mail-Verifizierungscode');
else if (r.error) toast?.(r.error,'error');
else toast?.('Aktualisiert');
} catch(e) { toast?.(e.message,'error'); }
finally { setRefreshing(false); }
};
const saveCredentials = async () => {
if (!credForm.username.trim() || !credForm.password) return;
setSavingCreds(true);
try {
const r = await api('/tools/makerworld/credentials', { body:credForm });
setCredForm({ username:'', password:'' });
setState(p => ({ ...p, ...r, configured:true }));
loadLoginStatus();
if (r.needsCode) toast?.('Zugangsdaten gespeichert — E-Mail-Code erforderlich');
else if (r.error) toast?.('Gespeichert, aber Testabruf fehlgeschlagen: '+r.error,'error');
else toast?.('Zugangsdaten gespeichert, Abruf erfolgreich');
} catch(e) { toast?.(e.message,'error'); }
finally { setSavingCreds(false); }
};
const submitCode = async () => {
if (!codeInput.trim()) return;
setSubmittingCode(true);
try {
const r = await api('/tools/makerworld/verify-code', { body:{ code:codeInput.trim() } });
setState(p => ({ ...p, ...r }));
loadLoginStatus();
if (r.needsCode) toast?.(r.error || 'Code nicht akzeptiert, nochmal versuchen','error');
else if (r.error) toast?.(r.error,'error');
else { toast?.('Login erfolgreich'); setCodeInput(''); }
} catch(e) { toast?.(e.message,'error'); }
finally { setSubmittingCode(false); }
};
const saveCookie = async () => {
if (!cookieInput.trim()) return;
setSavingCookie(true);
try {
const r = await api('/tools/makerworld/cookie', { body:{ cookie: cookieInput } });
setCookieInput('');
setState(p => ({ ...p, ...r, configured:true }));
if (r.error) toast?.('Gespeichert, aber Testabruf fehlgeschlagen: '+r.error,'error');
else toast?.('Cookie übernommen, Abruf erfolgreich');
loadLoginStatus();
} catch(e) { toast?.(e.message,'error'); }
finally { setSavingCookie(false); }
};
if (loading) return <div style={{color:'rgba(255,255,255,0.3)',fontFamily:'monospace',fontSize:12}}>Lädt</div>;
const d = state?.data;
const fmtInt = v => (v ?? 0).toLocaleString('de-DE');
const fmtAgo = ts => {
if (!ts) return '—';
const min = Math.round((Date.now()-ts)/60000);
if (min < 1) return 'gerade eben';
if (min < 60) return `vor ${min} Min.`;
return `vor ${Math.round(min/60)} Std.`;
};
return (
<div>
{/* Admin: Code-Eingabe hat höchste Priorität, wenn gerade angefordert */}
{isAdmin && state?.needsCode && (
<div style={{...S.card,borderColor:'rgba(255,230,109,0.4)',background:'rgba(255,230,109,0.08)'}}>
<div style={{...S.head,color:'#ffe66d'}}>📧 VERIFIZIERUNGSCODE ERFORDERLICH</div>
<div style={{...S.sub,marginBottom:8}}>
MakerWorld hat einen Code an deine E-Mail-Adresse geschickt. Bitte hier eintragen
(5 Minuten Zeit, danach muss der Login-Versuch neu gestartet werden).
</div>
<div style={{display:'flex',gap:8}}>
<input value={codeInput} onChange={e=>setCodeInput(e.target.value)}
onKeyDown={e=>e.key==='Enter'&&submitCode()}
placeholder="Code aus E-Mail" style={{...S.inp,flex:1}}/>
<button onClick={submitCode} disabled={submittingCode||!codeInput.trim()}
style={{...S.btn('#ffe66d'),opacity:(submittingCode||!codeInput.trim())?0.5:1,whiteSpace:'nowrap'}}>
{submittingCode?'…':'✓ Bestätigen'}
</button>
</div>
</div>
)}
{/* Admin: Zugangsdaten */}
{isAdmin && !state?.needsCode && (
<div style={S.card}>
<div style={S.head}>MAKERWORLD-LOGIN (ADMIN)</div>
{loginStatus && (
<div style={{...S.sub,marginBottom:10}}>
{loginStatus.configured
? `Eingerichtet als "${loginStatus.username}" · Letzter Abruf: ${fmtAgo(loginStatus.lastFetchedAt)}`
: 'Noch keine Zugangsdaten hinterlegt.'}
{loginStatus.lastError && <div style={{color:'#f87171',marginTop:4}}> {loginStatus.lastError}</div>}
</div>
)}
<div style={{...S.sub,marginBottom:8}}>
Wird verschlüsselt gespeichert. Ein echter Browser mit dauerhaftem Profil hält die Session
am Laufen bei aktiver Nutzung wird ein erneuter Login kaum je nötig. Falls doch, erscheint
hier automatisch die Code-Eingabe.
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap',marginBottom:8}}>
<input value={credForm.username} onChange={e=>setCredForm(p=>({...p,username:e.target.value}))}
placeholder="E-Mail / Benutzername" style={{...S.inp,flex:1,minWidth:160}}/>
<input type="password" value={credForm.password} onChange={e=>setCredForm(p=>({...p,password:e.target.value}))}
onKeyDown={e=>e.key==='Enter'&&saveCredentials()}
placeholder="Passwort" style={{...S.inp,flex:1,minWidth:160}}/>
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
<button onClick={saveCredentials} disabled={savingCreds||!credForm.username.trim()||!credForm.password}
style={{...S.btn('#4ade80'),opacity:(savingCreds||!credForm.username.trim()||!credForm.password)?0.5:1}}>
{savingCreds?'…':'💾 Speichern & testen'}
</button>
<button onClick={refresh} disabled={refreshing}
style={{...S.btn('#4ecdc4'),opacity:refreshing?0.5:1}}>
{refreshing?'…':'🔄 Neu abrufen'}
</button>
<button onClick={()=>setShowCookieFallback(p=>!p)} style={{...S.btn('#888888')}}>
{showCookieFallback ? '▲ Cookie-Fallback ausblenden' : '⚙ Cookie-Fallback (manuell)'}
</button>
</div>
{showCookieFallback && (
<div style={{marginTop:14,paddingTop:14,borderTop:'1px solid rgba(255,255,255,0.07)'}}>
<div style={{...S.sub,marginBottom:8}}>
Nur falls der automatische Login nicht funktioniert: kompletten Cookie-Header aus einem
eingeloggten Browser-Tab kopieren (Entwicklertools Netzwerk Request an
/de/my/data-overview/model "Cookie"-Header) und hier einfügen.
</div>
<textarea value={cookieInput} onChange={e=>setCookieInput(e.target.value)}
placeholder="lang=de; cf_clearance=...; token=...; ..."
rows={3} style={{...S.inp,resize:'vertical',fontSize:10,marginBottom:8}}/>
<button onClick={saveCookie} disabled={savingCookie||!cookieInput.trim()}
style={{...S.btn('#4ecdc4'),opacity:(savingCookie||!cookieInput.trim())?0.5:1}}>
{savingCookie?'…':'💾 Cookie übernehmen & testen'}
</button>
</div>
)}
</div>
)}
{!state?.configured && !isAdmin && (
<div style={S.card}>
<div style={{color:'rgba(255,255,255,0.4)',fontFamily:'monospace',fontSize:12}}>
MakerWorld-Anbindung ist noch nicht eingerichtet.
</div>
</div>
)}
{state?.error && d && (
<div style={{...S.card,borderColor:'rgba(248,113,113,0.3)',background:'rgba(248,113,113,0.06)'}}>
<div style={{color:'#f87171',fontFamily:'monospace',fontSize:11}}>
Letzter Abruf fehlgeschlagen ({state.error}) zeige zuletzt bekannten Stand.
</div>
</div>
)}
{d && (
<>
<div style={S.card}>
<div style={S.head}>CREATOR CENTER {state.fetchedAt ? `· ${fmtAgo(state.fetchedAt)}` : ''}</div>
<div style={{display:'flex',flexWrap:'wrap',gap:20,marginBottom:16}}>
<KPI label="Downloads" value={fmtInt(d.downloads)} color="#4ecdc4"/>
<KPI label="Prints" value={fmtInt(d.prints)} color="#4ecdc4"/>
<KPI label="Likes" value={fmtInt(d.likes)} color="#ff6b9d"/>
<KPI label="Collections" value={fmtInt(d.collections)} color="#ff6b9d"/>
<KPI label="Boosts" value={fmtInt(d.boosts)} color="#ffe66d"/>
<KPI label="Follower" value={fmtInt(d.followers)} color="#ffe66d"/>
</div>
<div style={{borderTop:'1px solid rgba(255,255,255,0.06)',paddingTop:16,display:'flex',flexWrap:'wrap',gap:20}}>
<KPI label="Views" value={fmtInt(d.views)} color="#60a5fa"/>
<KPI label="Impressions" value={fmtInt(d.impressions)} color="#60a5fa"/>
<KPI label="Creator Points" value={fmtInt(d.creatorPoints)} color="#c084fc"/>
<KPI label="Model Points" value={fmtInt(d.modelPoints)} color="#c084fc" sub="aus Modellen"/>
<KPI label="MakerLab Points" value={fmtInt(d.instructionPoints)} color="#c084fc" sub="aus Anleitungen"/>
</div>
</div>
{d.models?.length > 0 && (
<div style={S.card}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:showModels?10:0}}>
<div style={{...S.head,marginBottom:0}}>MODELLE ({d.models.length})</div>
<button onClick={()=>setShowModels(p=>!p)} style={{...S.btn('#888888'),fontSize:10}}>
{showModels ? '▲ einklappen' : '▼ anzeigen'}
</button>
</div>
{showModels && [...d.models].sort((a,b)=>b.downloads-a.downloads).map(m => (
<div key={m.id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 0',
borderBottom:'1px solid rgba(255,255,255,0.05)',flexWrap:'wrap'}}>
<span style={{color:'rgba(255,255,255,0.8)',fontFamily:'monospace',fontSize:12,flex:1,minWidth:160}}>
{m.title}
</span>
<span style={{color:'#4ecdc4',fontFamily:"'Space Mono',monospace",fontSize:11}}>{fmtInt(m.downloads)}</span>
<span style={{color:'#ff6b9d',fontFamily:"'Space Mono',monospace",fontSize:11}}>{fmtInt(m.likes)}</span>
<span style={{color:'#60a5fa',fontFamily:"'Space Mono',monospace",fontSize:11}}>👁{fmtInt(m.views)}</span>
<span style={{color:'#c084fc',fontFamily:"'Space Mono',monospace",fontSize:11}}>{m.points?.toFixed?.(1) ?? m.points}pt</span>
</div>
))}
</div>
)}
</>
)}
</div>
);
}
export default function Statistik({ mobile, user, toast }) {
const [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState(todayLocal());
const [data, setData] = useState(null);
@@ -269,7 +516,7 @@ export default function Statistik({ mobile }) {
{/* ── Tabs ── */}
<div style={{display:'flex',gap:4,marginBottom:16}}>
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen']].map(([id,label])=>(
{[['overview','📈 Übersicht'],['revenue','💰 Einnahmen'],['expenses','💸 Ausgaben'],['orders','📦 Bestellungen'],['makerworld','🖨️ MakerWorld']].map(([id,label])=>(
<button key={id} onClick={()=>setTab(id)} style={{
background: tab===id ? 'rgba(255,255,255,0.08)' : 'transparent',
border: `1px solid ${tab===id ? 'rgba(255,255,255,0.2)' : 'rgba(255,255,255,0.08)'}`,
@@ -504,6 +751,11 @@ export default function Statistik({ mobile }) {
{tab==='orders' && (
<OrdersInPeriod fromDate={fromDate} toDate={toDate}/>
)}
{/* ══ MAKERWORLD ══ */}
{tab==='makerworld' && (
<MakerworldTab isAdmin={user?.role==='admin'} toast={toast}/>
)}
</div>
);
}