This commit is contained in:
Sewmina 2025-06-08 22:04:00 +05:30
parent 2dae3acd21
commit 9ab363ca59
19 changed files with 1377 additions and 242 deletions

121
Chat-admin-readme.md Normal file
View File

@ -0,0 +1,121 @@
# Duelfi Chat Server
A real-time chat server built with Socket.IO and Express, supporting message creation, editing, and deletion.
## Server Configuration
- **Port**: 3040
- **CORS Origins**:
- http://localhost:3000
- http://localhost:3030
- http://localhost:3031
- https://dev.duelfi.io
- https://beta.duelfi.io
- https://duelfi.io
## Message Structure
```typescript
interface ChatMessage {
id: string; // Unique message identifier
user: string; // User identifier (DID format)
message: string; // Message content
timestamp: number; // Unix timestamp
}
```
## Socket Events
### Client to Server
1. **chat message**
- Purpose: Send a new message
- Payload: `ChatMessage` object
- Response: Broadcasts to all clients
2. **edit message**
- Purpose: Edit an existing message
- Payload: `{ messageId: string, newMessage: string }`
- Response: Broadcasts 'message edited' on success
3. **delete message**
- Purpose: Delete a message
- Payload: `messageId: string`
- Response: Broadcasts 'message deleted' on success
### Server to Client
1. **recent messages**
- Purpose: Initial message history
- Payload: `ChatMessage[]`
- Trigger: On connection
2. **chat message**
- Purpose: New message notification
- Payload: `ChatMessage` object
- Trigger: When any client sends a message
3. **message edited**
- Purpose: Message edit notification
- Payload: `{ messageId: string, newMessage: string }`
- Trigger: When a message is successfully edited
4. **message deleted**
- Purpose: Message deletion notification
- Payload: `messageId: string`
- Trigger: When a message is successfully deleted
## Message Storage
- Messages are stored in `chat_history.json`
- Maximum of 100 messages are kept in memory
- File storage maintains message history
- All operations (create/edit/delete) are persisted to file
## Error Handling
- File operations are wrapped in try-catch blocks
- Failed operations return false
- Successful operations return true
- All errors are logged to console
## Usage Example
```typescript
// Connect to server
const socket = io('http://localhost:3040');
// Send message
socket.emit('chat message', {
id: Date.now().toString(),
user: 'did:privy:user123',
message: 'Hello world',
timestamp: Date.now()
});
// Edit message
socket.emit('edit message', {
messageId: 'message-id-here',
newMessage: 'Updated message'
});
// Delete message
socket.emit('delete message', 'message-id-here');
// Listen for events
socket.on('recent messages', (messages) => {
// Handle initial messages
});
socket.on('chat message', (message) => {
// Handle new message
});
socket.on('message edited', ({ messageId, newMessage }) => {
// Handle edited message
});
socket.on('message deleted', (messageId) => {
// Handle deleted message
});
```

43
app/components/Modal.tsx Normal file
View File

