feat: MakerWorld Creator-Center Statistik-Tab (Cookie-basierter HTML-Fetch, __NEXT_DATA__-Extraktion)
This commit is contained in:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user