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 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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
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';
|
'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>
|
||||||
|
|
|
||||||
|
|
@ -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
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";
|
@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
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 { 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
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; // ""
|
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
202
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
12
package.json
12
package.json
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user