@ -0,0 +1,43 @@
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
{/* Background overlay */}
<div
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
onClick={onClose}
/>
{/* Modal panel */}
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
<span className="sr-only">Close</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mt-2">
{children}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -13,7 +13,7 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
const pageNumbers = []; const pageNumbers = [];
const maxVisiblePages = 5; const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2)); let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) { if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1); startPage = Math.max(1, endPage - maxVisiblePages + 1);
@ -28,7 +28,7 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
<button <button
onClick={() => onPageChange(currentPage - 1)} onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
className="px-3 py-1 rounded-md bg-gray-100 text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 rounded-md bg-[var(--card-bg)] text-[var(--text-primary)] disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--card-border)] transition-colors"
> >
Previous Previous
</button> </button>
@ -38,12 +38,14 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
<button <button
onClick={() => onPageChange(1)} onClick={() => onPageChange(1)}
className={`px-3 py-1 rounded-md ${ className={`px-3 py-1 rounded-md ${
currentPage === 1 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700' currentPage === 1
}`} ? 'bg-[var(--accent)] text-white'
: 'bg-[var(--card-bg)] text-[var(--text-primary)] hover:bg-[var(--card-border)]'
} transition-colors`}
> >
1 1
</button> </button>
{startPage > 2 && <span className="px-2">...</span>} {startPage > 2 && <span className="px-2 text-[var(--text-secondary)]">...</span>}
</> </>
)} )}
@ -52,8 +54,10 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
key={number} key={number}
onClick={() => onPageChange(number)} onClick={() => onPageChange(number)}
className={`px-3 py-1 rounded-md ${ className={`px-3 py-1 rounded-md ${
currentPage === number ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700' currentPage === number
}`} ? 'bg-[var(--accent)] text-white'
: 'bg-[var(--card-bg)] text-[var(--text-primary)] hover:bg-[var(--card-border)]'
} transition-colors`}
> >
{number} {number}
</button> </button>
@ -61,12 +65,14 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
{endPage < totalPages && ( {endPage < totalPages && (
<> <>
{endPage < totalPages - 1 && <span className="px-2">...</span>} {endPage < totalPages - 1 && <span className="px-2 text-[var(--text-secondary)]">...</span>}
<button <button
onClick={() => onPageChange(totalPages)} onClick={() => onPageChange(totalPages)}
className={`px-3 py-1 rounded-md ${ className={`px-3 py-1 rounded-md ${
currentPage === totalPages ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700' currentPage === totalPages
}`} ? 'bg-[var(--accent)] text-white'
: 'bg-[var(--card-bg)] text-[var(--text-primary)] hover:bg-[var(--card-border)]'
} transition-colors`}
> >
{totalPages} {totalPages}
</button> </button>
@ -76,7 +82,7 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
<button <button
onClick={() => onPageChange(currentPage + 1)} onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="px-3 py-1 rounded-md bg-gray-100 text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed" className="px-3 py-1 rounded-md bg-[var(--card-bg)] text-[var(--text-primary)] disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--card-border)] transition-colors"
> >
Next Next
</button> </button>

View File

@ -8,13 +8,13 @@ export default function SearchInput({ placeholder, value, onChange }: SearchInpu
return ( return (
<div className="relative max-w-xs"> <div className="relative max-w-xs">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-5 w-5 text-[var(--text-secondary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</div> </div>
<input <input
type="text" type="text"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm" className="block w-full pl-10 pr-3 py-2 border border-[var(--card-border)] rounded-md leading-5 bg-[var(--card-bg)] text-[var(--text-primary)] placeholder-[var(--text-muted)] focus:outline-none focus:ring-1 focus:ring-[var(--accent)] focus:border-[var(--accent)] sm:text-sm"
placeholder={placeholder} placeholder={placeholder}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}

View File

@ -0,0 +1,34 @@
import { ButtonHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'default' | 'outline' | 'destructive';
size?: 'default' | 'sm' | 'lg';
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'default', size = 'default', ...props }, ref) => {
return (
<button
className={cn(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
'border border-input bg-background hover:bg-accent hover:text-accent-foreground': variant === 'outline',
'bg-destructive text-destructive-foreground hover:bg-destructive/90': variant === 'destructive',
'h-10 px-4 py-2': size === 'default',
'h-9 rounded-md px-3': size === 'sm',
'h-11 rounded-md px-8': size === 'lg',
},
className
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button };

View File

@ -0,0 +1,18 @@
import { HTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
)
);
Card.displayName = 'Card';
export { Card };

View File

@ -0,0 +1,22 @@
import { InputHTMLAttributes, forwardRef } from 'react';
import { cn } from '@/lib/utils';
const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

160
app/dashboard/chat/page.tsx Normal file
View File

@ -0,0 +1,160 @@
'use client';
import { useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card } from '@/components/ui/card';
import { User, fetchUsers } from '@/utils/api';
interface ChatMessage {
id: string;
user: string;
message: string;
timestamp: number;
}
export default function ChatModeration() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [socket, setSocket] = useState<Socket | null>(null);
const [editingMessage, setEditingMessage] = useState<string | null>(null);
const [editText, setEditText] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadUsers = async () => {
try {
const usersData = await fetchUsers();
setUsers(usersData);
} catch (error) {
console.error('Error loading users:', error);
} finally {
setLoading(false);
}
};
loadUsers();
}, []);
useEffect(() => {
const newSocket = io('https://wschat.duelfi.io');
setSocket(newSocket);
newSocket.on('recent messages', (recentMessages: ChatMessage[]) => {
setMessages(recentMessages);
});
newSocket.on('chat message', (message: ChatMessage) => {
setMessages(prev => [...prev, message]);
});
newSocket.on('message edited', ({ messageId, newMessage }: { messageId: string; newMessage: string }) => {
setMessages(prev =>
prev.map(msg =>
msg.id === messageId ? { ...msg, message: newMessage } : msg
)
);
});
newSocket.on('message deleted', (messageId: string) => {
setMessages(prev => prev.filter(msg => msg.id !== messageId));
});
return () => {
newSocket.close();
};
}, []);
const getUserDisplayName = (userId: string): string => {
const user = users.find(u => u.id === userId);
if (user?.username) {
return user.username;
}
// If no username found, return a truncated version of the ID
return userId.startsWith('did:privy:')
? `User ${userId.slice(-4)}`
: userId;
};
const handleEdit = (message: ChatMessage) => {
setEditingMessage(message.id);
setEditText(message.message);
};
const handleSaveEdit = (messageId: string) => {
if (socket) {
socket.emit('edit message', {
messageId,
newMessage: editText
});
setEditingMessage(null);
}
};
const handleDelete = (messageId: string) => {
if (socket && confirm('Are you sure you want to delete this message?')) {
socket.emit('delete message', messageId);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg text-[var(--text-primary)]">Loading...</div>
</div>
);
}
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Chat Moderation</h1>
<div className="space-y-4">
{messages.map((message) => (
<Card key={message.id} className="p-4">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="text-sm text-[var(--text-secondary)]">
{getUserDisplayName(message.user)} {new Date(message.timestamp).toLocaleString()}
</div>
{editingMessage === message.id ? (
<div className="mt-2 flex gap-2">
<Input
value={editText}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEditText(e.target.value)}
className="flex-1"
/>
<Button onClick={() => handleSaveEdit(message.id)}>Save</Button>
<Button variant="outline" onClick={() => setEditingMessage(null)}>
Cancel
</Button>
</div>
) : (
<div className="mt-1 text-[var(--text-primary)]">{message.message}</div>
)}
</div>
{editingMessage !== message.id && (
<div className="flex gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(message)}
>
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(message.id)}
>
Delete
</Button>
</div>
)}
</div>
</Card>
))}
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
export default function DashboardLayout({ export default function DashboardLayout({
@ -9,26 +10,51 @@ export default function DashboardLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const handleLogout = () => { const handleLogout = () => {
Cookies.remove('isAuthenticated', { path: '/' }); Cookies.remove('isAuthenticated', { path: '/' });
router.push('/login'); router.push('/login');
}; };
const isActive = (path: string) => pathname === path;
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-[var(--background)]">
<nav className="bg-white shadow-lg"> <nav className="bg-[var(--card-bg)] border-b border-[var(--card-border)]">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16"> <div className="flex justify-between h-16">
<div className="flex"> <div className="flex">
<div className="flex-shrink-0 flex items-center"> <div className="flex-shrink-0 flex items-center">
<h1 className="text-xl font-bold">Admin Panel</h1> <h1 className="text-xl font-bold text-[var(--text-primary)]">Admin Panel</h1>
</div>
<div className="ml-6 flex items-center space-x-4">
<Link
href="/dashboard"
className={`px-3 py-2 rounded-md text-sm font-medium ${
isActive('/dashboard')
? 'text-[var(--accent)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Dashboard
</Link>
<Link
href="/dashboard/chat"
className={`px-3 py-2 rounded-md text-sm font-medium ${
isActive('/dashboard/chat')
? 'text-[var(--accent)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
}`}
>
Chat Moderation
</Link>
</div> </div>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<button <button
onClick={handleLogout} onClick={handleLogout}
className="ml-4 px-4 py-2 text-sm text-red-600 hover:text-red-900" className="ml-4 px-4 py-2 text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
> >
Logout Logout
</button> </button>

View File

@ -1,21 +1,40 @@
'use client'; 'use client';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect } from 'react';
import { Game, User, fetchGames, fetchUsers, truncateAddress } from '../utils/api'; import { Game, User, fetchGames, fetchUsers, getDisplayGameName } from '../utils/api';
import Pagination from '../components/Pagination'; import Link from 'next/link';
import SearchInput from '../components/SearchInput'; import { Line, Bar } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend
} from 'chart.js';
const ITEMS_PER_PAGE = 20; ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Title,
Tooltip,
Legend
);
type TimeRange = '7d' | '30d' | '90d';
export default function Dashboard() { export default function Dashboard() {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [games, setGames] = useState<Game[]>([]); const [games, setGames] = useState<Game[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentUserPage, setCurrentUserPage] = useState(1); const [gameTimeRange, setGameTimeRange] = useState<TimeRange>('30d');
const [currentGamePage, setCurrentGamePage] = useState(1);
const [userSearch, setUserSearch] = useState('');
const [gameSearch, setGameSearch] = useState('');
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@ -37,77 +56,172 @@ export default function Dashboard() {
loadData(); loadData();
}, []); }, []);
useEffect(() => { const getDaysFromTimeRange = (range: TimeRange): number => {
// Reset to first page when search changes switch (range) {
setCurrentUserPage(1); case '7d': return 7;
}, [userSearch]); case '30d': return 30;
case '90d': return 90;
}
};
useEffect(() => { const getGameCountData = () => {
// Reset to first page when search changes const days = getDaysFromTimeRange(gameTimeRange);
setCurrentGamePage(1); const lastDays = Array.from({ length: days }, (_, i) => {
}, [gameSearch]); const date = new Date();
date.setDate(date.getDate() - i);
return date.toISOString().split('T')[0];
}).reverse();
const formatDate = (dateStr: string) => { const gameCounts = lastDays.map(date => {
const date = new Date(dateStr); return games.filter(game => {
return date.toLocaleString('en-US', { try {
year: 'numeric', const gameDate = new Date(game.ended_time).toISOString().split('T')[0];
month: 'short', return gameDate === date;
day: '2-digit', } catch (err) {
hour: '2-digit', console.error('Error parsing date:', err);
minute: '2-digit' return false;
}
}).length;
}); });
return {
labels: lastDays.map(date => new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })),
datasets: [
{
label: 'Games per Day',
data: gameCounts,
borderColor: '#FFA500',
backgroundColor: 'rgba(255, 165, 0, 0.2)',
tension: 0.4,
fill: true
}
]
};
}; };
const getGameResult = (game: Game) => { const getGamePopularityData = () => {
const masterScore = parseInt(game.master_score); const gameTypes = Array.from(new Set(games.map(game => game.game)));
const clientScore = parseInt(game.client_score); const gameCounts = gameTypes.map(type => ({
if (masterScore === clientScore) return 'Draw'; type,
return game.winner === 'master' ? 'Master Won' : 'Client Won'; displayName: getDisplayGameName(type),
count: games.filter(game => game.game === type).length
})).sort((a, b) => b.count - a.count);
return {
labels: gameCounts.map(game => game.displayName),
datasets: [
{
label: 'Games Played',
data: gameCounts.map(game => game.count),
backgroundColor: 'rgba(255, 165, 0, 0.7)',
borderColor: '#FFA500',
borderWidth: 1
}
]
};
}; };
// Filter and paginate users const chartOptions = {
const filteredUsers = useMemo(() => { responsive: true,
if (!userSearch) return users; plugins: {
const searchLower = userSearch.toLowerCase(); legend: {
return users.filter(user => display: false,
user.ref_id.toLowerCase().includes(searchLower) || labels: {
(user.username || '').toLowerCase().includes(searchLower) || color: '#ffffff'
(user.active_wallet || '').toLowerCase().includes(searchLower) || }
(user.x_profile_url || '').toLowerCase().includes(searchLower) || },
(user.referred_id || '').toLowerCase().includes(searchLower) tooltip: {
); titleColor: '#ffffff',
}, [users, userSearch]); bodyColor: '#ffffff',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: '#ffffff'
}
},
x: {
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: '#ffffff'
}
}
}
};
// Filter and paginate games const barChartOptions = {
const filteredGames = useMemo(() => { responsive: true,
if (!gameSearch) return games; plugins: {
const searchLower = gameSearch.toLowerCase(); legend: {
return games.filter(game => display: false,
game.ended_time.toLowerCase().includes(searchLower) || labels: {
game.game.toLowerCase().includes(searchLower) || color: '#ffffff'
game.winner.toLowerCase().includes(searchLower) || }
game.master_id.toLowerCase().includes(searchLower) || },
game.client_id.toLowerCase().includes(searchLower) || tooltip: {
game.master_score.includes(searchLower) || titleColor: '#ffffff',
game.client_score.includes(searchLower) || bodyColor: '#ffffff',
game.wager.includes(searchLower) backgroundColor: 'rgba(0, 0, 0, 0.8)',
); borderColor: 'rgba(255, 255, 255, 0.1)',
}, [games, gameSearch]); borderWidth: 1
}
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: '#ffffff'
}
},
x: {
grid: {
color: 'rgba(255, 255, 255, 0.1)'
},
ticks: {
color: '#ffffff'
}
}
}
};
const currentUsers = filteredUsers.slice( const TimeRangeButton = ({
(currentUserPage - 1) * ITEMS_PER_PAGE, range,
currentUserPage * ITEMS_PER_PAGE currentRange,
); onChange
}: {
const currentGames = filteredGames.slice( range: TimeRange;
(currentGamePage - 1) * ITEMS_PER_PAGE, currentRange: TimeRange;
currentGamePage * ITEMS_PER_PAGE onChange: (range: TimeRange) => void;
}) => (
<button
onClick={() => onChange(range)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
currentRange === range
? 'bg-[var(--accent)] text-white'
: 'bg-[var(--card-bg)] text-[var(--text-secondary)] hover:bg-[var(--card-border)]'
}`}
>
{range}
</button>
); );
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-lg">Loading...</div> <div className="text-lg text-[var(--text-primary)]">Loading...</div>
</div> </div>
); );
} }
@ -115,166 +229,62 @@ export default function Dashboard() {
if (error) { if (error) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
<div className="text-red-500">{error}</div> <div className="text-[var(--accent)]">{error}</div>
</div> </div>
); );
} }
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Users Section */} <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-white shadow rounded-lg p-6"> {/* Users Card */}
<div className="flex justify-between items-center mb-4"> <div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg p-6">
<h2 className="text-xl font-semibold text-gray-900">Users</h2> <div className="flex justify-between items-center">
<div className="flex items-center space-x-4"> <h2 className="text-xl font-semibold text-[var(--text-primary)]">
<SearchInput Users - {users.length}
placeholder="Search users..." </h2>
value={userSearch} <Link
onChange={setUserSearch} href="/users"
/> className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
<div className="text-sm text-gray-600"> >
Total Users: {filteredUsers.length} View All
</div> </Link>
</div> </div>
</div> </div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> {/* Game Count Card */}
<thead className="bg-gray-50"> <div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg p-6">
<tr> <div className="flex justify-between items-center mb-4">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <h2 className="text-xl font-semibold text-[var(--text-primary)]">
Ref ID Games - {games.length}
</th> </h2>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <Link
Username href="/games"
</th> className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> >
Wallet View All
</th> </Link>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> </div>
X Profile <div className="flex justify-end space-x-2 mb-4">
</th> <TimeRangeButton range="7d" currentRange={gameTimeRange} onChange={setGameTimeRange} />
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <TimeRangeButton range="30d" currentRange={gameTimeRange} onChange={setGameTimeRange} />
Referred By <TimeRangeButton range="90d" currentRange={gameTimeRange} onChange={setGameTimeRange} />
</th> </div>
</tr> <div className="h-[300px]">
</thead> <Line data={getGameCountData()} options={chartOptions} />
<tbody className="bg-white divide-y divide-gray-200"> </div>
{currentUsers.map((user) => (
<tr key={user.ref_id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{user.ref_id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.username || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{truncateAddress(user.active_wallet) || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.x_profile_url ? (
<a
href={user.x_profile_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800"
>
View Profile
</a>
) : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.referred_id === '-1' ? '-' : user.referred_id}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
totalItems={filteredUsers.length}
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentUserPage}
onPageChange={setCurrentUserPage}
/>
</div> </div>
</div> </div>
{/* Games History Section */} {/* Game Popularity Card */}
<div className="bg-white shadow rounded-lg p-6"> <div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg p-6">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900">Game History</h2> <h2 className="text-xl font-semibold text-[var(--text-primary)]">
<div className="flex items-center space-x-4"> Game Popularity
<SearchInput </h2>
placeholder="Search games..."
value={gameSearch}
onChange={setGameSearch}
/>
<div className="text-sm text-gray-600">
Total Games: {filteredGames.length}
</div>
</div>
</div> </div>
<div className="overflow-x-auto"> <div className="h-[300px]">
<table className="min-w-full divide-y divide-gray-200"> <Bar data={getGamePopularityData()} options={barChartOptions} />
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Game
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Winner
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Players
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Score
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Result
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Wager (SOL)
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentGames.map((game) => (
<tr key={game.address}>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(game.ended_time)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 capitalize">
{game.game}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 capitalize">
{game.winner}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div>Master: {truncateAddress(game.master_id)}</div>
<div>Client: {truncateAddress(game.client_id)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{game.master_score} - {game.client_score}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{getGameResult(game)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(parseInt(game.wager) / 1000000).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
totalItems={filteredGames.length}
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentGamePage}
onPageChange={setCurrentGamePage}
/>
</div> </div>
</div> </div>
</div> </div>

243
app/games/page.tsx Normal file
View File

@ -0,0 +1,243 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { Game, User, fetchGames, fetchUsers, truncateAddress, getDisplayGameName } from '../utils/api';
import Pagination from '../components/Pagination';
import SearchInput from '../components/SearchInput';
import Modal from '../components/Modal';
const ITEMS_PER_PAGE = 20;
export default function Games() {
const [games, setGames] = useState<Game[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [showUserModal, setShowUserModal] = useState(false);
const handlePlayerClick = (e: React.MouseEvent, playerId: string) => {
e.preventDefault();
e.stopPropagation();
const user = users.find(u => u.active_wallet === playerId);
if (user) {
setSelectedUser(user);
setShowUserModal(true);
}
};
const getUserDisplayName = (userId: string): string => {
if (userId === 'na') return 'N/A';
const user = users.find(u => u.id === userId || u.active_wallet === userId);
if (user?.username) {
return user.username;
}
return truncateAddress(userId);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// Filter and paginate games
const filteredGames = useMemo(() => {
if (!search) return games;
const searchLower = search.toLowerCase();
return games.filter(game =>
game.ended_time.toLowerCase().includes(searchLower) ||
game.game.toLowerCase().includes(searchLower) ||
game.winner.toLowerCase().includes(searchLower) ||
game.master_id.toLowerCase().includes(searchLower) ||
game.client_id.toLowerCase().includes(searchLower) ||
game.master_score.includes(searchLower) ||
game.client_score.includes(searchLower) ||
game.wager.includes(searchLower)
);
}, [games, search]);
const currentGames = filteredGames.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
);
useEffect(() => {
const loadData = async () => {
try {
const [gamesData, usersData] = await Promise.all([
fetchGames(),
fetchUsers()
]);
setGames(gamesData);
setUsers(usersData);
} catch (err) {
setError('Failed to load data. Please try again later.');
console.error('Error loading data:', err);
} finally {
setLoading(false);
}
};
loadData();
}, []);
useEffect(() => {
setCurrentPage(1);
}, [search]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg text-[var(--text-primary)]">Loading...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-[var(--accent)]">{error}</div>
</div>
);
}
return (
<div className="space-y-6">
{/* User Details Modal */}
<Modal
isOpen={showUserModal}
onClose={() => {
setShowUserModal(false);
setSelectedUser(null);
}}
title="User Details"
>
{selectedUser && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Ref ID</p>
<p className="mt-1 text-[var(--text-primary)]">{selectedUser.ref_id}</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Username</p>
<p className="mt-1 text-[var(--text-primary)]">{selectedUser.username || '-'}</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Wallet Address</p>
<p className="mt-1 text-[var(--text-primary)]">{selectedUser.active_wallet || '-'}</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">X Profile</p>
<p className="mt-1">
{selectedUser.x_profile_url ? (
<a
href={selectedUser.x_profile_url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] hover:text-[var(--accent-hover)]"
onClick={(e) => e.stopPropagation()}
>
View Profile
</a>
) : '-'}
</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Referred By</p>
<p className="mt-1 text-[var(--text-primary)]">{selectedUser.referred_id === '-1' ? '-' : selectedUser.referred_id}</p>
</div>
</div>
</div>
)}
</Modal>
{/* Games History Section */}
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Game History</h2>
<div className="flex items-center space-x-4">
<SearchInput
placeholder="Search games..."
value={search}
onChange={setSearch}
/>
<div className="text-sm text-[var(--text-secondary)]">
Total Games: {filteredGames.length}
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--card-border)]">
<thead className="bg-[var(--card-bg)]">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider">Date</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider">Game</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider">Winner</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider">Players</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider">Score</th>
<th className="px-6 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase tracking-wider">Wager (SOL)</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--card-border)]">
{currentGames.map((game) => (
<tr key={game.address} className="hover:bg-[var(--card-bg)] transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
{formatDate(game.ended_time)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)] capitalize">
{getDisplayGameName(game.game)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)] capitalize">
{game.winner}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
<div>
Master: <button
onClick={(e) => handlePlayerClick(e, game.master_id)}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] hover:underline cursor-pointer transition-colors"
>
{getUserDisplayName(game.master_id)}
</button>
</div>
<div>
Client: <button
onClick={(e) => handlePlayerClick(e, game.client_id)}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] hover:underline cursor-pointer transition-colors"
disabled={game.client_id === 'na'}
>
{getUserDisplayName(game.client_id)}
</button>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
{game.master_score} - {game.client_score}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-[var(--text-primary)]">
{(parseInt(game.wager) / 100000000).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
totalItems={filteredGames.length}
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</div>
</div>
</div>
);
}

View File

@ -1,8 +1,15 @@
@import "tailwindcss"; @import "tailwindcss";
:root { :root {
--background: #ffffff; --background: #121212;
--foreground: #171717; --foreground: #ffffff;
--accent: #ff6b00;
--accent-hover: #ff8533;
--card-bg: #1e1e1e;
--card-border: #2d2d2d;
--text-primary: #ffffff;
--text-secondary: #a0a0a0;
--text-muted: #666666;
} }
@theme inline { @theme inline {
@ -21,6 +28,25 @@
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--text-primary);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--card-bg);
}
::-webkit-scrollbar-thumb {
background: var(--accent);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-hover);
}

6
app/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -2,7 +2,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Image from "next/image";
export default function Home() { export default function Home() {
const router = useRouter(); const router = useRouter();

205
app/users/page.tsx Normal file
View File

@ -0,0 +1,205 @@
'use client';
import { useState, useEffect, useMemo } from 'react';
import { User, fetchUsers, truncateAddress } from '../utils/api';
import Pagination from '../components/Pagination';
import SearchInput from '../components/SearchInput';
import Modal from '../components/Modal';
const ITEMS_PER_PAGE = 20;
export default function Users() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [search, setSearch] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [showUserModal, setShowUserModal] = useState(false);
const handlePlayerClick = (e: React.MouseEvent, playerId: string) => {
e.preventDefault();
e.stopPropagation();
const user = users.find(u => u.active_wallet === playerId);
if (user) {
setSelectedUser(user);
setShowUserModal(true);
}
};
// Filter and paginate users
const filteredUsers = useMemo(() => {
if (!search) return users;
const searchLower = search.toLowerCase();
return users.filter(user =>
user.ref_id.toLowerCase().includes(searchLower) ||
(user.username || '').toLowerCase().includes(searchLower) ||
(user.active_wallet || '').toLowerCase().includes(searchLower) ||
(user.x_profile_url || '').toLowerCase().includes(searchLower) ||
(user.referred_id || '').toLowerCase().includes(searchLower)
);
}, [users, search]);
const currentUsers = filteredUsers.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
);
useEffect(() => {
const loadData = async () => {
try {
const usersData = await fetchUsers();
setUsers(usersData);
} catch (err) {
setError('Failed to load data. Please try again later.');
console.error('Error loading data:', err);
} finally {
setLoading(false);
}
};
loadData();
}, []);
useEffect(() => {
setCurrentPage(1);
}, [search]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg text-[var(--text-primary)]">Loading...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-[var(--accent)]">{error}</div>
</div>
);
}
return (
<div className="space-y-6">
{/* User Details Modal */}
<Modal
isOpen={showUserModal}
onClose={() => {
setShowUserModal(false);
setSelectedUser(null);
}}
title="User Details"
>
{selectedUser && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Ref ID</p>
<p className="mt-1 text-[var(--text-primary)]">{selectedUser.ref_id}</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Username</p>
<p className="mt-1 text-[var(--text-primary)]">{selectedUser.username || '-'}</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Wallet Address</p>
<p className="mt-1 text-[var(--text-primary)]">{selectedUser.active_wallet || '-'}</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">X Profile</p>
<p className="mt-1">
{selectedUser.x_profile_url ? (
<a
href={selectedUser.x_profile_url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] hover:text-[var(--accent-hover)]"
onClick={(e) => e.stopPropagation()}
>
View Profile
</a>
) : '-'}
</p>
</div>
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Referred By</p>
<p className="mt-1 text-[var(--text-primary)]">{selectedUser.referred_id === '-1' ? '-' : selectedUser.referred_id}</p>
</div>
</div>
</div>
)}
</Modal>
{/* Users Section */}
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-[var(--text-primary)]">Users</h2>
<div className="flex items-center space-x-4">
<SearchInput
placeholder="Search users..."
value={search}
onChange={setSearch}
/>
<div className="text-sm text-[var(--text-secondary)]">
Total Users: {filteredUsers.length}
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--card-border)]">
<thead className="bg-[var(--card-bg)]">
<tr>
<th className="px-4 py-2 text-left text-[var(--text-secondary)]">Ref ID</th>
<th className="px-4 py-2 text-left text-[var(--text-secondary)]">Username</th>
<th className="px-4 py-2 text-left text-[var(--text-secondary)]">Bio</th>
<th className="px-4 py-2 text-left text-[var(--text-secondary)]">X Profile</th>
<th className="px-4 py-2 text-left text-[var(--text-secondary)]">Referred By</th>
<th className="px-4 py-2 text-left text-[var(--text-secondary)]">Active Wallet</th>
</tr>
</thead>
<tbody>
{currentUsers.map((user) => (
<tr
key={user.ref_id}
className="border-t border-[var(--card-border)] hover:bg-[var(--card-bg)] cursor-pointer"
onClick={(e) => handlePlayerClick(e, user.active_wallet)}
>
<td className="px-4 py-2 text-[var(--text-primary)]">{user.ref_id}</td>
<td className="px-4 py-2 text-[var(--text-primary)]">{user.username || '-'}</td>
<td className="px-4 py-2 text-[var(--text-primary)]">{user.bio || '-'}</td>
<td className="px-4 py-2 text-[var(--text-primary)]">
{user.x_profile_url ? (
<a
href={user.x_profile_url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] hover:text-[var(--accent-hover)]"
onClick={(e) => e.stopPropagation()}
>
{user.x_profile_url}
</a>
) : '-'}
</td>
<td className="px-4 py-2 text-[var(--text-primary)]">
{user.referred_id === "-1" ? '-' : user.referred_id}
</td>
<td className="px-4 py-2 text-[var(--text-primary)]">
{user.active_wallet ? truncateAddress(user.active_wallet) : '-'}
</td>
</tr>
))}
</tbody>
</table>
<Pagination
totalItems={filteredUsers.length}
itemsPerPage={ITEMS_PER_PAGE}
currentPage={currentPage}
onPageChange={setCurrentPage}
/>
</div>
</div>
</div>
);
}

