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 LegalNoticePage = React.lazy(() => import('./pages/legal/LegalNoticePage'));
|
||||||
const AdminPanel = React.lazy(() => import('./pages/admin/AdminPanel'));
|
const AdminPanel = React.lazy(() => import('./pages/admin/AdminPanel'));
|
||||||
|
|
||||||
|
|
||||||
const PageLoader = () => (
|
const PageLoader = () => (
|
||||||
<div style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.6, fontSize: '0.9rem' }}>
|
<div style={{ minHeight: '50vh', display: 'flex', alignItems: 'center', justifyContent: 'center', opacity: 0.6, fontSize: '0.9rem' }}>
|
||||||
Loading...
|
Loading...
|
||||||
@ -184,6 +185,7 @@ const App = () => (
|
|||||||
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
<Route path="settings" element={<ProtectedRoute><ProfilePage /></ProtectedRoute>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
||||||
{/* Developer Studio layout (Developer Studio navbar replaces original navbar when in dev menu) */}
|
{/* Developer Studio layout (Developer Studio navbar replaces original navbar when in dev menu) */}
|
||||||
<Route path="/dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
<Route path="/dashboard" element={<ProtectedRoute><DashboardLayout /></ProtectedRoute>}>
|
||||||
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
<Route index element={<Navigate to="/dashboard/plugins" replace />} />
|
||||||
@ -251,6 +253,7 @@ const DeveloperStudioNavbar = ({ user }: any) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div className="nav-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '1.25rem' }}>
|
<div className="nav-actions" style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '1.25rem' }}>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
@ -331,6 +334,7 @@ const Navbar = ({ user }: any) => {
|
|||||||
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
|
{user && (user.role === 'admin' || user.role === 'moderator') && <NavLink to="/staff" onClick={() => setIsMenuOpen(false)}>Staff</NavLink>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* ── Center Search Bar with Suggestions ── */}
|
{/* ── Center Search Bar with Suggestions ── */}
|
||||||
<div className="nav-search-center">
|
<div className="nav-search-center">
|
||||||
<NavSearch onNavigate={() => setIsMenuOpen(false)} />
|
<NavSearch onNavigate={() => setIsMenuOpen(false)} />
|
||||||
@ -428,6 +432,7 @@ const Footer = () => (
|
|||||||
© 2026 PumpkinMC
|
© 2026 PumpkinMC
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
|
<span style={{ opacity: 0.2, fontSize: '11px' }}>·</span>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
AlertCircle, Eye, EyeOff, ChevronRight, Bell,
|
AlertCircle, Eye, EyeOff, ChevronRight, Bell,
|
||||||
Smartphone, Key, Trash2, Code, Sparkles, Building2,
|
Smartphone, Key, Trash2, Code, Sparkles, Building2,
|
||||||
ShieldCheck, Download, Sliders, Package, Laptop, Tablet,
|
ShieldCheck, Download, Sliders, Package, Laptop, Tablet,
|
||||||
Fingerprint, Plus, ShieldAlert, RefreshCw
|
Fingerprint, Plus, ShieldAlert, RefreshCw, Copy, Check, Terminal
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface LibraryEntry {
|
interface LibraryEntry {
|
||||||
@ -31,11 +31,12 @@ interface LibraryEntry {
|
|||||||
is_preorder?: boolean;
|
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 }[] = [
|
const NAV: { key: Tab; label: string; icon: React.FC<{ size?: number }> ; danger?: boolean }[] = [
|
||||||
{ key: 'account', label: 'Account', icon: User },
|
{ key: 'account', label: 'Account', icon: User },
|
||||||
{ key: 'security', label: 'Security', icon: Shield },
|
{ key: 'security', label: 'Security', icon: Shield },
|
||||||
|
{ key: 'api_keys', label: 'API Keys', icon: Key },
|
||||||
{ key: 'developer', label: 'Developer Profile', icon: Code },
|
{ key: 'developer', label: 'Developer Profile', icon: Code },
|
||||||
{ key: 'notifications', label: 'Notifications', icon: Bell },
|
{ key: 'notifications', label: 'Notifications', icon: Bell },
|
||||||
{ key: 'library', label: 'Library', icon: BookOpen },
|
{ key: 'library', label: 'Library', icon: BookOpen },
|
||||||
@ -324,11 +325,89 @@ const ProfilePage = () => {
|
|||||||
const [verify2faError, setVerify2faError] = useState('');
|
const [verify2faError, setVerify2faError] = useState('');
|
||||||
const [verify2faLoading, setVerify2faLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'security') {
|
if (activeTab === 'security') {
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
fetchPasskeys();
|
fetchPasskeys();
|
||||||
fetchRecoveryStatus();
|
fetchRecoveryStatus();
|
||||||
|
} else if (activeTab === 'api_keys') {
|
||||||
|
fetchApiKeys();
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [activeTab]);
|
||||||
|
|
||||||
@ -1242,6 +1321,200 @@ const ProfilePage = () => {
|
|||||||
</div>
|
</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 ════════════════════════════════════════════ */}
|
{/* ══ DEVELOPER PROFILE ════════════════════════════════════════════ */}
|
||||||
{activeTab === 'developer' && (
|
{activeTab === 'developer' && (
|
||||||
<div className="profile-section">
|
<div className="profile-section">
|
||||||
|
|||||||
@ -1,16 +1,20 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
|
import { ComposableMap, Geographies, Geography } from "@vnedyalk0v/react19-simple-maps";
|
||||||
import { scaleLinear } from "d3-scale";
|
import { scaleLinear } from "d3-scale";
|
||||||
|
import { Server, Users, Globe, Activity } from 'lucide-react';
|
||||||
import { useAnalytics } from './useAnalytics';
|
import { useAnalytics } from './useAnalytics';
|
||||||
|
|
||||||
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||||
|
|
||||||
const DashboardAudience = () => {
|
const DashboardAudience = () => {
|
||||||
const { mapData, loading } = useAnalytics();
|
const { mapData, telemetry, loading } = useAnalytics();
|
||||||
const [mapFilter, setMapFilter] = useState<'visitors' | 'free_downloads' | 'buyers'>('visitors');
|
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
|
// Memoize scales to prevent recalculation on every render
|
||||||
const colorScales = useMemo(() => ({
|
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"]),
|
visitors: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#8884d8"]),
|
||||||
free_downloads: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#82ca9d"]),
|
free_downloads: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#82ca9d"]),
|
||||||
buyers: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#FF7518"]),
|
buyers: scaleLinear<string>().domain([0, 50]).range(["#1a1a2e", "#FF7518"]),
|
||||||
@ -20,52 +24,152 @@ const DashboardAudience = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="loader-container" style={{ padding: '4rem', textAlign: 'center' }}>
|
<div className="loader-container" style={{ padding: '4rem', textAlign: 'center' }}>
|
||||||
<div className="pumpkin-spinner"></div>
|
<div className="pumpkin-spinner"></div>
|
||||||
<p>Analyzing Global Distribution...</p>
|
<p>Analyzing Global Telemetry & Distribution...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="analytics-container">
|
<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="charts-grid">
|
||||||
<div className="chart-card" style={{ gridColumn: '1 / -1' }}>
|
<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>
|
<div>
|
||||||
<h3 className="section-title" style={{ margin: 0 }}>Geographic <span>Distribution</span></h3>
|
<h3 className="section-title" style={{ margin: 0 }}>Plugin <span>Audience & Server Geography</span></h3>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>Visualizing user activity across the globe</p>
|
<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>
|
||||||
<div className="map-filters" style={{ display: 'flex', gap: '8px' }}>
|
<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
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
className={`btn-sm ${mapFilter === type ? 'active' : ''}`}
|
className={`btn-sm ${mapFilter === type ? 'active' : ''}`}
|
||||||
onClick={() => setMapFilter(type)}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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}>
|
<ComposableMap projectionConfig={{ rotate: [-10, 0, 0] as any, scale: 147 }} width={800} height={400}>
|
||||||
<Geographies geography={GEO_URL}>
|
<Geographies geography={GEO_URL}>
|
||||||
{({ geographies }) =>
|
{({ geographies }) =>
|
||||||
geographies.map((geo) => {
|
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 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 (
|
return (
|
||||||
<Geography
|
<Geography
|
||||||
key={geo.rsmKey}
|
key={geo.rsmKey}
|
||||||
geography={geo}
|
geography={geo}
|
||||||
fill={d ? colorScales[mapFilter](d[mapFilter] || 0) : "#1a1a2e"}
|
fill={val > 0 ? colorScales[mapFilter](val) : "#1a1a2e"}
|
||||||
stroke="#2a2a40"
|
stroke="#2a2a40"
|
||||||
strokeWidth={0.5}
|
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={{
|
style={{
|
||||||
default: { outline: "none" },
|
default: { outline: "none" },
|
||||||
hover: { fill: "var(--primary)", outline: "none", cursor: 'pointer' },
|
hover: { fill: "#f97316", outline: "none", cursor: 'pointer' },
|
||||||
pressed: { outline: "none" }
|
pressed: { outline: "none" }
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -77,10 +181,51 @@ const DashboardAudience = () => {
|
|||||||
|
|
||||||
{mapData.length === 0 && (
|
{mapData.length === 0 && (
|
||||||
<div style={{ textAlign: 'center', padding: '1rem', color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import {
|
import {
|
||||||
TrendingUp, TrendingDown, Minus, DollarSign,
|
TrendingUp, TrendingDown, Minus, DollarSign,
|
||||||
Download, MousePointer2, Star, Globe
|
Download, MousePointer2, Star, Globe,
|
||||||
|
Server, Users, Radio, Cpu, Layers, Activity
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAnalytics } from '../useAnalytics';
|
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 }) => {
|
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'];
|
const timeframes = ['Today', '7 Days', '1 Month', '1 Year', 'Lifetime'];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -164,6 +165,202 @@ export const PluginAnalyticsTab: React.FC<Props> = ({ pluginId, pluginName }) =>
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Grid: Charts & Rating Breakdown */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.25rem', marginTop: '1.25rem' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: '1.25rem', marginTop: '1.25rem' }}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||||
|
|||||||
@ -1,11 +1,48 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import api from '../../api';
|
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) => {
|
export const useAnalytics = (pluginId?: number) => {
|
||||||
const [timeSeries, setTimeSeries] = useState<any[]>([]);
|
const [timeSeries, setTimeSeries] = useState<any[]>([]);
|
||||||
const [ratingSummary, setRatingSummary] = useState<any>(null);
|
const [ratingSummary, setRatingSummary] = useState<any>(null);
|
||||||
const [referrers, setReferrers] = useState<any[]>([]);
|
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 [breakdowns, setBreakdowns] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [timeframe, setTimeframe] = useState<string>('Lifetime');
|
const [timeframe, setTimeframe] = useState<string>('Lifetime');
|
||||||
@ -23,6 +60,7 @@ export const useAnalytics = (pluginId?: number) => {
|
|||||||
setRatingSummary(res.data.ratingSummary || null);
|
setRatingSummary(res.data.ratingSummary || null);
|
||||||
setReferrers(Array.isArray(res.data.referrers) ? res.data.referrers : []);
|
setReferrers(Array.isArray(res.data.referrers) ? res.data.referrers : []);
|
||||||
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
|
setMapData(Array.isArray(res.data.mapData) ? res.data.mapData : []);
|
||||||
|
setTelemetry(res.data.telemetry || null);
|
||||||
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
|
setBreakdowns(Array.isArray(res.data.breakdowns) ? res.data.breakdowns : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Analytics Fetch Error:", error);
|
console.error("Analytics Fetch Error:", error);
|
||||||
@ -30,6 +68,7 @@ export const useAnalytics = (pluginId?: number) => {
|
|||||||
setRatingSummary(null);
|
setRatingSummary(null);
|
||||||
setReferrers([]);
|
setReferrers([]);
|
||||||
setMapData([]);
|
setMapData([]);
|
||||||
|
setTelemetry(null);
|
||||||
setBreakdowns([]);
|
setBreakdowns([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -88,6 +127,7 @@ export const useAnalytics = (pluginId?: number) => {
|
|||||||
ratingSummary,
|
ratingSummary,
|
||||||
referrers,
|
referrers,
|
||||||
mapData,
|
mapData,
|
||||||
|
telemetry,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
processedData,
|
processedData,
|
||||||
totals,
|
totals,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user