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

@@ -0,0 +1,356 @@
const express = require('express');
const fs = require('fs');
const db = require('../../db');
const { encrypt, decrypt } = require('../../crypto-helper');
const { authenticate } = require('../../middleware/auth');
const router = express.Router();
// ── MakerWorld Creator Center Statistik ──────────────────────────────────────
// Strategie: echtes (headless) Chromium mit PERSISTENTEM Profil unter /data
// (überlebt Container-Neustarts, da /data bereits als Volume gemountet ist).
// Ein echter Browser mit gültigem, langlebigem Profil löst Cloudflares
// JS-Challenge (cf_clearance) selbstständig — dafür ist kein manuelles
// Cookie-Kopieren mehr nötig. Nur wenn die eigentliche Login-Session (nicht
// nur Cloudflare) abgelaufen ist, wird automatisch neu eingeloggt; verlangt
// MakerWorld dabei einen E-Mail-Verifizierungscode, wird der Vorgang pausiert
// und wartet auf Eingabe des Codes über die App (POST /verify-code).
//
// HINWEIS: Die Login-Formular-Selektoren sind best-effort (Bambu Lab nutzt
// einen Cross-Domain-SSO-Flow über bambulab.com, dessen genaues Markup wir
// nicht vorab kennen). Falls der automatische Login fehlschlägt, meldet die
// App einen klaren Fehler statt stillschweigend hängen zu bleiben — dann bitte
// einen HAR-Export des echten Login-Vorgangs schicken, um die Selektoren zu fixen.
const DATA_URL = 'https://makerworld.com/de/my/data-overview/model';
const PROFILE_DIR = '/data/makerworld-profile';
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 Minuten zwischen automatischen Neuabrufen
const LOGIN_CODE_TIMEOUT_MS = 5 * 60 * 1000; // 5 Minuten Zeit, den E-Mail-Code einzugeben
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const cache = { data: null, fetchedAt: 0, error: null };
let pendingLogin = null; // { browser, page, codeField, startedAt } während auf E-Mail-Code gewartet wird
// Alle Browser-Operationen seriell ausführen (gleiche Profil-Datei darf nicht
// von zwei Chromium-Prozessen gleichzeitig verwendet werden)
let opQueue = Promise.resolve();
function serialized(fn) {
const run = opQueue.then(fn, fn);
opQueue = run.catch(() => {});
return run;
}
// ── Zugangsdaten ──────────────────────────────────────────────────────────────
function getCredentials() {
const username = db.prepare("SELECT value FROM admin_settings WHERE key='makerworld_username'").get()?.value;
const passEnc = db.prepare("SELECT value FROM admin_settings WHERE key='makerworld_password_enc'").get()?.value;
if (!username || !passEnc) return null;
const password = decrypt(passEnc);
if (!password) return null;
return { username, password };
}
function saveCredentials(username, password) {
db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('makerworld_username', ?)").run(username);
db.prepare("INSERT OR REPLACE INTO admin_settings (key,value) VALUES ('makerworld_password_enc', ?)").run(encrypt(password));
}
// ── __NEXT_DATA__ Extraktion (über Tag-Grenzen, kein Regex auf den JSON-Inhalt) ─
function extractNextData(html) {
const marker = '<script id="__NEXT_DATA__"';
const markerIdx = html.indexOf(marker);
if (markerIdx === -1) return null;
const tagEnd = html.indexOf('>', markerIdx) + 1;
if (tagEnd === 0) return null;
const scriptEnd = html.indexOf('</script>', tagEnd);
if (scriptEnd === -1) return null;
try { return JSON.parse(html.slice(tagEnd, scriptEnd)); }
catch { return null; }
}
function mapStatisticalData(parsed) {
const statisticalData = parsed?.props?.pageProps?.statisticalData;
if (!statisticalData?.summary) throw new Error('Erwartete Datenstruktur nicht gefunden (summary fehlt)');
const s = statisticalData.summary;
return {
downloads: s.download ?? 0,
prints: s.print ?? 0,
likes: s.like ?? 0,
collections: s.collect ?? 0,
boosts: s.boost ?? 0,
followers: s.follower ?? 0,
views: s.view ?? 0,
impressions: s.impression ?? 0,
creatorPoints: s.point ?? 0,
modelPoints: s.pointFromModel ?? 0,
instructionPoints: s.pointFromInst ?? 0,
models: (statisticalData.statisticalList || []).map(m => ({
id: m.designId,
title: m.title || m.titleTranslated || '',
cover: m.cover || '',
publishedAt: m.publishTime || '',
downloads: m.download ?? 0,
prints: m.print ?? 0,
likes: m.like ?? 0,
collections: m.collect ?? 0,
boosts: m.boost ?? 0,
views: m.view ?? 0,
impressions: m.impression ?? 0,
points: m.point ?? 0,
})),
};
}
// ── Browser-Helfer ────────────────────────────────────────────────────────────
async function launchBrowser() {
let puppeteer;
try { puppeteer = require('puppeteer-core'); }
catch { throw new Error('puppeteer-core nicht installiert'); }
fs.mkdirSync(PROFILE_DIR, { recursive: true });
return puppeteer.launch({
executablePath: '/usr/bin/chromium-browser',
userDataDir: PROFILE_DIR,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
headless: true,
});
}
async function getNextDataFromPage(page) {
const html = await page.content();
return extractNextData(html);
}
async function isCloudflareChallenge(page) {
const title = await page.title().catch(() => '');
return /just a moment|checking your browser|attention required/i.test(title);
}
function isLoginUrl(url) {
return /bambulab\.com\/[^/]*\/?sign-in|makerworld\.com\/[a-z]{2}\/sign-in/i.test(url || '');
}
async function findFirstSelector(page, selectors) {
for (const sel of selectors) {
const el = await page.$(sel).catch(() => null);
if (el) return sel;
}
return null;
}
// Best-effort Login-Formular ausfüllen. Wirft einen Error mit .needsCode=true,
// falls danach eine Code-Eingabe (E-Mail-Verifizierung) erkannt wird — der
// Browser/die Page bleiben dann bewusst OFFEN in `pendingLogin`.
async function attemptLogin(browser, page) {
const creds = getCredentials();
if (!creds) throw new Error('Login-Seite erkannt, aber keine MakerWorld-Zugangsdaten im Admin-Bereich hinterlegt');
const emailField = await findFirstSelector(page, [
'input[type="email"]', 'input[name="account"]', 'input[name="username"]', 'input[type="text"]',
]);
const pwField = await findFirstSelector(page, ['input[type="password"]']);
if (!emailField || !pwField) {
throw new Error('Login-Formular nicht gefunden (Selektoren evtl. veraltet) — bitte HAR der Login-Seite schicken, dann passe ich das an');
}
await page.type(emailField, creds.username, { delay: 25 });
await page.type(pwField, creds.password, { delay: 25 });
const submitBtn = await findFirstSelector(page, ['button[type="submit"]']);
if (submitBtn) await page.click(submitBtn); else await page.keyboard.press('Enter');
await new Promise(r => setTimeout(r, 3000));
// Direkt erfolgreich (bekanntes Gerät, kein Code nötig)?
let parsed = await getNextDataFromPage(page);
if (parsed) return parsed;
if (!page.url().includes('data-overview')) {
await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {});
parsed = await getNextDataFromPage(page);
if (parsed) return parsed;
}
const codeField = await findFirstSelector(page, [
'input[name="code"]', 'input[autocomplete="one-time-code"]', 'input[type="tel"]',
]);
if (codeField) {
const startedAt = Date.now();
pendingLogin = { browser, page, codeField, startedAt };
setTimeout(() => {
if (pendingLogin && pendingLogin.startedAt === startedAt) {
pendingLogin.browser.close().catch(() => {});
pendingLogin = null;
}
}, LOGIN_CODE_TIMEOUT_MS);
const err = new Error('Verifizierungscode per E-Mail erforderlich');
err.needsCode = true;
throw err;
}
throw new Error('Login fehlgeschlagen (weder Daten noch Code-Feld erkannt) — evtl. falsche Zugangsdaten oder geändertes Seitenlayout');
}
async function fetchStatsViaBrowserInner(forceRefresh) {
if (!forceRefresh && cache.data && (Date.now() - cache.fetchedAt) < CACHE_TTL_MS) {
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
}
if (pendingLogin) {
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
}
let browser;
try {
browser = await launchBrowser();
const page = await browser.newPage();
await page.setUserAgent(USER_AGENT);
await page.setViewport({ width: 1366, height: 900 });
await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 45000 });
for (let i = 0; i < 3 && await isCloudflareChallenge(page); i++) {
await new Promise(r => setTimeout(r, 4000));
}
let parsed = await getNextDataFromPage(page);
if (!parsed && isLoginUrl(page.url())) {
parsed = await attemptLogin(browser, page); // wirft ggf. needsCode-Error
}
if (!parsed) {
throw new Error('__NEXT_DATA__ nicht gefunden (weder Daten noch erkennbare Login-/Challenge-Seite) — Seitenlayout evtl. geändert');
}
const data = mapStatisticalData(parsed);
cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
await browser.close();
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
} catch (e) {
if (e.needsCode) {
// Browser/Page bewusst NICHT schließen — liegt in pendingLogin bereit
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: true };
}
if (browser) await browser.close().catch(() => {});
cache.error = e.message;
return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false };
}
}
async function submitLoginCodeInner(code) {
if (!pendingLogin) {
return { data: cache.data, error: 'Kein offener Login-Vorgang', fetchedAt: cache.fetchedAt, needsCode: false };
}
const { browser, page, codeField } = pendingLogin;
try {
await page.type(codeField, code, { delay: 25 });
const submitBtn = await findFirstSelector(page, ['button[type="submit"]']);
if (submitBtn) await page.click(submitBtn); else await page.keyboard.press('Enter');
await new Promise(r => setTimeout(r, 3000));
let parsed = await getNextDataFromPage(page);
if (!parsed && !page.url().includes('data-overview')) {
await page.goto(DATA_URL, { waitUntil: 'networkidle2', timeout: 30000 }).catch(() => {});
parsed = await getNextDataFromPage(page);
}
if (parsed) {
const data = mapStatisticalData(parsed);
cache.data = data; cache.fetchedAt = Date.now(); cache.error = null;
pendingLogin = null;
await browser.close();
return { data: cache.data, error: null, fetchedAt: cache.fetchedAt, needsCode: false };
}
// Code vermutlich falsch — Vorgang bleibt offen für einen weiteren Versuch
// (solange das Zeitfenster noch läuft)
return { data: cache.data, error: 'Code wurde nicht akzeptiert, bitte erneut versuchen', fetchedAt: cache.fetchedAt, needsCode: true };
} catch (e) {
await browser.close().catch(() => {});
pendingLogin = null;
cache.error = e.message;
return { data: cache.data, error: cache.error, fetchedAt: cache.fetchedAt, needsCode: false };
}
}
// Manuellen Cookie-String (Fallback) ins persistente Profil einspielen
async function injectCookiesInner(cookieString) {
const cookies = cookieString.split(';').map(s => s.trim()).filter(Boolean).map(p => {
const idx = p.indexOf('=');
return { name: p.slice(0, idx), value: p.slice(idx + 1), domain: '.makerworld.com', path: '/' };
});
const browser = await launchBrowser();
try {
const page = await browser.newPage();
await page.goto('https://makerworld.com', { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
await page.setCookie(...cookies);
} finally {
await browser.close();
}
}
const fetchStatsViaBrowser = (forceRefresh) => serialized(() => fetchStatsViaBrowserInner(forceRefresh));
const submitLoginCode = (code) => serialized(() => submitLoginCodeInner(code));
const injectCookies = (cookieString) => serialized(() => injectCookiesInner(cookieString));
// ── Routes ────────────────────────────────────────────────────────────────────
// GET /stats für alle eingeloggten Nutzer (reine Anzeige)
router.get('/stats', authenticate, async (req, res) => {
const configured = !!getCredentials() || !!cache.fetchedAt;
if (!configured) {
return res.json({ configured: false, data: null, error: null, fetchedAt: null, needsCode: false });
}
const result = await fetchStatsViaBrowser(false);
res.json({ configured: true, ...result });
});
// POST /refresh erzwungener Neu-Abruf (Admin)
router.post('/refresh', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const result = await fetchStatsViaBrowser(true);
res.json(result);
});
// POST /verify-code E-Mail-Verifizierungscode eingeben (Admin)
router.post('/verify-code', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const { code } = req.body;
if (!code || typeof code !== 'string') return res.status(400).json({ error: 'Code fehlt' });
const result = await submitLoginCode(code.trim());
res.json(result);
});
// GET /login-status Diagnose für Admin (Passwort wird nie zurückgegeben)
router.get('/login-status', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const creds = getCredentials();
res.json({
configured: !!creds,
username: creds?.username || null,
lastFetchedAt: cache.fetchedAt || null,
lastError: cache.error || null,
needsCode: !!pendingLogin,
});
});
// POST /credentials MakerWorld-Zugangsdaten speichern (verschlüsselt) + Testabruf (Admin)
router.post('/credentials', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' });
saveCredentials(username.trim(), password);
const result = await fetchStatsViaBrowser(true);
res.json(result);
});
// POST /cookie manueller Fallback: Cookie-String direkt ins Profil einspielen (Admin)
router.post('/cookie', authenticate, async (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const { cookie } = req.body;
if (!cookie || typeof cookie !== 'string' || cookie.trim().length < 10) {
return res.status(400).json({ error: 'Ungültiger Cookie-String' });
}
try {
await injectCookies(cookie.trim());
const result = await fetchStatsViaBrowser(true);
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
module.exports = router;

View File

@@ -3933,7 +3933,7 @@ export default function App() {
<main ref={mainRef} style={mainStyle}> <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==='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}/>} {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> </main>
</div> </div>
{mobile && ( {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 [fromDate, setFromDate] = useState('');
const [toDate, setToDate] = useState(todayLocal()); const [toDate, setToDate] = useState(todayLocal());
const [data, setData] = useState(null); const [data, setData] = useState(null);
@@ -269,7 +516,7 @@ export default function Statistik({ mobile }) {
{/* ── Tabs ── */} {/* ── Tabs ── */}
<div style={{display:'flex',gap:4,marginBottom:16}}> <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={{ <button key={id} onClick={()=>setTab(id)} style={{
background: tab===id ? 'rgba(255,255,255,0.08)' : 'transparent', 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)'}`, 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' && ( {tab==='orders' && (
<OrdersInPeriod fromDate={fromDate} toDate={toDate}/> <OrdersInPeriod fromDate={fromDate} toDate={toDate}/>
)} )}
{/* ══ MAKERWORLD ══ */}
{tab==='makerworld' && (
<MakerworldTab isAdmin={user?.role==='admin'} toast={toast}/>
)}
</div> </div>
); );
} }