View File

@ -22,6 +22,7 @@ export interface User {
x_profile_url: string; // "" x_profile_url: string; // ""
referred_id: string; // "-1" referred_id: string; // "-1"
active_wallet: string; // "" active_wallet: string; // ""
joined_date: string; // "2024-03-20T12:00:00Z"
} }
// Helper function to truncate wallet address // Helper function to truncate wallet address
@ -30,6 +31,17 @@ export function truncateAddress(address: string): string {
return `${address.slice(0, 4)}...${address.slice(-4)}`; return `${address.slice(0, 4)}...${address.slice(-4)}`;
} }
export function getDisplayGameName(gameName: string): string {
switch (gameName) {
case 'walls':
return 'Bubble Shooter';
case 'bubbles':
return 'Wall Smash';
default:
return gameName;
}
}
export async function fetchGames(): Promise<Game[]> { export async function fetchGames(): Promise<Game[]> {
try { try {
const response = await fetch('https://api.duelfi.io/v1/admin/get_all_games.php'); const response = await fetch('https://api.duelfi.io/v1/admin/get_all_games.php');

202
package-lock.json generated
View File

@ -9,10 +9,16 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/socket.io-client": "^3.0.0",
"chart.js": "^4.4.9",
"clsx": "^2.1.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "15.3.3", "next": "15.3.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@ -755,6 +761,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.10", "version": "0.2.10",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz",
@ -974,6 +986,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@swc/counter": { "node_modules/@swc/counter": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@ -1333,6 +1351,16 @@
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
}, },
"node_modules/@types/socket.io-client": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/socket.io-client/-/socket.io-client-3.0.0.tgz",
"integrity": "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==",
"deprecated": "This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed.",
"license": "MIT",
"dependencies": {
"socket.io-client": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.33.1", "version": "8.33.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz",
@ -2286,6 +2314,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chart.js": {
"version": "4.4.9",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -2302,6 +2342,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@ -2543,6 +2592,45 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.1", "version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@ -4599,7 +4687,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -5076,6 +5163,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -5489,6 +5586,68 @@
"is-arrayish": "^0.3.1" "is-arrayish": "^0.3.1"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -5712,6 +5871,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tailwind-merge": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.0.tgz",
"integrity": "sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.8", "version": "4.1.8",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz",
@ -6126,6 +6295,35 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@ -3,17 +3,23 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack -p 3045",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -p 3045",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/socket.io-client": "^3.0.0",
"chart.js": "^4.4.9",
"clsx": "^2.1.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "15.3.3", "next": "15.3.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0" "react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",

View File

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
@ -19,7 +19,7 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./app/*"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],