This commit is contained in:
warlock 2025-06-05 20:50:51 +05:30
parent 8a416787a0
commit 2dae3acd21
10 changed files with 636 additions and 104 deletions

View File

@ -0,0 +1,85 @@
interface PaginationProps {
totalItems: number;
itemsPerPage: number;
currentPage: number;
onPageChange: (page: number) => void;
}
export default function Pagination({ totalItems, itemsPerPage, currentPage, onPageChange }: PaginationProps) {
const totalPages = Math.ceil(totalItems / itemsPerPage);
if (totalPages <= 1) return null;
const pageNumbers = [];
const maxVisiblePages = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i);
}
return (
<div className="flex items-center justify-center space-x-2 mt-4">
<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"
>
Previous
</button>
{startPage > 1 && (
<>
<button
onClick={() => onPageChange(1)}
className={`px-3 py-1 rounded-md ${
currentPage === 1 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-700'
}`}
>
1
</button>
{startPage > 2 && <span className="px-2">...</span>}
</>
)}
{pageNumbers.map((number) => (
<button
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'
}`}
>
{number}
</button>
))}
{endPage < totalPages && (
<>
{endPage < totalPages - 1 && <span className="px-2">...</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'
}`}
>
{totalPages}
</button>
</>
)}
<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"
>
Next
</button>
</div>
);
}

View File

@ -0,0 +1,24 @@
interface SearchInputProps {
placeholder: string;
value: string;
onChange: (value: string) => void;
}
export default function SearchInput({ placeholder, value, onChange }: SearchInputProps) {
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">
<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"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}

45
app/dashboard/layout.tsx Normal file
View File

@ -0,0 +1,45 @@
'use client';
import { useRouter } from 'next/navigation';
import Cookies from 'js-cookie';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const handleLogout = () => {
Cookies.remove('isAuthenticated', { path: '/' });
router.push('/login');
};
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow-lg">
<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>
</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"
>
Logout
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
{children}
</main>
</div>
);
}

282
app/dashboard/page.tsx Normal file
View File

@ -0,0 +1,282 @@
'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';
const ITEMS_PER_PAGE = 20;
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('');
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(() => {
// Reset to first page when search changes
setCurrentUserPage(1);
}, [userSearch]);
useEffect(() => {
// Reset to first page when search changes
setCurrentGamePage(1);
}, [gameSearch]);
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 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';
};
// 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]);
// 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 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
);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg">Loading...</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-red-500">{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>
</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}
/>
</div>
</div>
{/* Games History 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">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>
</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>
</div>
</div>
);
}

84
app/login/page.tsx Normal file
View File

@ -0,0 +1,84 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Cookies from 'js-cookie';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
// For demo purposes, using hardcoded credentials
// In production, this should be replaced with proper authentication
if (username === 'admin' && password === 'admin123') {
// Set authentication cookie
Cookies.set('isAuthenticated', 'true', { path: '/' });
router.push('/dashboard');
} else {
setError('Invalid username or password');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-lg">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Admin Login
</h2>
</div>
<form className="mt-8 space-y-6" onSubmit={handleLogin}>
{error && (
<div className="text-red-500 text-sm text-center">{error}</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
Username
</label>
<input
id="username"
name="username"
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
Password
</label>
<input
id="password"
name="password"
type="password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Sign in
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -1,103 +1,15 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Image from "next/image";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
const router = useRouter();
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
useEffect(() => {
router.push('/login');
}, [router]);
return null;
}

60
app/utils/api.ts Normal file
View File

@ -0,0 +1,60 @@
export interface Game {
ended_time: string; // "2025-06-04 13:24:52"
address: string; // "12oDoNd73dCcHqq2fB5QxpzE8TTQQyMVj4mGavm2pGX8"
game: string; // "tetris"
master_score: string; // "0"
client_score: string; // "0"
winner: string; // "master"
wager: string; // "1000000"
master_id: string; // "did:privy:cm9a3f19v00iri90mag51e0zk"
client_id: string; // "na"
reward_tx: string; // "xYJnbWNTyMU36J7HL188Rq6tMHYf8X6ausPXP6izedNAiuzdPmwYkeCNFeRejT2mU8uXpjPjVPAaK5GD42dMbGM"
rematch_address: string | null; // null
owner_referree: string | null; // "9esrj2X33pr5og6fdkDMjaW6fdnnb9hT1cWshamxTdL4"
joiner_referree: string | null; // "9esrj2X33pr5og6fdkDMjaW6fdnnb9hT1cWshamxTdL4"
}
export interface User {
ref_id: string; // "146"
id: string; // ""
username: string; // ""
bio: string; // ""
x_profile_url: string; // ""
referred_id: string; // "-1"
active_wallet: string; // ""
}
// Helper function to truncate wallet address
export function truncateAddress(address: string): string {
if (!address || address === 'na' || address.startsWith('did:privy:')) return address;
return `${address.slice(0, 4)}...${address.slice(-4)}`;
}
export async function fetchGames(): Promise<Game[]> {
try {
const response = await fetch('https://api.duelfi.io/v1/admin/get_all_games.php');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching games:', error);
throw error;
}
}
export async function fetchUsers(): Promise<User[]> {
try {
const response = await fetch('https://api.duelfi.io/v1/admin/get_all_users.php');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error('Error fetching users:', error);
throw error;
}
}

21
middleware.ts Normal file
View File

@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const isAuthenticated = request.cookies.get('isAuthenticated')?.value === 'true';
const isLoginPage = request.nextUrl.pathname === '/login';
if (!isAuthenticated && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
if (isAuthenticated && isLoginPage) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/login']
};

17
package-lock.json generated
View File

@ -8,6 +8,8 @@
"name": "duelfi_admin",
"version": "0.1.0",
"dependencies": {
"@types/js-cookie": "^3.0.6",
"js-cookie": "^3.0.5",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
@ -1281,6 +1283,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -4083,6 +4091,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -9,19 +9,21 @@
"lint": "next lint"
},
"dependencies": {
"@types/js-cookie": "^3.0.6",
"js-cookie": "^3.0.5",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.3.3"
"react-dom": "^19.0.0"
},
"devDependencies": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4",
"typescript": "^5"
}
}