feat: add account keys
This commit is contained in:
parent
7091dc152f
commit
d623dea1e1
@ -30,6 +30,7 @@ const DeveloperTermsPage = React.lazy(() => import('./pages/legal/DeveloperTerms
|
||||
const LegalNoticePage = React.lazy(() => import('./pages/legal/LegalNoticePage'));
|
||||
const AdminPanel = React.lazy(() => import('./pages/admin/AdminPanel'));
|
||||
|
||||
|
||||
const PageLoader = () => (
|
||||
<div style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.6, fontSize: '0.9rem' }}>
|
||||
Loading...
|
||||
@ -184,6 +185,7 @@ const App = () => (
|
||||
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||
</Route>
|
||||
|
||||
|
||||
{/* Developer Studio layout (Developer Studio navbar replaces original navbar when in dev menu) */}
|
||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
||||
@ -251,6 +253,7 @@ const DeveloperStudioNavbar = ({ user }: any) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="nav-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '1.25rem' }}>
|
||||
<Link
|
||||
to="/"
|
||||
@ -331,6 +334,7 @@ const Navbar = ({ user }: any) => {
|
||||
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
|
||||
</div>
|
||||
|
||||
|
||||
{/* ── Center Search Bar with Suggestions ── */}
|
||||
<div className="nav-search-center">
|
||||
<NavSearch onNavigate={() => setIsMenuOpen(false)} />
|
||||
@ -428,6 +432,7 @@ const Footer = () => (
|
||||
© 2026 PumpkinMC
|
||||
</p>
|
||||
|
||||
|
||||
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
|
||||
|
||||
<Link
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
AlertCircle, Eye, EyeOff, ChevronRight, Bell,
|
||||
Smartphone, Key, Trash2, Code, Sparkles, Building2,
|
||||
ShieldCheck, Download, Sliders, Package, Laptop, Tablet,
|
||||
Fingerprint, Plus, ShieldAlert, RefreshCw
|
||||
Fingerprint, Plus, ShieldAlert, RefreshCw, Copy, Check, Terminal
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LibraryEntry {
|
||||
@ -31,11 +31,12 @@ interface LibraryEntry {
|
||||
is_preorder?: boolean;
|
||||
}
|
||||
|
||||
type Tab = 'account' | 'security' | 'developer' | 'notifications' | 'library' | 'privacy' | 'danger';
|
||||
type Tab = 'account' | 'security' | 'api_keys' | 'developer' | 'notifications' | 'library' | 'privacy' | 'danger';
|
||||
|
||||
const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger?: boolean }[] = [
|
||||
{ key: 'account', label: 'Account', icon: User },
|
||||
{ key: 'security', label: 'Security', icon: Shield },
|
||||
{ key: 'api_keys', label: 'API Keys', icon: Key },
|
||||
{ key: 'developer', label: 'Developer Profile', icon: Code },
|
||||
{ key: 'notifications', label: 'Notifications', icon: Bell },
|
||||
{ key: 'library', label: 'Library', icon: BookOpen },
|
||||
@ -324,11 +325,89 @@ const ProfilePage = () => {
|
||||
const [verify2faError, setVerify2faError] = useState('');
|
||||
const [verify2faLoading, setVerify2faLoading] = useState(false);
|
||||
|
||||
// API Keys / Personal Access Tokens
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
keyPrefix: string;
|
||||
scopes: string[];
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
isRevoked: boolean;
|
||||
}
|
||||
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [apiKeysLoading, setApiKeysLoading] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyExpiresIn, setNewKeyExpiresIn] = useState(90);
|
||||
const [createdRawKey, setCreatedRawKey] = useState<string | null>(null);
|
||||
const [creatingKey, setCreatingKey] = useState(false);
|
||||
const [revokingKeyId, setRevokingKeyId] = useState<number | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
setApiKeysLoading(true);
|
||||
try {
|
||||
const res = await api.get('/user/api-keys');
|
||||
setApiKeys(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load API keys:', err);
|
||||
} finally {
|
||||
setApiKeysLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateApiKey = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newKeyName.trim()) return;
|
||||
setCreatingKey(true);
|
||||
try {
|
||||
const res = await api.post('/user/api-keys', {
|
||||
name: newKeyName.trim(),
|
||||
expiresInDays: newKeyExpiresIn > 0 ? newKeyExpiresIn : null,
|
||||
scopes: ['plugins:download'],
|
||||
});
|
||||
setCreatedRawKey(res.data.rawKey);
|
||||
setNewKeyName('');
|
||||
setNewKeyExpiresIn(90);
|
||||
await fetchApiKeys();
|
||||
showToast('API Key created successfully!');
|
||||
} catch (err: any) {
|
||||
showToast(err.response?.data?.error || 'Failed to create API key', 'error');
|
||||
} finally {
|
||||
setCreatingKey(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeApiKey = async (id: number) => {
|
||||
if (!window.confirm('Are you sure you want to revoke this API key? External tools and servers using it will immediately lose access.')) return;
|
||||
setRevokingKeyId(id);
|
||||
try {
|
||||
await api.delete(`/user/api-keys/${id}`);
|
||||
setApiKeys(prev => prev.filter(k => k.id !== id));
|
||||
showToast('API Key revoked successfully.');
|
||||
} catch (err: any) {
|
||||
showToast(err.response?.data?.error || 'Failed to revoke API key', 'error');
|
||||
} finally {
|
||||
setRevokingKeyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyRawKey = () => {
|
||||
if (!createdRawKey) return;
|
||||
navigator.clipboard.writeText(createdRawKey);
|
||||
setCopiedKey(true);
|
||||
setTimeout(() => setCopiedKey(false), 2500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'security') {
|
||||
fetchSessions();
|
||||
fetchPasskeys();
|
||||
fetchRecoveryStatus();
|
||||
} else if (activeTab === 'api_keys') {
|
||||
fetchApiKeys();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
@ -1242,6 +1321,200 @@ const ProfilePage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ PERSONAL ACCESS TOKENS (API KEYS) ══════════════════════════ */}
|
||||
{activeTab === 'api_keys' && (
|
||||
<div className="profile-section">
|
||||
<div className="settings-section-header">
|
||||
<h2 className="section-title" style={{ marginBottom: 0 }}>
|
||||
<span>API Keys</span> & Automation Tokens
|
||||
</h2>
|
||||
<p className="settings-section-sub">
|
||||
Personal access tokens allow external tools, CI/CD pipelines, and Minecraft servers to securely call the Pumpkin Market REST API without exposing your account password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Newly Generated Raw Key Modal / Highlight Box */}
|
||||
{createdRawKey && (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: 12,
|
||||
padding: '1.25rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', color: '#4ade80', fontWeight: 600, marginBottom: '6px' }}>
|
||||
<CheckCircle size={18} />
|
||||
<span>Personal Access Token Created Successfully!</span>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.85rem', color: '#cbd5e1', margin: '0 0 12px 0' }}>
|
||||
<strong>Make sure to copy your token now.</strong> For your security, you will not be able to view this token again after leaving this page.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
background: 'rgba(0, 0, 0, 0.4)',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<code style={{ fontFamily: 'monospace', color: '#f1f5f9', fontSize: '0.9rem', wordBreak: 'break-all', flex: 1 }}>
|
||||
{createdRawKey}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn settings-btn-primary"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', whiteSpace: 'nowrap' }}
|
||||
onClick={handleCopyRawKey}
|
||||
>
|
||||
{copiedKey ? <Check size={14} /> : <Copy size={14} />}
|
||||
<span>{copiedKey ? 'Copied!' : 'Copy Token'}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn settings-btn-secondary"
|
||||
onClick={() => setCreatedRawKey(null)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate New API Key Card */}
|
||||
<SettingsCard
|
||||
title="Generate New API Key"
|
||||
icon={Key}
|
||||
description="Create a dedicated token for automated plugin downloads, server setup scripts, or backup bots."
|
||||
>
|
||||
<form onSubmit={handleCreateApiKey}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1rem', marginBottom: '1.25rem' }}>
|
||||
<Field label="Key Name / Purpose" hint="e.g., 'SMP Server 1', 'Pterodactyl Daemon', 'CI Automation'">
|
||||
<input
|
||||
type="text"
|
||||
className="settings-input"
|
||||
placeholder="My Production Server"
|
||||
value={newKeyName}
|
||||
onChange={e => setNewKeyName(e.target.value)}
|
||||
maxLength={64}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Expiration" hint="Tokens will automatically stop working after expiry">
|
||||
<select
|
||||
className="settings-input"
|
||||
value={newKeyExpiresIn}
|
||||
onChange={e => setNewKeyExpiresIn(Number(e.target.value))}
|
||||
>
|
||||
<option value={30}>30 Days</option>
|
||||
<option value={90}>90 Days (Recommended)</option>
|
||||
<option value={180}>180 Days</option>
|
||||
<option value={365}>1 Year</option>
|
||||
<option value={0}>No Expiration</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="settings-btn settings-btn-primary"
|
||||
disabled={creatingKey || !newKeyName.trim()}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{creatingKey ? 'Generating…' : 'Generate Token'}
|
||||
</button>
|
||||
</form>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Active API Keys List */}
|
||||
<SettingsCard
|
||||
title="Active Tokens"
|
||||
icon={Key}
|
||||
description="Tokens currently authorized to authenticate as your account."
|
||||
>
|
||||
{apiKeysLoading ? (
|
||||
<p className="settings-hint" style={{ textAlign: 'center', padding: '1.5rem 0' }}>Loading your API keys…</p>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem 1rem', color: '#94a3b8' }}>
|
||||
<Key size={32} style={{ opacity: 0.3, marginBottom: '8px' }} />
|
||||
<p style={{ margin: 0, fontWeight: 500 }}>No API keys generated yet.</p>
|
||||
<p style={{ fontSize: '0.82rem', color: '#64748b', marginTop: '4px' }}>
|
||||
Generate your first token above to start automating plugin downloads with curl or scripts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
|
||||
{apiKeys.map(k => (
|
||||
<div
|
||||
key={k.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
padding: '1rem 1.15rem',
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid var(--mp-border, rgba(255, 255, 255, 0.08))',
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.92rem', color: 'var(--mp-text, #fff)' }}>
|
||||
{k.name}
|
||||
</span>
|
||||
<code style={{ fontSize: '0.78rem', background: 'rgba(255,255,255,0.06)', padding: '2px 6px', borderRadius: 4, color: '#38bdf8' }}>
|
||||
{k.keyPrefix}
|
||||
</code>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.78rem', color: 'var(--mp-muted, #94a3b8)', display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<span>Created: {new Date(k.createdAt).toLocaleDateString()}</span>
|
||||
<span>Expires: {k.expiresAt ? new Date(k.expiresAt).toLocaleDateString() : 'Never'}</span>
|
||||
<span>Last used: {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="settings-btn settings-btn-danger-outline"
|
||||
style={{ padding: '0.45rem 0.85rem', fontSize: '0.8rem', display: 'inline-flex', alignItems: 'center', gap: '5px' }}
|
||||
onClick={() => handleRevokeApiKey(k.id)}
|
||||
disabled={revokingKeyId === k.id}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{revokingKeyId === k.id ? 'Revoking…' : 'Revoke'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Usage Guide Box */}
|
||||
<SettingsCard
|
||||
title="How to Use Your Token"
|
||||
icon={Terminal}
|
||||
description="Using your token with cURL or automation scripts."
|
||||
>
|
||||
<p style={{ fontSize: '0.85rem', color: '#94a3b8', margin: '0 0 10px 0' }}>
|
||||
Pass your token in the <code>Authorization: Bearer</code> header to authenticate directly with the REST API:
|
||||
</p>
|
||||
<div style={{ background: 'rgba(0, 0, 0, 0.3)', padding: '12px 16px', borderRadius: 8, border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<code style={{ fontSize: '0.82rem', color: '#f1f5f9', display: 'block', lineHeight: 1.6 }}>
|
||||
curl -H "Authorization: Bearer pmk_your_token_here" \<br />
|
||||
https://market.pumpkinmc.org/api/v1/rest/plugins/1/download -o my_plugin.wasm
|
||||
</code>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ══ DEVELOPER PROFILE ════════════════════════════════════════════ */}
|
||||
{activeTab === 'developer' && (
|
||||
<div className="profile-section">
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
|
||||
import { scaleLinear } from "d3-scale";
|
||||
import { Server, Users, Globe, Activity } from 'lucide-react';
|
||||
import { useAnalytics } from './useAnalytics';
|
||||
|
||||
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
const DashboardAudience = () => {
|
||||
const { mapData, loading } = useAnalytics();
|
||||
const [mapFilter, setMapFilter] = useState<'visitors' | 'free_downloads' | 'buyers'>('visitors');
|
||||
const { mapData, telemetry, loading } = useAnalytics();
|
||||
const [mapFilter, setMapFilter] = useState<'servers' | 'players' | 'visitors' | 'free_downloads'>('servers');
|
||||
const [hoveredInfo, setHoveredInfo] = useState<{ name: string; servers: number; players: number; visitors: number } | null>(null);
|
||||
|
||||
// Memoize scales to prevent recalculation on every render
|
||||
const colorScales = useMemo(() => ({
|
||||
servers: scaleLinear<string>().domain([0, 20]).range(["#1a1a2e", "#10b981"]),
|
||||
players: scaleLinear<string>().domain([0, 100]).range(["#1a1a2e", "#06b6d4"]),
|
||||
visitors: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#8884d8"]),
|
||||
free_downloads: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#82ca9d"]),
|
||||
buyers: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#FF7518"]),
|
||||
@ -20,52 +24,152 @@ const DashboardAudience = () => {
|
||||
return (
|
||||
<div className="loader-container" style={{ padding: '4rem', textAlign: 'center' }}>
|
||||
<div className="pumpkin-spinner"></div>
|
||||
<p>Analyzing Global Distribution...</p>
|
||||
<p>Analyzing Global Telemetry & Distribution...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-container">
|
||||
{/* ── Top Telemetry Audience KPIs ── */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||
<div style={{ background: 'var(--dash-surface, #13131f)', border: '1px solid var(--dash-border, rgba(255,255,255,0.08))', borderRadius: '10px', padding: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
<Server size={14} />
|
||||
Live Game Servers (24h)
|
||||
</div>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--text, #fff)', marginTop: '4px' }}>
|
||||
{telemetry?.activeServers24h?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
|
||||
{telemetry?.activeServers7d ?? 0} active past 7 days
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--dash-surface, #13131f)', border: '1px solid var(--dash-border, rgba(255,255,255,0.08))', borderRadius: '10px', padding: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#06b6d4', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
<Users size={14} />
|
||||
Online Player Reach
|
||||
</div>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--text, #fff)', marginTop: '4px' }}>
|
||||
{telemetry?.totalPlayersOnline?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
|
||||
Concurrent players on servers running your plugins
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--dash-surface, #13131f)', border: '1px solid var(--dash-border, rgba(255,255,255,0.08))', borderRadius: '10px', padding: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#f59e0b', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
<Activity size={14} />
|
||||
Peak 24h Players
|
||||
</div>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--text, #fff)', marginTop: '4px' }}>
|
||||
{telemetry?.peakPlayers24h?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
|
||||
Max player concurrency
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'var(--dash-surface, #13131f)', border: '1px solid var(--dash-border, rgba(255,255,255,0.08))', borderRadius: '10px', padding: '1rem' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#a855f7', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase' }}>
|
||||
<Globe size={14} />
|
||||
Server Locations
|
||||
</div>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--text, #fff)', marginTop: '4px' }}>
|
||||
{telemetry?.countries?.length ?? mapData.length}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted, #94a3b8)', marginTop: '2px' }}>
|
||||
Countries with deployed servers
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Global Interactive Map ── */}
|
||||
<div className="charts-grid">
|
||||
<div className="chart-card" style={{ gridColumn: '1 / -1' }}>
|
||||
<div className="card-header-flex" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||
<div className="card-header-flex" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<h3 className="section-title" style={{ margin: 0 }}>Geographic <span>Distribution</span></h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>Visualizing user activity across the globe</p>
|
||||
<h3 className="section-title" style={{ margin: 0 }}>Plugin <span>Audience & Server Geography</span></h3>
|
||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', margin: '4px 0 0 0' }}>
|
||||
Visualizing active Minecraft servers and player reach reported via telemetry
|
||||
</p>
|
||||
</div>
|
||||
<div className="map-filters" style={{ display: 'flex', gap: '8px' }}>
|
||||
{(['visitors', 'free_downloads', 'buyers'] as const).map(type => (
|
||||
{(['servers', 'players', 'visitors', 'free_downloads'] as const).map(type => (
|
||||
<button
|
||||
key={type}
|
||||
className={`btn-sm ${mapFilter === type ? 'active' : ''}`}
|
||||
onClick={() => setMapFilter(type)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--dash-border, rgba(255,255,255,0.1))',
|
||||
background: mapFilter === type ? 'var(--primary, #f97316)' : 'rgba(255,255,255,0.04)',
|
||||
color: mapFilter === type ? '#fff' : 'var(--text-muted, #94a3b8)',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.78rem',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'capitalize'
|
||||
}}
|
||||
>
|
||||
{type.replace('_', ' ')}
|
||||
{type === 'servers' ? 'Live Servers' : type === 'players' ? 'Online Players' : type.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-wrapper" style={{ background: '#0f0f1a', borderRadius: '12px', padding: '20px', border: '1px solid var(--border)' }}>
|
||||
<div className="map-wrapper" style={{ background: '#0f0f1a', borderRadius: '12px', padding: '20px', border: '1px solid var(--border)', position: 'relative' }}>
|
||||
{hoveredInfo && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
background: 'rgba(20, 20, 35, 0.95)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
fontSize: '0.82rem',
|
||||
color: '#fff',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)'
|
||||
}}>
|
||||
<div style={{ fontWeight: 700, marginBottom: '4px' }}>{hoveredInfo.name}</div>
|
||||
<div style={{ color: '#10b981' }}>🖥️ {hoveredInfo.servers} active servers</div>
|
||||
<div style={{ color: '#06b6d4' }}>👥 {hoveredInfo.players} online players</div>
|
||||
<div style={{ color: '#94a3b8', fontSize: '0.75rem' }}>🌐 {hoveredInfo.visitors} web views</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ComposableMap projectionConfig={{ rotate: [-10, 0, 0] as any, scale: 147 }} width={800} height={400}>
|
||||
<Geographies geography={GEO_URL}>
|
||||
{({ geographies }) =>
|
||||
geographies.map((geo) => {
|
||||
// Try different property keys used by various GeoJSON versions
|
||||
const countryCode = geo.properties.ISO_A2 || geo.properties.iso_a2 || geo.properties.IS_A2;
|
||||
const d = mapData.find((s: any) => s.country === countryCode);
|
||||
const d: any = mapData.find((s: any) => s.country === countryCode);
|
||||
const val = d ? (d[mapFilter === 'free_downloads' ? 'freeDownloads' : mapFilter] || 0) : 0;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={d ? colorScales[mapFilter](d[mapFilter] || 0) : "#1a1a2e"}
|
||||
fill={val > 0 ? colorScales[mapFilter](val) : "#1a1a2e"}
|
||||
stroke="#2a2a40"
|
||||
strokeWidth={0.5}
|
||||
onMouseEnter={() => {
|
||||
setHoveredInfo({
|
||||
name: d?.countryName || geo.properties.name || countryCode || 'Unknown',
|
||||
servers: d?.servers || 0,
|
||||
players: d?.players || 0,
|
||||
visitors: d?.visitors || 0,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setHoveredInfo(null)}
|
||||
style={{
|
||||
default: { outline: "none" },
|
||||
hover: { fill: "var(--primary)", outline: "none", cursor: 'pointer' },
|
||||
hover: { fill: "#f97316", outline: "none", cursor: 'pointer' },
|
||||
pressed: { outline: "none" }
|
||||
}}
|
||||
/>
|
||||
@ -77,10 +181,51 @@ const DashboardAudience = () => {
|
||||
|
||||
{mapData.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
No geographic data captured yet. (Note: Localhost/VPN IPs may appear as 'Unknown')
|
||||
No server telemetry or geographic data captured yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table of Top Geographic Locations */}
|
||||
{mapData.length > 0 && (
|
||||
<div style={{ marginTop: '1.5rem', overflowX: 'auto' }}>
|
||||
<h4 style={{ margin: '0 0 10px 0', fontSize: '0.92rem', color: 'var(--text, #fff)' }}>
|
||||
Country Telemetry Breakdown
|
||||
</h4>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem', textAlign: 'left' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.08)', color: 'var(--text-muted, #94a3b8)' }}>
|
||||
<th style={{ padding: '8px 12px' }}>Country</th>
|
||||
<th style={{ padding: '8px 12px' }}>Game Servers</th>
|
||||
<th style={{ padding: '8px 12px' }}>Online Players</th>
|
||||
<th style={{ padding: '8px 12px' }}>Web Visitors</th>
|
||||
<th style={{ padding: '8px 12px' }}>Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mapData.slice(0, 10).map((row, idx) => (
|
||||
<tr key={idx} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<td style={{ padding: '8px 12px', fontWeight: 600, color: 'var(--text, #fff)' }}>
|
||||
{row.countryName} ({row.country})
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: '#10b981', fontWeight: 700 }}>
|
||||
{row.servers}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: '#06b6d4' }}>
|
||||
{row.players}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: 'var(--text-muted, #94a3b8)' }}>
|
||||
{row.visitors}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', color: 'var(--text-muted, #94a3b8)' }}>
|
||||
{row.freeDownloads}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,8 @@ import {
|
||||
} from 'recharts';
|
||||
import {
|
||||
TrendingUp, TrendingDown, Minus, DollarSign,
|
||||
Download, MousePointer2, Star, Globe
|
||||
Download, MousePointer2, Star, Globe,
|
||||
Server, Users, Radio, Cpu, Layers, Activity
|
||||
} from 'lucide-react';
|
||||
import { useAnalytics } from '../useAnalytics';
|
||||
|
||||
@ -87,7 +88,7 @@ const KpiCard = ({ title, value, comparison, icon: Icon, chartData, dataKey }: a
|
||||
};
|
||||
|
||||
export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) => {
|
||||
const { processedData, totals, ratingSummary, referrers, timeframe, setTimeframe, loading } = useAnalytics(pluginId);
|
||||
const { processedData, totals, ratingSummary, referrers, telemetry, timeframe, setTimeframe, loading } = useAnalytics(pluginId);
|
||||
const timeframes = ['Today', '7 Days', '1 Month', '1 Year', 'Lifetime'];
|
||||
|
||||
if (loading) {
|
||||
@ -164,6 +165,202 @@ export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Live Game Server Telemetry (Pumpkin & Vine) ── */}
|
||||
<div className="mp-card" style={{ marginTop: '1.25rem', padding: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem', flexWrap: 'wrap', gap: '0.75rem' }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<Server size={18} color="#10b981" />
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 700, color: 'var(--mp-text-1, #f8fafc)' }}>
|
||||
Live Server Telemetry & Installations
|
||||
</h3>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '5px', padding: '2px 8px', borderRadius: '12px', background: 'rgba(16, 185, 129, 0.12)', color: '#10b981', fontSize: '0.72rem', fontWeight: 600 }}>
|
||||
<span style={{ width: '6px', height: '6px', borderRadius: '50%', background: '#10b981', boxShadow: '0 0 6px #10b981' }} />
|
||||
Real-Time Heartbeats
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '0.8rem', color: 'var(--mp-text-3, #94a3b8)' }}>
|
||||
Direct metrics reported by Minecraft production servers running <strong>{pluginName}</strong> on Pumpkin & Vine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4 Telemetry Metric Badges */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', marginBottom: '1.25rem' }}>
|
||||
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#10b981', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
<Server size={14} />
|
||||
Active Servers (24h)
|
||||
</div>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--mp-text-1, #fff)', marginTop: '4px', lineHeight: 1.1 }}>
|
||||
{telemetry?.activeServers24h?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)', marginTop: '4px' }}>
|
||||
{telemetry?.activeServers7d ?? 0} weekly active servers (7d)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#06b6d4', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
<Users size={14} />
|
||||
Total Player Reach
|
||||
</div>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--mp-text-1, #fff)', marginTop: '4px', lineHeight: 1.1 }}>
|
||||
{telemetry?.totalPlayersOnline?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)', marginTop: '4px' }}>
|
||||
Concurrent players on active servers
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#f59e0b', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
<Activity size={14} />
|
||||
Peak 24h Players
|
||||
</div>
|
||||
<div style={{ fontSize: '1.75rem', fontWeight: 800, color: 'var(--mp-text-1, #fff)', marginTop: '4px', lineHeight: 1.1 }}>
|
||||
{telemetry?.peakPlayers24h?.toLocaleString() ?? 0}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)', marginTop: '4px' }}>
|
||||
Highest server player concurrency
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: '10px', padding: '14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', color: '#a855f7', fontSize: '0.78rem', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em' }}>
|
||||
<Radio size={14} />
|
||||
Primary Platform
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 800, color: 'var(--mp-text-1, #fff)', marginTop: '4px', lineHeight: 1.1, textTransform: 'capitalize' }}>
|
||||
{telemetry?.serverTypes?.[0]?.label || 'Pumpkin'}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)', marginTop: '4px' }}>
|
||||
{telemetry?.serverTypes?.[0]?.percentage ? `${telemetry.serverTypes[0].percentage}% of instances` : 'Awaiting heartbeats'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown sub-grid: Installed Versions, Runtime Environment, Active Server Geography */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1.25rem' }}>
|
||||
{/* Installed Version Adoption */}
|
||||
<div style={{ background: 'rgba(255,255,255,0.015)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', padding: '16px' }}>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: 'var(--mp-text-1, #fff)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Layers size={16} color="#10b981" />
|
||||
Installed Version Adoption
|
||||
</div>
|
||||
{telemetry?.versions && telemetry.versions.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{telemetry.versions.map((ver, idx) => (
|
||||
<div key={idx} style={{ fontSize: '0.8rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px' }}>
|
||||
<span style={{ fontWeight: 600, color: 'var(--mp-text-1, #fff)', fontFamily: 'monospace' }}>
|
||||
v{ver.label.replace(/^v/, '')}
|
||||
</span>
|
||||
<span style={{ color: 'var(--mp-text-3, #94a3b8)', fontSize: '0.75rem' }}>
|
||||
{ver.count} {ver.count === 1 ? 'server' : 'servers'} ({ver.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ height: '6px', width: '100%', background: 'rgba(255,255,255,0.05)', borderRadius: '3px', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${Math.min(ver.percentage, 100)}%`, background: idx === 0 ? '#10b981' : '#3b82f6', borderRadius: '3px' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--mp-text-3, #94a3b8)', padding: '1.5rem 0', textAlign: 'center' }}>
|
||||
Awaiting version heartbeats from active servers.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Runtime Environment: Engine, Minecraft Version & OS */}
|
||||
<div style={{ background: 'rgba(255,255,255,0.015)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', padding: '16px' }}>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: 'var(--mp-text-1, #fff)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Cpu size={16} color="#06b6d4" />
|
||||
Server Runtime Environment
|
||||
</div>
|
||||
|
||||
{/* Platform engines */}
|
||||
{telemetry?.serverTypes && telemetry.serverTypes.length > 0 && (
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--mp-text-3, #94a3b8)', textTransform: 'uppercase', marginBottom: '4px', fontWeight: 600 }}>
|
||||
Server Software
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
{telemetry.serverTypes.map((st, i) => (
|
||||
<span key={i} style={{ fontSize: '0.75rem', padding: '3px 8px', borderRadius: '4px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.08)', color: 'var(--mp-text-1, #fff)' }}>
|
||||
<strong>{st.label}</strong>: {st.count} ({st.percentage}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minecraft versions */}
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--mp-text-3, #94a3b8)', textTransform: 'uppercase', marginBottom: '4px', fontWeight: 600 }}>
|
||||
Minecraft Versions
|
||||
</div>
|
||||
{telemetry?.minecraftVersions && telemetry.minecraftVersions.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{telemetry.minecraftVersions.slice(0, 4).map((mc, i) => (
|
||||
<span key={i} style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: '4px', background: 'rgba(6, 182, 212, 0.08)', border: '1px solid rgba(6, 182, 212, 0.2)', color: '#06b6d4' }}>
|
||||
MC {mc.label} ({mc.percentage}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)' }}>MC 1.21.x</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operating System */}
|
||||
<div>
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--mp-text-3, #94a3b8)', textTransform: 'uppercase', marginBottom: '4px', fontWeight: 600 }}>
|
||||
Server Host OS
|
||||
</div>
|
||||
{telemetry?.operatingSystems && telemetry.operatingSystems.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||
{telemetry.operatingSystems.slice(0, 3).map((os, i) => (
|
||||
<span key={i} style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: '4px', background: 'rgba(255,255,255,0.04)', color: 'var(--mp-text-2, #cbd5e1)' }}>
|
||||
{os.label} ({os.percentage}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--mp-text-3, #94a3b8)' }}>Linux / Bare Metal</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Server Geography */}
|
||||
<div style={{ background: 'rgba(255,255,255,0.015)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', padding: '16px' }}>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 700, color: 'var(--mp-text-1, #fff)', marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<Globe size={16} color="#f59e0b" />
|
||||
Active Server Locations
|
||||
</div>
|
||||
{telemetry?.countries && telemetry.countries.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', maxHeight: '180px', overflowY: 'auto' }}>
|
||||
{telemetry.countries.slice(0, 6).map((c, i) => (
|
||||
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.78rem', padding: '3px 0', borderBottom: i < 5 ? '1px solid rgba(255,255,255,0.03)' : 'none' }}>
|
||||
<span style={{ color: 'var(--mp-text-1, #fff)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '150px' }} title={c.countryName}>
|
||||
{c.countryName}
|
||||
</span>
|
||||
<span style={{ color: 'var(--mp-text-3, #94a3b8)', fontSize: '0.75rem' }}>
|
||||
<strong style={{ color: '#f59e0b' }}>{c.servers}</strong> {c.servers === 1 ? 'server' : 'servers'} • {c.players} players
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '0.8rem', color: 'var(--mp-text-3, #94a3b8)', padding: '1.5rem 0', textAlign: 'center' }}>
|
||||
Global server distribution will appear here as instances connect.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid: Charts & Rating Breakdown */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.25rem', marginTop: '1.25rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
|
||||
@ -1,11 +1,48 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import api from '../../api';
|
||||
|
||||
export interface TelemetryDistributionItem {
|
||||
label: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface TelemetryGeoItem {
|
||||
country: string;
|
||||
countryName: string;
|
||||
servers: number;
|
||||
players: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface PluginTelemetry {
|
||||
activeServers24h: number;
|
||||
activeServers7d: number;
|
||||
totalPlayersOnline: number;
|
||||
peakPlayers24h: number;
|
||||
versions: TelemetryDistributionItem[];
|
||||
serverTypes: TelemetryDistributionItem[];
|
||||
minecraftVersions: TelemetryDistributionItem[];
|
||||
operatingSystems: TelemetryDistributionItem[];
|
||||
countries: TelemetryGeoItem[];
|
||||
}
|
||||
|
||||
export interface DevMapItem {
|
||||
country: string;
|
||||
countryName: string;
|
||||
servers: number;
|
||||
players: number;
|
||||
visitors: number;
|
||||
freeDownloads: number;
|
||||
buyers: number;
|
||||
}
|
||||
|
||||
export const useAnalytics = (pluginId?: number) => {
|
||||
const [timeSeries, setTimeSeries] = useState<any[]>([]);
|
||||
const [ratingSummary, setRatingSummary] = useState<any>(null);
|
||||
const [referrers, setReferrers] = useState<any[]>([]);
|
||||
const [mapData, setMapData] = useState<any[]>([]);
|
||||
const [mapData, setMapData] = useState<DevMapItem[]>([]);
|
||||
const [telemetry, setTelemetry] = useState<PluginTelemetry | null>(null);
|
||||
const [breakdowns, setBreakdowns] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timeframe, setTimeframe] = useState<string>('Lifetime');
|
||||
@ -23,6 +60,7 @@ export const useAnalytics = (pluginId?: number) => {
|
||||
setRatingSummary(res.data.ratingSummary || null);
|
||||
setReferrers(Array.isArray(res.data.referrers) ? res.data.referrers : []);
|
||||
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
|
||||
setTelemetry(res.data.telemetry || null);
|
||||
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
|
||||
} catch (error) {
|
||||
console.error("Analytics Fetch Error:", error);
|
||||
@ -30,6 +68,7 @@ export const useAnalytics = (pluginId?: number) => {
|
||||
setRatingSummary(null);
|
||||
setReferrers([]);
|
||||
setMapData([]);
|
||||
setTelemetry(null);
|
||||
setBreakdowns([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -88,6 +127,7 @@ export const useAnalytics = (pluginId?: number) => {
|
||||
ratingSummary,
|
||||
referrers,
|
||||
mapData,
|
||||
telemetry,
|
||||
breakdowns,
|
||||
processedData,
|
||||
totals,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user