ready
This commit is contained in:
parent
2dae3acd21
commit
9ab363ca59
121
Chat-admin-readme.md
Normal file
121
Chat-admin-readme.md
Normal 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
43
app/components/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
|
|||
const pageNumbers = [];
|
||||
const maxVisiblePages = 5;
|
||||
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) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||||
|
|
@ -28,7 +28,7 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
|
|||
<button
|
||||
onClick={() => onPageChange(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
|
||||
</button>
|
||||
|
|
@ -38,12 +38,14 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
|
|||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
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
|
||||
</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}
|
||||
onClick={() => onPageChange(number)}
|
||||
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}
|
||||
</button>
|
||||
|
|
@ -61,12 +65,14 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
|
|||
|
||||
{endPage < totalPages && (
|
||||
<>
|
||||
{endPage < totalPages - 1 && <span className="px-2">...</span>}
|
||||
{endPage < totalPages - 1 && <span className="px-2 text-[var(--text-secondary)]">...</span>}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
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}
|
||||
</button>
|
||||
|
|
@ -76,7 +82,7 @@ export default function Pagination({ totalItems, itemsPerPage, currentPage, onPa
|
|||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
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
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ export default function SearchInput({ placeholder, value, onChange }: SearchInpu
|
|||
return (
|
||||
<div className="relative max-w-xs">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
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}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
|
|
|
|||
34
app/components/ui/button.tsx
Normal file
34
app/components/ui/button.tsx
Normal 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 };
|
||||
18
app/components/ui/card.tsx
Normal file
18
app/components/ui/card.tsx
Normal 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 };
|
||||
22
app/components/ui/input.tsx
Normal file
22
app/components/ui/input.tsx
Normal 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
160
app/dashboard/chat/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
export default function DashboardLayout({
|
||||
|
|
@ -9,26 +10,51 @@ export default function DashboardLayout({
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const handleLogout = () => {
|
||||
Cookies.remove('isAuthenticated', { path: '/' });
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const isActive = (path: string) => pathname === path;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow-lg">
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<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="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<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 className="flex items-center">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Game, User, fetchGames, fetchUsers, truncateAddress } from '../utils/api';
|
||||
import Pagination from '../components/Pagination';
|
||||
import SearchInput from '../components/SearchInput';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Game, User, fetchGames, fetchUsers, getDisplayGameName } from '../utils/api';
|
||||
import Link from 'next/link';
|
||||
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() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [games, setGames] = useState<Game[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentUserPage, setCurrentUserPage] = useState(1);
|
||||
const [currentGamePage, setCurrentGamePage] = useState(1);
|
||||
const [userSearch, setUserSearch] = useState('');
|
||||
const [gameSearch, setGameSearch] = useState('');
|
||||
const [gameTimeRange, setGameTimeRange] = useState<TimeRange>('30d');
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
|
|
@ -37,77 +56,172 @@ export default function Dashboard() {
|
|||
loadData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset to first page when search changes
|
||||
setCurrentUserPage(1);
|
||||
}, [userSearch]);
|
||||
const getDaysFromTimeRange = (range: TimeRange): number => {
|
||||
switch (range) {
|
||||
case '7d': return 7;
|
||||
case '30d': return 30;
|
||||
case '90d': return 90;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Reset to first page when search changes
|
||||
setCurrentGamePage(1);
|
||||
}, [gameSearch]);
|
||||
const getGameCountData = () => {
|
||||
const days = getDaysFromTimeRange(gameTimeRange);
|
||||
const lastDays = Array.from({ length: days }, (_, i) => {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - i);
|
||||
return date.toISOString().split('T')[0];
|
||||
}).reverse();
|
||||
|
||||
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'
|
||||
const gameCounts = lastDays.map(date => {
|
||||
return games.filter(game => {
|
||||
try {
|
||||
const gameDate = new Date(game.ended_time).toISOString().split('T')[0];
|
||||
return gameDate === date;
|
||||
} catch (err) {
|
||||
console.error('Error parsing date:', err);
|
||||
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 masterScore = parseInt(game.master_score);
|
||||
const clientScore = parseInt(game.client_score);
|
||||
if (masterScore === clientScore) return 'Draw';
|
||||
return game.winner === 'master' ? 'Master Won' : 'Client Won';
|
||||
const getGamePopularityData = () => {
|
||||
const gameTypes = Array.from(new Set(games.map(game => game.game)));
|
||||
const gameCounts = gameTypes.map(type => ({
|
||||
type,
|
||||
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 filteredUsers = useMemo(() => {
|
||||
if (!userSearch) return users;
|
||||
const searchLower = userSearch.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, userSearch]);
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
labels: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
titleColor: '#ffffff',
|
||||
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 filteredGames = useMemo(() => {
|
||||
if (!gameSearch) return games;
|
||||
const searchLower = gameSearch.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, gameSearch]);
|
||||
const barChartOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
labels: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
titleColor: '#ffffff',
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const currentUsers = filteredUsers.slice(
|
||||
(currentUserPage - 1) * ITEMS_PER_PAGE,
|
||||
currentUserPage * ITEMS_PER_PAGE
|
||||
);
|
||||
|
||||
const currentGames = filteredGames.slice(
|
||||
(currentGamePage - 1) * ITEMS_PER_PAGE,
|
||||
currentGamePage * ITEMS_PER_PAGE
|
||||
const TimeRangeButton = ({
|
||||
range,
|
||||
currentRange,
|
||||
onChange
|
||||
}: {
|
||||
range: TimeRange;
|
||||
currentRange: TimeRange;
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -115,166 +229,62 @@ export default function Dashboard() {
|
|||
if (error) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Users Section */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Users</h2>
|
||||
<div className="flex items-center space-x-4">
|
||||
<SearchInput
|
||||
placeholder="Search users..."
|
||||
value={userSearch}
|
||||
onChange={setUserSearch}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
Total Users: {filteredUsers.length}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Users Card */}
|
||||
<div className="bg-[var(--card-bg)] border border-[var(--card-border)] rounded-lg p-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Users - {users.length}
|
||||
</h2>
|
||||
<Link
|
||||
href="/users"
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Ref ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Username
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Wallet
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
X Profile
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Referred By
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{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}
|
||||
/>
|
||||
|
||||
{/* Game Count Card */}
|
||||
<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)]">
|
||||
Games - {games.length}
|
||||
</h2>
|
||||
<Link
|
||||
href="/games"
|
||||
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 mb-4">
|
||||
<TimeRangeButton range="7d" currentRange={gameTimeRange} onChange={setGameTimeRange} />
|
||||
<TimeRangeButton range="30d" currentRange={gameTimeRange} onChange={setGameTimeRange} />
|
||||
<TimeRangeButton range="90d" currentRange={gameTimeRange} onChange={setGameTimeRange} />
|
||||
</div>
|
||||
<div className="h-[300px]">
|
||||
<Line data={getGameCountData()} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Games History Section */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
{/* Game Popularity Card */}
|
||||
<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-gray-900">Game History</h2>
|
||||
<div className="flex items-center space-x-4">
|
||||
<SearchInput
|
||||
placeholder="Search games..."
|
||||
value={gameSearch}
|
||||
onChange={setGameSearch}
|
||||
/>
|
||||
<div className="text-sm text-gray-600">
|
||||
Total Games: {filteredGames.length}
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Game Popularity
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<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 className="h-[300px]">
|
||||
<Bar data={getGamePopularityData()} options={barChartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
243
app/games/page.tsx
Normal file
243
app/games/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #121212;
|
||||
--foreground: #ffffff;
|
||||
--accent: #ff6b00;
|
||||
--accent-hover: #ff8533;
|
||||
--card-bg: #1e1e1e;
|
||||
--card-border: #2d2d2d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #666666;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
|
|
@ -21,6 +28,25 @@
|
|||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
color: var(--text-primary);
|
||||
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
6
app/lib/utils.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Image from "next/image";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
|
|
|||
205
app/users/page.tsx
Normal file
205
app/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export interface User {
|
|||
x_profile_url: string; // ""
|
||||
referred_id: string; // "-1"
|
||||
active_wallet: string; // ""
|
||||
joined_date: string; // "2024-03-20T12:00:00Z"
|
||||
}
|
||||
|
||||
// Helper function to truncate wallet address
|
||||
|
|
@ -30,6 +31,17 @@ export function truncateAddress(address: string): string {
|
|||
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[]> {
|
||||
try {
|
||||
const response = await fetch('https://api.duelfi.io/v1/admin/get_all_games.php');
|
||||
|
|
|
|||
202
package-lock.json
generated
202
package-lock.json
generated
|
|
@ -9,10 +9,16 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"next": "15.3.3",
|
||||
"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": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
|
@ -755,6 +761,12 @@
|
|||
"@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": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.10.tgz",
|
||||
|
|
@ -974,6 +986,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
|
|
@ -1333,6 +1351,16 @@
|
|||
"@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": {
|
||||
"version": "8.33.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
|
|
@ -2302,6 +2342,15 @@
|
|||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"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": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
|
|
@ -2543,6 +2592,45 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.18.1",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
||||
|
|
@ -4599,7 +4687,6 @@
|
|||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
|
|
@ -5076,6 +5163,16 @@
|
|||
"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": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
|
|
@ -5489,6 +5586,68 @@
|
|||
"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": {
|
||||
"version": "1.2.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz",
|
||||
|
|
@ -6126,6 +6295,35 @@
|
|||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -3,17 +3,23 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev --turbopack -p 3045",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"start": "next start -p 3045",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"next": "15.3.3",
|
||||
"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": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./app/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user