This commit is contained in:
2026-04-07 10:38:28 +03:30
parent aa9ed69dd2
commit 8bcd1c2951
99 changed files with 3357 additions and 178 deletions

233
README.md Normal file
View File

@@ -0,0 +1,233 @@
# ⚽ فانتزی فوتبال - Fantasy Football
یک پلتفرم فانتزی فوتبال کامل با Next.js، Prisma و PostgreSQL
## 🌟 ویژگی‌ها
### برای کاربران
- ✅ ثبت‌نام و ورود امن با NextAuth
- ✅ ساخت تیم به صورت استپ‌بای‌استپ
- ✅ انتخاب لوگو و نام تیم
- ✅ انتخاب از 6 ترکیب مختلف
- ✅ مدیریت بودجه (100 میلیون)
- ✅ انتخاب 11 بازیکن اصلی + 4 ذخیره
- ✅ Drag & Drop برای جابجایی بازیکنان
- ✅ انتخاب کاپیتان و نایب کاپیتان
- ✅ فیلتر و جستجوی بازیکنان
- ✅ نمایش زمین فوتبال واقعی
- ✅ پیگیری امتیازات
### برای ادمین
- ✅ مدیریت کشورها و پرچم‌ها
- ✅ مدیریت بازیکنان
- ✅ مدیریت مسابقات و راند‌ها
- ✅ ثبت رویدادهای بازی (گل، کارت، و...)
- ✅ محاسبه خودکار امتیازات
- ✅ تایید تیم‌های کاربران
- ✅ مدیریت قوانین امتیازدهی
- ✅ آمار و گزارش‌گیری
## 🚀 نصب و راه‌اندازی
### پیش‌نیازها
- Node.js 18+
- PostgreSQL
- npm یا yarn
### مراحل نصب
1. **کلون کردن پروژه**
```bash
git clone <repository-url>
cd football-next
```
2. **نصب وابستگی‌ها**
```bash
npm install
```
3. **تنظیم متغیرهای محیطی**
فایل `.env` را ایجاد کنید:
```env
DATABASE_URL="postgresql://user:password@host:port/database"
NEXTAUTH_SECRET="your-secret-key"
NEXTAUTH_URL="http://localhost:3000"
```
4. **راه‌اندازی دیتابیس**
```bash
npm run db:generate
npm run db:push
```
5. **ساخت کاربران تست**
```bash
# کاربر عادی
npm run setup:test-user
# کاربر ادمین
npm run setup:admin
```
6. **اجرای پروژه**
```bash
npm run dev
```
پروژه روی `http://localhost:3000` اجرا می‌شود.
## 📋 اسکریپت‌های NPM
```bash
npm run dev # اجرای development server
npm run build # ساخت برای production
npm run start # اجرای production server
npm run db:push # اعمال تغییرات schema به دیتابیس
npm run db:generate # تولید Prisma Client
npm run db:studio # باز کردن Prisma Studio
npm run setup:test-user # ساخت کاربر تست
npm run setup:admin # ساخت کاربر ادمین
npm run check:users # بررسی کاربران موجود
```
## 🔐 کاربران پیش‌فرض
### کاربر عادی
- ایمیل: `test@test.com`
- رمز عبور: `123456`
### ادمین
- ایمیل: `admin@admin.com`
- رمز عبور: `admin123`
## 📁 ساختار پروژه
```
football-next/
├── app/ # Next.js App Router
│ ├── (admin)/ # صفحات ادمین
│ ├── (user)/ # صفحات کاربر
│ └── api/ # API Routes
├── components/ # کامپوننت‌های React
├── lib/ # توابع کمکی
│ ├── auth.ts # تنظیمات NextAuth
│ └── db.ts # Prisma Client
├── prisma/ # Schema و Migrations
├── public/ # فایل‌های استاتیک
├── scripts/ # اسکریپت‌های کمکی
└── styles/ # فایل‌های CSS
```
## 🎮 راهنمای استفاده
### برای کاربران
مستندات کامل در [USER-GUIDE.md](./USER-GUIDE.md)
### برای توسعه‌دهندگان
مستندات راه‌اندازی در [SETUP.md](./SETUP.md)
## 🛠️ تکنولوژی‌ها
- **Framework**: Next.js 16 (App Router)
- **Database**: PostgreSQL
- **ORM**: Prisma
- **Authentication**: NextAuth.js
- **Styling**: Tailwind CSS
- **Language**: TypeScript
- **Drag & Drop**: Native HTML5
## 📊 مدل دیتابیس
### جداول اصلی
- `User` - کاربران
- `Team` - تیم‌های کاربران
- `Player` - بازیکنان
- `Country` - کشورها
- `Match` - مسابقات
- `Round` - راندها
- `MatchEvent` - رویدادهای بازی
- `PlayerMatchStat` - آمار بازیکنان
- `ScoringRule` - قوانین امتیازدهی
## 🎨 ویژگی‌های طراحی
- طراحی Responsive
- حالت RTL برای فارسی
- انیمیشن‌های روان
- رنگ‌بندی مدرن
- UX بهینه
- دسترسی‌پذیری
## 🔄 فرآیند ساخت تیم
### استپ 1: مشخصات تیم
1. انتخاب لوگو (10 گزینه)
2. وارد کردن نام تیم
3. ساخت تیم
### استپ 2: انتخاب بازیکنان
1. انتخاب ترکیب
2. اضافه کردن 11 بازیکن اصلی
3. اضافه کردن 4 بازیکن ذخیره
4. انتخاب کاپیتان و نایب کاپیتان
5. ثبت نهایی
## 🐛 رفع مشکلات
### خطای Foreign Key
```bash
# بررسی کاربران
npm run check:users
# ساخت کاربر جدید
npm run setup:test-user
```
### مشکل Session
1. از مرورگر خارج شوید
2. Cache را پاک کنید
3. دوباره وارد شوید
### خطای دیتابیس
```bash
# ریست کردن دیتابیس
npx prisma db push --force-reset
# تولید مجدد Client
npm run db:generate
```
## 📈 TODO
- [ ] آپلود تصویر برای لوگو تیم
- [ ] پیش‌نمایش تیم
- [ ] انیمیشن‌های بهتر
- [ ] نمایش آمار تفصیلی بازیکنان
- [ ] فیلتر پیشرفته
- [ ] مقایسه بازیکنان
- [ ] پیش‌بینی امتیازات
- [ ] اعلان‌های Real-time
- [ ] چت و نظرات
- [ ] لیگ‌های خصوصی
## 🤝 مشارکت
برای مشارکت در پروژه:
1. Fork کنید
2. Branch جدید بسازید
3. تغییرات را Commit کنید
4. Push کنید
5. Pull Request بزنید
## 📄 لایسنس
این پروژه تحت لایسنس MIT است.
## 📞 تماس
برای سوالات و پشتیبانی، با ما تماس بگیرید.
---
**ساخته شده با ❤️ برای علاقه‌مندان فوتبال**

112
SETUP.md Normal file
View File

@@ -0,0 +1,112 @@
# راهنمای راه‌اندازی پروژه فانتزی فوتبال
## 🚀 نصب و راه‌اندازی
### 1. نصب وابستگی‌ها
```bash
npm install
```
### 2. تنظیم دیتابیس
```bash
npx prisma generate
npx prisma db push
```
### 3. ساخت کاربران تست
#### کاربر عادی
```bash
npx tsx scripts/create-test-user.ts
```
- ایمیل: `test@test.com`
- رمز عبور: `123456`
#### کاربر ادمین
```bash
npx tsx scripts/create-admin-user.ts
```
- ایمیل: `admin@admin.com`
- رمز عبور: `admin123`
### 4. اجرای پروژه
```bash
npm run dev
```
پروژه روی `http://localhost:3000` اجرا می‌شود.
## 📋 مراحل ساخت تیم
### استپ 1: نام تیم و لوگو
1. به صفحه `/team` بروید
2. یک لوگو برای تیم انتخاب کنید (از 10 گزینه موجود)
3. نام تیم را وارد کنید (حداکثر 30 کاراکتر)
4. روی "بعدی: انتخاب ترکیب" کلیک کنید
### استپ 2: انتخاب ترکیب و بازیکنان
1. یک ترکیب انتخاب کنید (4-3-3، 4-4-2، و...)
2. از لیست سمت راست، بازیکنان را انتخاب کنید
3. باید 11 بازیکن اصلی + 4 بازیکن ذخیره داشته باشید
4. بودجه شما 100 میلیون است
5. می‌توانید با drag & drop بازیکنان را جابجا کنید
6. یک کاپیتان و یک نایب کاپیتان انتخاب کنید
7. وقتی تیم کامل شد، روی "وارد رقابت شو" کلیک کنید
## 🔧 اسکریپت‌های مفید
### بررسی کاربران
```bash
npx tsx scripts/check-users.ts
```
### تست Session
مراجعه به: `http://localhost:3000/api/test-session`
## 🎨 ویژگی‌های جدید
### صفحه ساخت تیم
- ✅ طراحی استپ‌بای‌استپ (2 مرحله)
- ✅ انتخاب لوگو تیم (10 گزینه)
- ✅ انتخاب نام تیم با محدودیت 30 کاراکتر
- ✅ نمایش پیشرفت (Progress Indicator)
- ✅ طراحی مدرن و شماتیک
- ✅ رنگ‌بندی بهتر و gradient ها
- ✅ پیام‌های خطا و موفقیت واضح‌تر
- ✅ راهنمای تکمیل تیم
### بهبودهای API
- ✅ بررسی وجود کاربر قبل از ساخت تیم
- ✅ ذخیره formation در زمان ساخت تیم
- ✅ پیام‌های خطای بهتر
## 🐛 رفع مشکلات
### خطای "Foreign key constraint violated"
این خطا زمانی رخ می‌دهد که userId در دیتابیس وجود ندارد. برای رفع:
1. مطمئن شوید که لاگین کرده‌اید
2. اسکریپت `check-users.ts` را اجرا کنید
3. در صورت نیاز، کاربر جدید بسازید
### تیم ساخته نمی‌شود
1. از مرورگر خارج شوید (Logout)
2. دوباره وارد شوید (Login)
3. به صفحه `/team` بروید
4. اگر باز هم مشکل دارید، `/api/test-session` را چک کنید
## 📱 صفحات
- `/` - صفحه اصلی
- `/login` - ورود
- `/register` - ثبت‌نام
- `/team` - ساخت و مدیریت تیم
- `/admin` - پنل ادمین
- `/profile` - پروفایل کاربر
## 🎯 TODO
- [ ] آپلود تصویر برای لوگو تیم
- [ ] پیش‌نمایش تیم قبل از ثبت نهایی
- [ ] انیمیشن‌های بهتر برای drag & drop
- [ ] نمایش آمار بازیکنان در هنگام انتخاب
- [ ] فیلتر پیشرفته بازیکنان (بر اساس قیمت، امتیاز، و...)

168
USER-GUIDE.md Normal file
View File

@@ -0,0 +1,168 @@
# 📖 راهنمای کاربر - فانتزی فوتبال
## 🎮 شروع کار
### ثبت‌نام و ورود
1. به صفحه `/register` بروید
2. ایمیل و رمز عبور خود را وارد کنید
3. پس از ثبت‌نام، وارد شوید
یا از کاربران تست استفاده کنید:
- **کاربر عادی**: `test@test.com` / `123456`
- **ادمین**: `admin@admin.com` / `admin123`
## ⚽ ساخت تیم (2 مرحله)
### مرحله 1⃣: مشخصات تیم
#### انتخاب لوگو
- 10 لوگوی مختلف در دسترس است
- روی هر لوگو کلیک کنید تا انتخاب شود
- لوگوی انتخابی با رنگ سبز هایلایت می‌شود
#### نام تیم
- یک نام منحصر به فرد برای تیم خود انتخاب کنید
- حداکثر 30 کاراکتر
- مثال: "شیران طلایی"، "عقاب‌های آبی"
#### دکمه بعدی
- پس از وارد کردن نام، روی "بعدی: انتخاب ترکیب" کلیک کنید
- تیم شما ساخته می‌شود و به مرحله بعد می‌روید
### مرحله 2⃣: انتخاب بازیکنان
#### انتخاب ترکیب
شش ترکیب مختلف در دسترس است:
- **4-3-3**: 4 مدافع، 3 هافبک، 3 مهاجم (متعادل)
- **4-4-2**: 4 مدافع، 4 هافبک، 2 مهاجم (دفاعی)
- **4-5-1**: 4 مدافع، 5 هافبک، 1 مهاجم (خیلی دفاعی)
- **3-5-2**: 3 مدافع، 5 هافبک، 2 مهاجم (کنترل میانه)
- **3-4-3**: 3 مدافع، 4 هافبک، 3 مهاجم (تهاجمی)
- **5-3-2**: 5 مدافع، 3 هافبک، 2 مهاجم (خیلی دفاعی)
#### بودجه
- بودجه اولیه: **100 میلیون**
- قیمت هر بازیکن از بودجه کم می‌شود
- بودجه باقیمانده در بالای صفحه نمایش داده می‌شود
- اگر بودجه کافی نداشته باشید، نمی‌توانید بازیکن اضافه کنید
#### انتخاب بازیکنان
##### فیلتر کردن
- **فیلتر پست**: GK (دروازه‌بان), DEF (مدافع), MID (هافبک), FWD (مهاجم)
- **جستجو**: نام بازیکن یا کشور را جستجو کنید
##### اضافه کردن بازیکن
1. بازیکن مورد نظر را پیدا کنید
2. روی دکمه **+** کلیک کنید
3. بازیکن به تیم شما اضافه می‌شود
##### تعداد بازیکنان مورد نیاز
- **11 بازیکن اصلی**:
- 1 دروازه‌بان
- تعداد مدافع، هافبک، مهاجم بر اساس ترکیب
- **4 بازیکن ذخیره** (حداقل):
- از هر پست حداقل 1 نفر
#### مدیریت بازیکنان
##### جابجایی (Drag & Drop)
- بازیکن را بگیرید و به جای دیگری بکشید
- می‌توانید بازیکنان اصلی و ذخیره را جابجا کنید
##### منوی بازیکن
روی هر بازیکن کلیک کنید تا منو باز شود:
- **کاپیتان (©)**: امتیاز 2 برابر می‌شود
- **نایب کاپیتان (VC)**: اگر کاپیتان بازی نکند، جایگزین می‌شود
- **حذف از تیم**: بازیکن را از تیم حذف کنید
##### بازیکنان حذف شده
- بازیکنانی که تیم ملی‌شان حذف شده با علامت ❌ نمایش داده می‌شوند
- این بازیکنان دیگر امتیازی نمی‌آورند
- بهتر است آنها را حذف کنید
#### ثبت نهایی تیم
وقتی تیم کامل شد:
1. دکمه سبز "✓ تیم کامله! وارد رقابت شو" نمایش داده می‌شود
2. روی آن کلیک کنید
3. تیم شما برای تایید ادمین ارسال می‌شود
4. پس از تایید، می‌توانید در رقابت شرکت کنید
## 📊 نمایش اطلاعات
### در بالای صفحه
- **امتیاز**: مجموع امتیازات تیم شما
- **بودجه**: بودجه باقیمانده
- **بازیکن**: تعداد بازیکنان اصلی / 11
### در لیست بازیکنان
- **نام بازیکن**
- **کشور و پرچم**
- **پست** (GK, DEF, MID, FWD)
- **قیمت** (به میلیون)
- **امتیاز** (pts)
### روی زمین
- **نام کوتاه بازیکن**
- **امتیاز**
- **کاپیتان (©)** یا **نایب کاپیتان (VC)**
- **وضعیت تیم ملی** (حذف شده یا فعال)
## 💡 نکات مهم
### استراتژی انتخاب
1. **تعادل**: بازیکنان گران و ارزان را ترکیب کنید
2. **فرم**: بازیکنانی که امتیاز بیشتری دارند را انتخاب کنید
3. **تیم ملی**: از بازیکنان تیم‌های قوی انتخاب کنید
4. **ذخیره**: ذخیره‌های خوب داشته باشید
### کاپیتان
- کاپیتان امتیاز 2 برابر می‌آورد
- بهترین بازیکن خود را کاپیتان کنید
- نایب کاپیتان را هم انتخاب کنید
### بودجه
- بودجه را هوشمندانه خرج کنید
- همه بودجه را خرج نکنید (برای تغییرات بعدی)
- بازیکنان ارزان با امتیاز خوب پیدا کنید
## ❓ سوالات متداول
### چرا نمی‌توانم بازیکن اضافه کنم؟
- بودجه کافی ندارید
- تیم شما کامل است (15 بازیکن)
- بازیکن قبلاً در تیم شماست
### چگونه ترکیب را عوض کنم؟
- در بالای زمین، روی ترکیب مورد نظر کلیک کنید
- بازیکنان خودکار جابجا نمی‌شوند، باید دستی جابجا کنید
### چرا تیمم ثبت نمی‌شود؟
- باید 11 بازیکن اصلی داشته باشید
- باید 4 بازیکن ذخیره داشته باشید
- بودجه باید مثبت باشد
### بازیکن حذف شده چیست؟
- بازیکنی که تیم ملی‌اش از مسابقات حذف شده
- دیگر امتیازی نمی‌آورد
- بهتر است حذف شود
## 🎯 مراحل بعدی
پس از ساخت تیم:
1. منتظر تایید ادمین بمانید
2. به صفحه `/profile` بروید
3. تیم خود را مدیریت کنید
4. امتیازات را دنبال کنید
5. در جدول رتبه‌بندی شرکت کنید
## 🆘 پشتیبانی
اگر مشکلی دارید:
1. از مرورگر خارج شوید و دوباره وارد شوید
2. Cache مرورگر را پاک کنید
3. با ادمین تماس بگیرید
---
**موفق باشید! ⚽🏆**

View File

@@ -11,7 +11,19 @@ export default function CountryForm({
countryId, countryId,
}: { }: {
groups: Group[]; groups: Group[];
initial?: { name: string; code: string; flagUrl?: string | null; groupId?: string | null }; initial?: {
name: string;
code: string;
flagUrl?: string | null;
flagImage?: string | null;
groupId?: string | null;
confederation?: string | null;
qualificationMethod?: string | null;
qualificationDate?: string | null;
participationHistory?: string | null;
bestResult?: string | null;
description?: string | null;
};
countryId?: string; countryId?: string;
}) { }) {
const router = useRouter(); const router = useRouter();
@@ -19,52 +31,194 @@ export default function CountryForm({
name: initial?.name ?? "", name: initial?.name ?? "",
code: initial?.code ?? "", code: initial?.code ?? "",
flagUrl: initial?.flagUrl ?? "", flagUrl: initial?.flagUrl ?? "",
flagImage: initial?.flagImage ?? "",
groupId: initial?.groupId ?? "", groupId: initial?.groupId ?? "",
confederation: initial?.confederation ?? "",
qualificationMethod: initial?.qualificationMethod ?? "",
qualificationDate: initial?.qualificationDate ?? "",
participationHistory: initial?.participationHistory ?? "",
bestResult: initial?.bestResult ?? "",
description: initial?.description ?? "",
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
const payload = { ...form, groupId: form.groupId || null, flagUrl: form.flagUrl || null }; const payload = {
...form,
groupId: form.groupId || null,
flagUrl: form.flagUrl || null,
flagImage: form.flagImage || null,
confederation: form.confederation || null,
qualificationMethod: form.qualificationMethod || null,
qualificationDate: form.qualificationDate || null,
participationHistory: form.participationHistory || null,
bestResult: form.bestResult || null,
description: form.description || null,
};
const res = await fetch(countryId ? `/api/countries/${countryId}` : "/api/countries", { const res = await fetch(countryId ? `/api/countries/${countryId}` : "/api/countries", {
method: countryId ? "PUT" : "POST", method: countryId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (res.ok) { router.push("/admin/countries"); router.refresh(); } if (res.ok) {
router.push("/admin/countries");
router.refresh();
}
setLoading(false); setLoading(false);
} }
return ( return (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4"> <form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4 max-w-3xl">
<div> <h3 className="text-lg font-bold mb-2">اطلاعات پایه</h3>
<label className="block text-sm font-medium mb-1">نام تیم</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} <div className="grid grid-cols-2 gap-4">
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required /> <div>
<label className="block text-sm font-medium mb-1">نام تیم *</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">کد (مثلاً IRN) *</label>
<input
type="text"
value={form.code}
onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })}
maxLength={3}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
required
/>
</div>
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">کد (مثلاً IRN)</label> <div className="grid grid-cols-2 gap-4">
<input type="text" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })} <div>
maxLength={3} <label className="block text-sm font-medium mb-1">ایموجی پرچم</label>
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required /> <input
type="text"
value={form.flagUrl}
onChange={(e) => setForm({ ...form, flagUrl: e.target.value })}
placeholder="🇮🇷"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">نام فایل پرچم</label>
<input
type="text"
value={form.flagImage}
onChange={(e) => setForm({ ...form, flagImage: e.target.value })}
placeholder="Flag_of_Iran.webp"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<p className="text-xs text-gray-500 mt-1">فایل باید در public/imgs/flags باشد</p>
</div>
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">ایموجی پرچم (اختیاری)</label> <div className="grid grid-cols-2 gap-4">
<input type="text" value={form.flagUrl} onChange={(e) => setForm({ ...form, flagUrl: e.target.value })} <div>
placeholder="🇮🇷" <label className="block text-sm font-medium mb-1">گروه</label>
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" /> <select
value={form.groupId}
onChange={(e) => setForm({ ...form, groupId: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="">بدون گروه</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>
گروه {g.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">کنفدراسیون</label>
<select
value={form.confederation}
onChange={(e) => setForm({ ...form, confederation: e.target.value })}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
>
<option value="">انتخاب کنید</option>
<option value="UEFA">UEFA (اروپا)</option>
<option value="AFC">AFC (آسیا)</option>
<option value="CAF">CAF (آفریقا)</option>
<option value="CONMEBOL">CONMEBOL (آمریکای جنوبی)</option>
<option value="CONCACAF">CONCACAF (آمریکای شمالی)</option>
<option value="OFC">OFC (اقیانوسیه)</option>
</select>
</div>
</div> </div>
<div>
<label className="block text-sm font-medium mb-1">گروه</label> <hr className="my-2" />
<select value={form.groupId} onChange={(e) => setForm({ ...form, groupId: e.target.value })} <h3 className="text-lg font-bold mb-2">اطلاعات راهیابی</h3>
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500">
<option value="">بدون گروه</option> <div className="grid grid-cols-2 gap-4">
{groups.map((g) => <option key={g.id} value={g.id}>گروه {g.name}</option>)} <div>
</select> <label className="block text-sm font-medium mb-1">شیوه راهیابی</label>
<input
type="text"
value={form.qualificationMethod}
onChange={(e) => setForm({ ...form, qualificationMethod: e.target.value })}
placeholder="مثلاً: صعود از مرحله مقدماتی"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">تاریخ راهیابی</label>
<input
type="text"
value={form.qualificationDate}
onChange={(e) => setForm({ ...form, qualificationDate: e.target.value })}
placeholder="مثلاً: ۲۵ مارس ۲۰۲۵"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
</div> </div>
<button type="submit" disabled={loading}
className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50"> <div>
<label className="block text-sm font-medium mb-1">سابقه شرکت در مسابقات</label>
<input
type="text"
value={form.participationHistory}
onChange={(e) => setForm({ ...form, participationHistory: e.target.value })}
placeholder="مثلاً: ۶ (۱۹۷۸، ۱۹۹۸، ۲۰۰۶، ۲۰۱۴، ۲۰۱۸، ۲۰۲۲)"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">بهترین نتیجه در دورههای گذشته</label>
<input
type="text"
value={form.bestResult}
onChange={(e) => setForm({ ...form, bestResult: e.target.value })}
placeholder="مثلاً: مرحله گروهی"
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">توضیحات</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={4}
placeholder="توضیحات کامل درباره تیم..."
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
</div>
<button
type="submit"
disabled={loading}
className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50"
>
{loading ? "در حال ذخیره..." : countryId ? "ذخیره تغییرات" : "افزودن تیم"} {loading ? "در حال ذخیره..." : countryId ? "ذخیره تغییرات" : "افزودن تیم"}
</button> </button>
</form> </form>

View File

@@ -2,9 +2,10 @@ import { db } from "@/lib/db";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import CountryForm from "../../CountryForm"; import CountryForm from "../../CountryForm";
export default async function EditCountryPage({ params }: { params: { id: string } }) { export default async function EditCountryPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [country, groups] = await Promise.all([ const [country, groups] = await Promise.all([
db.country.findUnique({ where: { id: params.id } }), db.country.findUnique({ where: { id } }),
db.group.findMany({ orderBy: { name: "asc" } }), db.group.findMany({ orderBy: { name: "asc" } }),
]); ]);
if (!country) notFound(); if (!country) notFound();

View File

@@ -0,0 +1,794 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import {
DndContext,
DragOverlay,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragEndEvent,
useDraggable,
useDroppable,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
type Player = {
id: string;
name: string;
position: string;
price: number;
image: string | null;
};
type Country = {
id: string;
name: string;
code: string;
flagUrl: string | null;
defaultFormation: string;
defaultLineupPlayerIds: string[];
defaultCaptainId: string | null;
};
const FORMATIONS: Record<string, { label: string; def: number; mid: number; fwd: number }> = {
"4-3-3": { label: "۴-۳-۳", def: 4, mid: 3, fwd: 3 },
"4-4-2": { label: "۴-۴-۲", def: 4, mid: 4, fwd: 2 },
"4-5-1": { label: "۴-۵-۱", def: 4, mid: 5, fwd: 1 },
"3-5-2": { label: "۳-۵-۲", def: 3, mid: 5, fwd: 2 },
"3-4-3": { label: "۳-۴-۳", def: 3, mid: 4, fwd: 3 },
"5-3-2": { label: "۵-۳-۲", def: 5, mid: 3, fwd: 2 },
"5-4-1": { label: "۵-۴-۱", def: 5, mid: 4, fwd: 1 },
};
const POSITION_LABELS: Record<string, string> = {
GK: "دروازه‌بان",
DEF: "مدافع",
MID: "هافبک",
FWD: "مهاجم",
};
const POSITION_COLORS: Record<string, string> = {
GK: "bg-yellow-100 text-yellow-800",
DEF: "bg-blue-100 text-blue-800",
MID: "bg-green-100 text-green-800",
FWD: "bg-red-100 text-red-800",
};
export default function DefaultLineupEditor({ country, players }: { country: Country; players: Player[] }) {
const router = useRouter();
const [formation, setFormation] = useState(country.defaultFormation);
const [selectedPlayerIds, setSelectedPlayerIds] = useState<string[]>(country.defaultLineupPlayerIds);
const [captainId, setCaptainId] = useState<string | null>(country.defaultCaptainId);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<{ text: string; type: "error" | "success" } | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [pendingFormation, setPendingFormation] = useState<string | null>(null);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [playersToRemove, setPlayersToRemove] = useState<{ position: string; count: number; players: Player[] }>({
position: "",
count: 0,
players: [],
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
const fmt = FORMATIONS[formation] ?? FORMATIONS["4-3-3"];
const gkPlayers = players.filter((p) => p.position === "GK");
const defPlayers = players.filter((p) => p.position === "DEF");
const midPlayers = players.filter((p) => p.position === "MID");
const fwdPlayers = players.filter((p) => p.position === "FWD");
const selectedGk = selectedPlayerIds.filter((id) => gkPlayers.find((p) => p.id === id));
const selectedDef = selectedPlayerIds.filter((id) => defPlayers.find((p) => p.id === id));
const selectedMid = selectedPlayerIds.filter((id) => midPlayers.find((p) => p.id === id));
const selectedFwd = selectedPlayerIds.filter((id) => fwdPlayers.find((p) => p.id === id));
function handleFormationChange(newFormation: string) {
const newFmt = FORMATIONS[newFormation];
const currentFmt = FORMATIONS[formation];
// بررسی اینکه آیا تعداد بازیکنان در هر پست کاهش پیدا می‌کنه
const defDiff = currentFmt.def - newFmt.def;
const midDiff = currentFmt.mid - newFmt.mid;
const fwdDiff = currentFmt.fwd - newFmt.fwd;
// اگر در هر پستی تعداد کاهش پیدا کرد و بازیکن اضافی داریم
if (defDiff > 0 && selectedDef.length > newFmt.def) {
setPendingFormation(newFormation);
setPlayersToRemove({
position: "DEF",
count: defDiff,
players: defPlayers.filter((p) => selectedDef.includes(p.id)),
});
setShowRemoveDialog(true);
return;
}
if (midDiff > 0 && selectedMid.length > newFmt.mid) {
setPendingFormation(newFormation);
setPlayersToRemove({
position: "MID",
count: midDiff,
players: midPlayers.filter((p) => selectedMid.includes(p.id)),
});
setShowRemoveDialog(true);
return;
}
if (fwdDiff > 0 && selectedFwd.length > newFmt.fwd) {
setPendingFormation(newFormation);
setPlayersToRemove({
position: "FWD",
count: fwdDiff,
players: fwdPlayers.filter((p) => selectedFwd.includes(p.id)),
});
setShowRemoveDialog(true);
return;
}
// اگر مشکلی نبود، فرمیشن رو تغییر بده
setFormation(newFormation);
}
function handleRemovePlayer(playerId: string) {
const newSelected = selectedPlayerIds.filter((id) => id !== playerId);
setSelectedPlayerIds(newSelected);
if (captainId === playerId) setCaptainId(null);
// بررسی کنیم آیا به اندازه کافی حذف شده
const newFmt = FORMATIONS[pendingFormation!];
const position = playersToRemove.position;
const currentCount = newSelected.filter((id) => {
const p = players.find((pl) => pl.id === id);
return p?.position === position;
}).length;
let maxCount = 0;
if (position === "DEF") maxCount = newFmt.def;
else if (position === "MID") maxCount = newFmt.mid;
else if (position === "FWD") maxCount = newFmt.fwd;
if (currentCount <= maxCount) {
// تعداد درست شد، فرمیشن رو تغییر بده
setFormation(pendingFormation!);
setShowRemoveDialog(false);
setPendingFormation(null);
setMsg({ text: "فرمیشن تغییر کرد", type: "success" });
setTimeout(() => setMsg(null), 3000);
}
}
function handleCancelFormationChange() {
setShowRemoveDialog(false);
setPendingFormation(null);
setPlayersToRemove({ position: "", count: 0, players: [] });
}
function addPlayer(playerId: string, position: string) {
if (selectedPlayerIds.includes(playerId)) return;
const posPlayers = selectedPlayerIds.filter((id) => {
const p = players.find((pl) => pl.id === id);
return p?.position === position;
});
let maxCount = 1;
if (position === "DEF") maxCount = fmt.def;
else if (position === "MID") maxCount = fmt.mid;
else if (position === "FWD") maxCount = fmt.fwd;
if (posPlayers.length >= maxCount) {
setMsg({ text: `حداکثر ${maxCount} ${position} می‌توانید انتخاب کنید`, type: "error" });
setTimeout(() => setMsg(null), 3000);
return;
}
setSelectedPlayerIds([...selectedPlayerIds, playerId]);
}
function removePlayer(playerId: string) {
setSelectedPlayerIds(selectedPlayerIds.filter((id) => id !== playerId));
if (captainId === playerId) setCaptainId(null);
}
function swapPlayers(playerId: string, direction: "left" | "right") {
const currentIndex = selectedPlayerIds.indexOf(playerId);
if (currentIndex === -1) return;
const player = players.find((p) => p.id === playerId);
if (!player) return;
// پیدا کردن بازیکنان هم‌پست
const samePositionIds = selectedPlayerIds.filter((id) => {
const p = players.find((pl) => pl.id === id);
return p?.position === player.position;
});
const positionIndex = samePositionIds.indexOf(playerId);
const targetIndex = direction === "left" ? positionIndex - 1 : positionIndex + 1;
if (targetIndex < 0 || targetIndex >= samePositionIds.length) return;
const targetPlayerId = samePositionIds[targetIndex];
const targetGlobalIndex = selectedPlayerIds.indexOf(targetPlayerId);
// جابجایی
const newIds = [...selectedPlayerIds];
newIds[currentIndex] = targetPlayerId;
newIds[targetGlobalIndex] = playerId;
setSelectedPlayerIds(newIds);
}
function handleDragStart(event: DragStartEvent) {
const id = event.active.id as string;
setActiveId(id);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActiveId(null);
if (!over) return;
const activePlayerId = active.id as string;
const overTarget = over.id as string;
if (activePlayerId === overTarget) return;
const activePlayer = players.find((p) => p.id === activePlayerId);
if (!activePlayer) return;
const activeInLineup = selectedPlayerIds.includes(activePlayerId);
// اگر روی "add-zone" رها شد
if (overTarget.startsWith("add-zone-")) {
const position = overTarget.replace("add-zone-", "");
if (activePlayer.position === position && !activeInLineup) {
addPlayer(activePlayerId, position);
}
return;
}
// اگر روی بازیکن دیگری رها شد
const overPlayer = players.find((p) => p.id === overTarget);
if (!overPlayer) return;
const overInLineup = selectedPlayerIds.includes(overTarget);
// جابجایی دو بازیکن در ترکیب
if (activeInLineup && overInLineup && activePlayer.position === overPlayer.position) {
const oldIndex = selectedPlayerIds.indexOf(activePlayerId);
const newIndex = selectedPlayerIds.indexOf(overTarget);
setSelectedPlayerIds(arrayMove(selectedPlayerIds, oldIndex, newIndex));
}
// جایگزینی: بازیکن از لیست روی بازیکن در ترکیب
else if (!activeInLineup && overInLineup && activePlayer.position === overPlayer.position) {
const newIds = selectedPlayerIds.map(id => id === overTarget ? activePlayerId : id);
setSelectedPlayerIds(newIds);
if (captainId === overTarget) setCaptainId(null);
}
}
async function handleSave() {
if (selectedPlayerIds.length !== 11) {
setMsg({ text: "باید دقیقاً 11 بازیکن انتخاب کنید", type: "error" });
return;
}
if (selectedGk.length !== 1 || selectedDef.length !== fmt.def ||
selectedMid.length !== fmt.mid || selectedFwd.length !== fmt.fwd) {
setMsg({ text: `ترکیب باید ${formation} باشد`, type: "error" });
return;
}
setLoading(true);
const res = await fetch(`/api/countries/${country.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
defaultFormation: formation,
defaultLineupPlayerIds: selectedPlayerIds,
defaultCaptainId: captainId,
}),
});
if (res.ok) {
setMsg({ text: "ترکیب پیش‌فرض ذخیره شد", type: "success" });
router.refresh();
} else {
const data = await res.json();
setMsg({ text: data.error || "خطا در ذخیره", type: "error" });
}
setLoading(false);
}
const activePlayer = activeId ? players.find((p) => p.id === activeId) : null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-[300px_1fr_320px] gap-6">
{/* ستون چپ: انتخاب فرمیشن */}
<div className="bg-white rounded-2xl shadow p-6 h-fit sticky top-6">
<h2 className="text-lg font-bold mb-4">انتخاب فرمیشن</h2>
<div className="flex flex-col gap-2">
{Object.entries(FORMATIONS).map(([key, val]) => (
<button key={key} onClick={() => handleFormationChange(key)}
className={`py-3 rounded-xl text-sm font-bold border-2 transition ${
formation === key
? "bg-green-700 text-white border-green-700"
: "bg-white border-gray-200 hover:border-green-400"
}`}>
{key} ({val.label})
</button>
))}
</div>
<div className="mt-6 p-4 bg-gray-50 rounded-xl">
<div className="text-sm text-gray-600 mb-2">انتخاب شده:</div>
<div className="text-lg font-bold text-green-700">{selectedPlayerIds.length} / 11</div>
<div className="text-xs text-gray-500 mt-2">
GK: {selectedGk.length}/1 · DEF: {selectedDef.length}/{fmt.def} ·
MID: {selectedMid.length}/{fmt.mid} · FWD: {selectedFwd.length}/{fmt.fwd}
</div>
</div>
{msg && (
<div className={`mt-4 px-3 py-2 rounded-lg text-sm ${
msg.type === "error" ? "bg-red-50 text-red-600" : "bg-green-50 text-green-700"
}`}>
{msg.text}
</div>
)}
<button onClick={handleSave} disabled={loading || selectedPlayerIds.length !== 11}
className="w-full mt-4 bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50">
{loading ? "در حال ذخیره..." : "ذخیره ترکیب پیش‌فرض"}
</button>
</div>
{/* ستون وسط: زمین */}
<div className="bg-gradient-to-b from-green-700 to-green-900 rounded-2xl shadow p-6 relative overflow-hidden min-h-[700px]">
<div className="absolute inset-0 opacity-10 flex items-center justify-center">
<svg width="100%" height="100%" className="absolute inset-0">
{/* خط وسط افقی */}
<line x1="0" y1="50%" x2="100%" y2="50%" stroke="white" strokeWidth="2" vectorEffect="non-scaling-stroke" />
{/* دایره وسط */}
<circle cx="50%" cy="50%" r="70" stroke="white" strokeWidth="2" fill="none" vectorEffect="non-scaling-stroke" />
</svg>
</div>
<div className="relative z-10 flex flex-col gap-4 h-full justify-around py-4">
<PositionRow
title="FWD"
players={fwdPlayers}
selectedIds={selectedFwd}
maxCount={fmt.fwd}
captainId={captainId}
activePlayerId={activeId}
onRemove={removePlayer}
onSetCaptain={setCaptainId}
onSwap={swapPlayers}
/>
<PositionRow
title="MID"
players={midPlayers}
selectedIds={selectedMid}
maxCount={fmt.mid}
captainId={captainId}
activePlayerId={activeId}
onRemove={removePlayer}
onSetCaptain={setCaptainId}
onSwap={swapPlayers}
/>
<PositionRow
title="DEF"
players={defPlayers}
selectedIds={selectedDef}
maxCount={fmt.def}
captainId={captainId}
activePlayerId={activeId}
onRemove={removePlayer}
onSetCaptain={setCaptainId}
onSwap={swapPlayers}
/>
<PositionRow
title="GK"
players={gkPlayers}
selectedIds={selectedGk}
maxCount={1}
captainId={captainId}
activePlayerId={activeId}
onRemove={removePlayer}
onSetCaptain={setCaptainId}
onSwap={swapPlayers}
/>
</div>
</div>
{/* ستون راست: لیست بازیکنان */}
<div className="bg-white rounded-2xl shadow p-6 max-h-[800px] overflow-y-auto h-fit sticky top-6">
<h2 className="text-lg font-bold mb-4">بازیکنان موجود</h2>
{["GK", "DEF", "MID", "FWD"].map((pos) => {
const posList = players.filter((p) => p.position === pos);
const available = posList.filter((p) => !selectedPlayerIds.includes(p.id));
return (
<div key={pos} className="mb-6">
<h3 className="text-sm font-bold text-gray-700 mb-3">{POSITION_LABELS[pos]}</h3>
<div className="flex gap-2 overflow-x-auto pb-2">
{available.map((p) => (
<PlayerCard
key={p.id}
player={p}
onAdd={() => addPlayer(p.id, pos)}
isDragging={activeId === p.id}
/>
))}
{available.length === 0 && (
<div className="text-xs text-gray-400 py-4">همه بازیکنان انتخاب شدهاند</div>
)}
</div>
</div>
);
})}
</div>
</div>
<DragOverlay>
{activePlayer ? (
<div className="bg-white rounded-xl p-2 shadow-2xl opacity-90">
<PlayerCardContent player={activePlayer} />
</div>
) : null}
</DragOverlay>
{/* دیالوگ حذف بازیکنان اضافی */}
{showRemoveDialog && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={handleCancelFormationChange}>
<div className="bg-white rounded-2xl p-6 max-w-md w-full mx-4 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-xl font-bold mb-4 text-gray-800">
تغییر فرمیشن به {pendingFormation}
</h3>
<p className="text-sm text-gray-600 mb-4">
برای تغییر به فرمیشن جدید، باید {playersToRemove.count} بازیکن از پست {POSITION_LABELS[playersToRemove.position]} حذف کنید:
</p>
<div className="space-y-2 mb-6 max-h-64 overflow-y-auto">
{playersToRemove.players.map((p) => {
const isSelected = selectedPlayerIds.includes(p.id);
if (!isSelected) return null;
return (
<div key={p.id} className="flex items-center gap-3 p-3 bg-gray-50 rounded-xl hover:bg-gray-100 transition">
<div className="relative w-12 h-12 rounded-lg overflow-hidden bg-gray-200 flex-shrink-0">
{p.image ? (
<Image
src={`/uploads/players/${p.image}`}
alt={p.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400">
👤
</div>
)}
</div>
<div className="flex-1">
<div className="font-bold text-sm text-gray-800">{p.name}</div>
<div className="text-xs text-gray-500">{POSITION_LABELS[p.position]}</div>
</div>
<button
onClick={() => handleRemovePlayer(p.id)}
className="bg-red-500 text-white px-3 py-1.5 rounded-lg text-xs font-bold hover:bg-red-600 transition"
>
حذف
</button>
</div>
);
})}
</div>
<div className="flex gap-3">
<button
onClick={handleCancelFormationChange}
className="flex-1 bg-gray-200 text-gray-700 py-3 rounded-xl font-bold hover:bg-gray-300 transition"
>
انصراف
</button>
</div>
</div>
</div>
)}
</DndContext>
);
}
// کامپوننت کارت بازیکن در لیست
function PlayerCard({ player, onAdd, isDragging }: {
player: Player;
onAdd: () => void;
isDragging: boolean;
}) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: player.id,
});
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
} : undefined;
return (
<div
ref={setNodeRef}
style={{ width: "80px", ...style }}
{...listeners}
{...attributes}
className={`flex-shrink-0 bg-gray-50 rounded-xl p-2 cursor-move hover:bg-gray-100 transition border-2 ${
isDragging ? "border-green-500 opacity-50" : "border-transparent"
}`}
>
<PlayerCardContent player={player} />
<button
onClick={onAdd}
onPointerDown={(e) => e.stopPropagation()}
className="w-full mt-2 bg-green-600 text-white text-xs py-1 rounded-lg hover:bg-green-700 transition"
>
+ افزودن
</button>
</div>
);
}
// محتوای کارت بازیکن
function PlayerCardContent({ player }: { player: Player }) {
const positionColor = POSITION_COLORS[player.position] || "bg-gray-100 text-gray-800";
return (
<>
<div className="relative w-16 h-16 rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto">
{player.image ? (
<Image
src={`/uploads/players/${player.image}`}
alt={player.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">
👤
</div>
)}
</div>
<div className="text-[10px] font-bold text-gray-800 text-center leading-tight">
{player.name.split(" ").slice(-1)[0]}
</div>
<div className={`text-[8px] text-center font-bold mt-1 rounded px-1 py-0.5 ${positionColor}`}>
{player.position}
</div>
</>
);
}
// ردیف بازیکنان در زمین
function PositionRow({ title, players, selectedIds, maxCount, captainId, activePlayerId, onRemove, onSetCaptain, onSwap }: {
title: string;
players: Player[];
selectedIds: string[];
maxCount: number;
captainId: string | null;
activePlayerId: string | null;
onRemove: (id: string) => void;
onSetCaptain: (id: string | null) => void;
onSwap: (id: string, direction: "left" | "right") => void;
}) {
// ترتیب بازیکنان رو حفظ می‌کنیم
const selected = selectedIds
.map(id => players.find(p => p.id === id))
.filter((p): p is Player => p !== undefined && p.position === title);
const activePlayer = activePlayerId ? players.find((p) => p.id === activePlayerId) : null;
const canAcceptDrop = activePlayer?.position === title;
const positionColor = POSITION_COLORS[title] || "bg-gray-100 text-gray-800";
return (
<div className="text-center">
<div className="text-white text-xs font-bold mb-2 opacity-70">{title} ({selected.length}/{maxCount})</div>
<div className="flex justify-center gap-3">
{selected.map((p, index) => (
<FieldPlayerCard
key={p.id}
player={p}
title={title}
positionColor={positionColor}
captainId={captainId}
activePlayerId={activePlayerId}
canAcceptDrop={canAcceptDrop}
onRemove={onRemove}
onSetCaptain={onSetCaptain}
onSwap={onSwap}
canMoveLeft={index > 0}
canMoveRight={index < selected.length - 1}
/>
))}
{/* جاهای خالی */}
{Array.from({ length: maxCount - selected.length }).map((_, i) => (
<EmptySlot
key={`empty-${title}-${i}`}
id={`add-zone-${title}`}
canAcceptDrop={canAcceptDrop}
activePlayerId={activePlayerId}
/>
))}
</div>
</div>
);
}
// کامپوننت بازیکن در زمین
function FieldPlayerCard({ player, title, positionColor, captainId, activePlayerId, canAcceptDrop, onRemove, onSetCaptain, onSwap, canMoveLeft, canMoveRight }: {
player: Player;
title: string;
positionColor: string;
captainId: string | null;
activePlayerId: string | null;
canAcceptDrop: boolean;
onRemove: (id: string) => void;
onSetCaptain: (id: string | null) => void;
onSwap: (id: string, direction: "left" | "right") => void;
canMoveLeft: boolean;
canMoveRight: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: player.id,
});
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: player.id,
});
const style = transform ? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
zIndex: 50,
} : undefined;
return (
<div
ref={setDropRef}
className={`relative group ${canAcceptDrop && isOver ? "ring-2 ring-yellow-300 rounded-xl" : ""}`}
>
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={`bg-white/95 rounded-xl p-2 cursor-move hover:bg-white transition shadow-lg ${
isDragging ? "opacity-50" : ""
}`}
>
{/* دکمه‌های چپ و راست */}
<div className="absolute -top-2 left-0 right-0 flex justify-between px-1 opacity-0 group-hover:opacity-100 transition z-10">
{canMoveLeft && (
<button
onClick={(e) => {
e.stopPropagation();
onSwap(player.id, "left");
}}
onPointerDown={(e) => e.stopPropagation()}
className="bg-blue-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs font-bold shadow hover:bg-blue-600"
>
</button>
)}
<div className="flex-1"></div>
{canMoveRight && (
<button
onClick={(e) => {
e.stopPropagation();
onSwap(player.id, "right");
}}
onPointerDown={(e) => e.stopPropagation()}
className="bg-blue-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs font-bold shadow hover:bg-blue-600"
>
</button>
)}
</div>
<div className="relative w-12 h-12 rounded-lg overflow-hidden bg-gray-200 mb-1">
{player.image ? (
<Image
src={`/uploads/players/${player.image}`}
alt={player.name}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
👤
</div>
)}
</div>
<div className="text-[10px] font-bold text-gray-800 text-center leading-tight">
{player.name.split(" ").slice(-1)[0]}
</div>
<div className={`text-[8px] text-center font-bold mt-1 rounded px-1 py-0.5 ${positionColor}`}>
{title}
</div>
{captainId === player.id && (
<div className="absolute -top-1 -right-1 bg-yellow-400 text-yellow-900 rounded-full w-5 h-5 flex items-center justify-center text-xs font-bold shadow">
C
</div>
)}
</div>
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition flex gap-1 z-20">
<button
onClick={(e) => {
e.stopPropagation();
onSetCaptain(captainId === player.id ? null : player.id);
}}
className="bg-yellow-400 text-yellow-900 text-[8px] px-2 py-0.5 rounded-full font-bold whitespace-nowrap shadow">
{captainId === player.id ? "❌" : "C"}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onRemove(player.id);
}}
className="bg-red-500 text-white text-[8px] px-2 py-0.5 rounded-full font-bold shadow">
حذف
</button>
</div>
</div>
);
}
// کامپوننت جای خالی
function EmptySlot({ id, canAcceptDrop, activePlayerId }: {
id: string;
canAcceptDrop: boolean;
activePlayerId: string | null;
}) {
const { setNodeRef, isOver } = useDroppable({
id: id,
});
return (
<div
ref={setNodeRef}
className={`border-2 border-dashed rounded-xl w-16 h-20 flex items-center justify-center transition ${
canAcceptDrop && activePlayerId
? isOver
? "border-yellow-300 bg-yellow-400/30 scale-105"
: "border-yellow-300 bg-yellow-400/20 animate-pulse"
: "border-white/30"
}`}
>
<span className="text-xs text-white/30">+</span>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import DefaultLineupEditor from "./DefaultLineupEditor";
export default async function CountryDefaultLineupPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [country, players] = await Promise.all([
db.country.findUnique({ where: { id } }),
db.player.findMany({
where: { countryId: id, isActive: true },
orderBy: [{ position: "asc" }, { name: "asc" }],
}),
]);
if (!country) notFound();
return (
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Link href="/admin/countries" className="text-gray-400 hover:text-gray-600"> تیمها</Link>
<h1 className="text-2xl font-bold">ترکیب پیشفرض {country.name} {country.flagUrl}</h1>
</div>
<DefaultLineupEditor country={country} players={players} />
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import Link from "next/link"; import Link from "next/link";
import CountryFlag from "@/components/CountryFlag";
export default async function AdminCountriesPage() { export default async function AdminCountriesPage() {
const countries = await db.country.findMany({ const countries = await db.country.findMany({
@@ -18,23 +19,39 @@ export default async function AdminCountriesPage() {
+ تیم جدید + تیم جدید
</Link> </Link>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4">
{countries.map((c) => ( {countries.map((c) => (
<div key={c.id} className="bg-white rounded-2xl shadow p-5 flex items-center justify-between"> <div key={c.id} className="bg-white rounded-2xl shadow p-5 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center text-2xl"> <div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
{c.flagUrl ?? "🏳️"} <CountryFlag
flagImage={c.flagImage}
flagEmoji={c.flagUrl}
countryName={c.name}
size="md"
/>
</div> </div>
<div> <div>
<div className="font-bold">{c.name}</div> <div className="font-bold">{c.name}</div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
{c.code} · گروه {c.group?.name ?? "-"} · {c._count.players} بازیکن {c.code} ·
{c.confederation && ` ${c.confederation} · `}
گروه {c.group?.name ?? "-"} · {c._count.players} بازیکن ·
ترکیب: {c.defaultFormation} ·
{(c.defaultLineupPlayerIds?.length ?? 0) > 0 ? `${c.defaultLineupPlayerIds.length} بازیکن` : "بدون ترکیب"}
</div> </div>
</div> </div>
</div> </div>
<Link href={`/admin/countries/${c.id}/edit`} className="text-blue-600 hover:underline text-sm"> <div className="flex gap-2">
ویرایش <Link href={`/admin/countries/${c.id}/lineup`}
</Link> className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition font-medium">
ترکیب پیشفرض
</Link>
<Link href={`/admin/countries/${c.id}/edit`}
className="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm hover:bg-gray-300 transition font-medium">
ویرایش
</Link>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -2,9 +2,10 @@ import { db } from "@/lib/db";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import MatchForm from "../../MatchForm"; import MatchForm from "../../MatchForm";
export default async function EditMatchPage({ params }: { params: { id: string } }) { export default async function EditMatchPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [match, countries, rounds] = await Promise.all([ const [match, countries, rounds] = await Promise.all([
db.match.findUnique({ where: { id: params.id } }), db.match.findUnique({ where: { id } }),
db.country.findMany({ orderBy: { name: "asc" } }), db.country.findMany({ orderBy: { name: "asc" } }),
db.round.findMany({ orderBy: { number: "asc" } }), db.round.findMany({ orderBy: { number: "asc" } }),
]); ]);

View File

@@ -2,6 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image";
type Country = { id: string; name: string }; type Country = { id: string; name: string };
@@ -11,7 +12,7 @@ export default function PlayerForm({
playerId, playerId,
}: { }: {
countries: Country[]; countries: Country[];
initial?: { name: string; position: string; countryId: string; price: number }; initial?: { name: string; position: string; countryId: string; price: number; image?: string | null };
playerId?: string; playerId?: string;
}) { }) {
const router = useRouter(); const router = useRouter();
@@ -20,10 +21,42 @@ export default function PlayerForm({
position: initial?.position ?? "FWD", position: initial?.position ?? "FWD",
countryId: initial?.countryId ?? "", countryId: initial?.countryId ?? "",
price: initial?.price ?? 5.0, price: initial?.price ?? 5.0,
image: initial?.image ?? "",
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setError("");
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch("/api/upload/player-image", {
method: "POST",
body: formData,
});
if (res.ok) {
const data = await res.json();
setForm({ ...form, image: data.fileName });
} else {
const data = await res.json();
setError(data.error ?? "خطا در آپلود تصویر");
}
} catch (err) {
setError("خطا در آپلود تصویر");
} finally {
setUploading(false);
}
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -45,6 +78,33 @@ export default function PlayerForm({
return ( return (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4"> <form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
{error && <p className="text-red-500 text-sm">{error}</p>} {error && <p className="text-red-500 text-sm">{error}</p>}
<div>
<label className="block text-sm font-medium mb-1">تصویر بازیکن</label>
<div className="flex items-center gap-4">
{form.image && (
<div className="relative w-24 h-24 rounded-xl overflow-hidden border">
<Image
src={`/uploads/players/${form.image}`}
alt={form.name}
fill
className="object-cover"
/>
</div>
)}
<div className="flex-1">
<input
type="file"
accept="image/*"
onChange={handleImageUpload}
disabled={uploading}
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
/>
{uploading && <p className="text-sm text-gray-500 mt-1">در حال آپلود...</p>}
</div>
</div>
</div>
<div> <div>
<label className="block text-sm font-medium mb-1">نام بازیکن</label> <label className="block text-sm font-medium mb-1">نام بازیکن</label>
<input <input

View File

@@ -3,9 +3,10 @@ import { notFound } from "next/navigation";
import PlayerForm from "../../PlayerForm"; import PlayerForm from "../../PlayerForm";
import DeleteButton from "./DeleteButton"; import DeleteButton from "./DeleteButton";
export default async function EditPlayerPage({ params }: { params: { id: string } }) { export default async function EditPlayerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [player, countries] = await Promise.all([ const [player, countries] = await Promise.all([
db.player.findUnique({ where: { id: params.id } }), db.player.findUnique({ where: { id } }),
db.country.findMany({ orderBy: { name: "asc" } }), db.country.findMany({ orderBy: { name: "asc" } }),
]); ]);
@@ -25,6 +26,7 @@ export default async function EditPlayerPage({ params }: { params: { id: string
position: player.position, position: player.position,
countryId: player.countryId, countryId: player.countryId,
price: player.price, price: player.price,
image: player.image,
}} }}
/> />
</div> </div>

View File

@@ -7,19 +7,21 @@ export default function ActivateRoundButton({ roundId, isActive }: { roundId: st
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
async function activate() { async function toggleActivation() {
setLoading(true); setLoading(true);
await fetch(`/api/rounds/${roundId}/activate`, { method: "POST" }); await fetch(`/api/rounds/${roundId}/activate`, { method: "POST" });
router.refresh(); router.refresh();
setLoading(false); setLoading(false);
} }
if (isActive) return <span className="text-xs text-green-600 font-medium px-3 py-1.5"> فعال</span>;
return ( return (
<button onClick={activate} disabled={loading} <button onClick={toggleActivation} disabled={loading}
className="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition disabled:opacity-50"> className={`px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50 ${
{loading ? "..." : "فعال‌سازی"} isActive
? "bg-gray-200 text-gray-600 hover:bg-gray-300"
: "bg-blue-600 text-white hover:bg-blue-700"
}`}>
{loading ? "..." : isActive ? "غیرفعال" : "فعال‌سازی"}
</button> </button>
); );
} }

View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function DeleteRoundButton({ roundId, hasMatches }: { roundId: string; hasMatches: boolean }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
async function handleDelete() {
setLoading(true);
const res = await fetch("/api/rounds", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: roundId }),
});
if (res.ok) {
router.refresh();
} else {
const data = await res.json();
alert(data.error || "خطا در حذف");
}
setLoading(false);
setShowConfirm(false);
}
if (hasMatches) {
return (
<button disabled
className="bg-gray-300 text-gray-500 px-3 py-1.5 rounded-lg text-sm cursor-not-allowed"
title="دور دارای بازی است">
حذف
</button>
);
}
return (
<>
<button onClick={() => setShowConfirm(true)}
className="bg-red-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-red-700 transition">
حذف
</button>
{showConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowConfirm(false)}>
<div className="bg-white rounded-2xl p-6 max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold mb-3">حذف دور</h3>
<p className="text-gray-600 mb-6">آیا مطمئن هستید که میخواهید این دور را حذف کنید؟</p>
<div className="flex gap-3">
<button onClick={handleDelete} disabled={loading}
className="flex-1 bg-red-600 text-white py-2 rounded-xl font-bold hover:bg-red-700 transition disabled:opacity-50">
{loading ? "در حال حذف..." : "بله، حذف شود"}
</button>
<button onClick={() => setShowConfirm(false)}
className="flex-1 bg-gray-200 text-gray-700 py-2 rounded-xl font-bold hover:bg-gray-300 transition">
انصراف
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -3,9 +3,20 @@
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function RoundForm() { type Round = {
id: string;
number: number;
name: string;
deadline: Date;
};
export default function RoundForm({ editRound }: { editRound?: Round }) {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState({ number: "", name: "", deadline: "" }); const [form, setForm] = useState({
number: editRound?.number.toString() ?? "",
name: editRound?.name ?? "",
deadline: editRound ? new Date(editRound.deadline).toISOString().slice(0, 16) : "",
});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -13,14 +24,22 @@ export default function RoundForm() {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(""); setError("");
const method = editRound ? "PUT" : "POST";
const body = editRound
? { id: editRound.id, ...form, number: parseInt(form.number) }
: { ...form, number: parseInt(form.number) };
const res = await fetch("/api/rounds", { const res = await fetch("/api/rounds", {
method: "POST", method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...form, number: parseInt(form.number) }), body: JSON.stringify(body),
}); });
if (res.ok) { if (res.ok) {
setForm({ number: "", name: "", deadline: "" }); setForm({ number: "", name: "", deadline: "" });
router.refresh(); router.refresh();
router.push("/admin/rounds");
} else { } else {
const d = await res.json(); const d = await res.json();
setError(d.error ?? "خطا در ذخیره"); setError(d.error ?? "خطا در ذخیره");
@@ -55,7 +74,7 @@ export default function RoundForm() {
</div> </div>
<button type="submit" disabled={loading} <button type="submit" disabled={loading}
className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50"> className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50">
{loading ? "در حال ذخیره..." : "افزودن دور"} {loading ? "در حال ذخیره..." : editRound ? "ویرایش دور" : "افزودن دور"}
</button> </button>
</form> </form>
); );

View File

@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function DeleteMatchButton({ matchId, hasEvents }: { matchId: string; hasEvents: boolean }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
async function handleDelete() {
setLoading(true);
const res = await fetch(`/api/matches/${matchId}`, {
method: "DELETE",
});
if (res.ok) {
router.refresh();
} else {
const data = await res.json();
alert(data.error || "خطا در حذف");
}
setLoading(false);
setShowConfirm(false);
}
return (
<>
<button onClick={() => setShowConfirm(true)}
className="bg-red-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-red-700 transition">
حذف
</button>
{showConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowConfirm(false)}>
<div className="bg-white rounded-2xl p-6 max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold mb-3">حذف بازی</h3>
<p className="text-gray-600 mb-2">آیا مطمئن هستید که میخواهید این بازی را حذف کنید؟</p>
{hasEvents && (
<p className="text-red-600 text-sm mb-4"> این بازی دارای {hasEvents} رویداد است که همگی حذف خواهند شد.</p>
)}
<div className="flex gap-3">
<button onClick={handleDelete} disabled={loading}
className="flex-1 bg-red-600 text-white py-2 rounded-xl font-bold hover:bg-red-700 transition disabled:opacity-50">
{loading ? "در حال حذف..." : "بله، حذف شود"}
</button>
<button onClick={() => setShowConfirm(false)}
className="flex-1 bg-gray-200 text-gray-700 py-2 rounded-xl font-bold hover:bg-gray-300 transition">
انصراف
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,23 @@
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import Link from "next/link";
import RoundForm from "../../RoundForm";
export default async function EditRoundPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const round = await db.round.findUnique({
where: { id },
});
if (!round) notFound();
return (
<div className="max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Link href="/admin/rounds" className="text-gray-400 hover:text-gray-600"> بازگشت</Link>
<h1 className="text-2xl font-bold">ویرایش دور {round.number}</h1>
</div>
<RoundForm editRound={round} />
</div>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
type Player = {
id: string;
name: string;
position: string;
};
type Country = {
id: string;
name: string;
code: string;
flagUrl: string | null;
defaultFormation: string;
defaultLineupPlayerIds: string[];
players: Player[];
};
type Lineup = {
id: string;
countryId: string;
formation: string;
playerIds: string[];
};
type Match = {
id: string;
homeTeam: Country;
awayTeam: Country;
lineups: Lineup[];
};
const FORMATIONS: Record<string, { def: number; mid: number; fwd: number }> = {
"4-3-3": { def: 4, mid: 3, fwd: 3 },
"4-4-2": { def: 4, mid: 4, fwd: 2 },
"4-5-1": { def: 4, mid: 5, fwd: 1 },
"3-5-2": { def: 3, mid: 5, fwd: 2 },
"3-4-3": { def: 3, mid: 4, fwd: 3 },
"5-3-2": { def: 5, mid: 3, fwd: 2 },
"5-4-1": { def: 5, mid: 4, fwd: 1 },
};
export default function MatchLineupManager({ match }: { match: Match }) {
const router = useRouter();
const homeLineup = match.lineups.find((l) => l.countryId === match.homeTeam.id);
const awayLineup = match.lineups.find((l) => l.countryId === match.awayTeam.id);
const [homeFormation, setHomeFormation] = useState(homeLineup?.formation ?? match.homeTeam.defaultFormation);
const [homePlayerIds, setHomePlayerIds] = useState<string[]>(homeLineup?.playerIds ?? match.homeTeam.defaultLineupPlayerIds);
const [awayFormation, setAwayFormation] = useState(awayLineup?.formation ?? match.awayTeam.defaultFormation);
const [awayPlayerIds, setAwayPlayerIds] = useState<string[]>(awayLineup?.playerIds ?? match.awayTeam.defaultLineupPlayerIds);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<{ text: string; type: "error" | "success" } | null>(null);
function loadDefaultLineup(team: "home" | "away") {
if (team === "home") {
setHomeFormation(match.homeTeam.defaultFormation);
setHomePlayerIds(match.homeTeam.defaultLineupPlayerIds);
setMsg({ text: "ترکیب پیش‌فرض میزبان بارگذاری شد", type: "success" });
} else {
setAwayFormation(match.awayTeam.defaultFormation);
setAwayPlayerIds(match.awayTeam.defaultLineupPlayerIds);
setMsg({ text: "ترکیب پیش‌فرض میهمان بارگذاری شد", type: "success" });
}
setTimeout(() => setMsg(null), 3000);
}
async function handleSave() {
// چک کنیم هر دو تیم 11 نفر داشته باشن
if (homePlayerIds.length !== 11 || awayPlayerIds.length !== 11) {
setMsg({ text: "هر تیم باید 11 بازیکن داشته باشد", type: "error" });
return;
}
setLoading(true);
const res = await fetch(`/api/admin/matches/${match.id}/lineup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([
{ countryId: match.homeTeam.id, formation: homeFormation, playerIds: homePlayerIds },
{ countryId: match.awayTeam.id, formation: awayFormation, playerIds: awayPlayerIds },
]),
});
if (res.ok) {
setMsg({ text: "ترکیب‌ها ذخیره شد", type: "success" });
router.refresh();
} else {
const data = await res.json();
setMsg({ text: data.error || "خطا در ذخیره", type: "error" });
}
setLoading(false);
}
return (
<div className="bg-white rounded-2xl shadow p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold">ترکیب تیمها</h2>
<button onClick={handleSave} disabled={loading}
className="bg-green-700 text-white px-6 py-2 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50">
{loading ? "در حال ذخیره..." : "ذخیره ترکیب‌ها"}
</button>
</div>
{msg && (
<div className={`mb-4 px-4 py-3 rounded-xl text-sm ${
msg.type === "error" ? "bg-red-50 text-red-600" : "bg-green-50 text-green-700"
}`}>
{msg.text}
</div>
)}
<div className="grid grid-cols-2 gap-6">
{/* تیم میزبان */}
<TeamLineupEditor
team={match.homeTeam}
formation={homeFormation}
selectedPlayerIds={homePlayerIds}
onFormationChange={setHomeFormation}
onPlayersChange={setHomePlayerIds}
onLoadDefault={() => loadDefaultLineup("home")}
/>
{/* تیم میهمان */}
<TeamLineupEditor
team={match.awayTeam}
formation={awayFormation}
selectedPlayerIds={awayPlayerIds}
onFormationChange={setAwayFormation}
onPlayersChange={setAwayPlayerIds}
onLoadDefault={() => loadDefaultLineup("away")}
/>
</div>
</div>
);
}
function TeamLineupEditor({ team, formation, selectedPlayerIds, onFormationChange, onPlayersChange, onLoadDefault }: {
team: Country;
formation: string;
selectedPlayerIds: string[];
onFormationChange: (f: string) => void;
onPlayersChange: (ids: string[]) => void;
onLoadDefault: () => void;
}) {
const fmt = FORMATIONS[formation] ?? FORMATIONS["4-3-3"];
const gkPlayers = team.players.filter((p) => p.position === "GK");
const defPlayers = team.players.filter((p) => p.position === "DEF");
const midPlayers = team.players.filter((p) => p.position === "MID");
const fwdPlayers = team.players.filter((p) => p.position === "FWD");
const selectedGk = selectedPlayerIds.filter((id) => gkPlayers.find((p) => p.id === id));
const selectedDef = selectedPlayerIds.filter((id) => defPlayers.find((p) => p.id === id));
const selectedMid = selectedPlayerIds.filter((id) => midPlayers.find((p) => p.id === id));
const selectedFwd = selectedPlayerIds.filter((id) => fwdPlayers.find((p) => p.id === id));
function togglePlayer(playerId: string, position: string) {
if (selectedPlayerIds.includes(playerId)) {
onPlayersChange(selectedPlayerIds.filter((id) => id !== playerId));
} else {
const posPlayers = selectedPlayerIds.filter((id) => {
const p = team.players.find((pl) => pl.id === id);
return p?.position === position;
});
let maxCount = 1;
if (position === "DEF") maxCount = fmt.def;
else if (position === "MID") maxCount = fmt.mid;
else if (position === "FWD") maxCount = fmt.fwd;
if (posPlayers.length >= maxCount) return;
onPlayersChange([...selectedPlayerIds, playerId]);
}
}
return (
<div className="border-2 border-gray-200 rounded-xl p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">{team.flagUrl}</span>
<h3 className="font-bold">{team.name}</h3>
</div>
<button onClick={onLoadDefault}
className="text-xs bg-blue-100 text-blue-700 px-3 py-1 rounded-lg hover:bg-blue-200 transition">
بارگذاری پیشفرض
</button>
</div>
{/* انتخاب فرمیشن */}
<div className="mb-4">
<label className="text-xs text-gray-500 mb-1 block">فرمیشن</label>
<select value={formation} onChange={(e) => onFormationChange(e.target.value)}
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
{Object.keys(FORMATIONS).map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
{/* نمایش تعداد */}
<div className="text-xs text-gray-600 mb-3 bg-gray-50 p-2 rounded-lg">
انتخاب شده: {selectedPlayerIds.length}/11 ·
GK: {selectedGk.length}/1 · DEF: {selectedDef.length}/{fmt.def} ·
MID: {selectedMid.length}/{fmt.mid} · FWD: {selectedFwd.length}/{fmt.fwd}
</div>
{/* لیست بازیکنان */}
<div className="space-y-3 max-h-96 overflow-y-auto">
{["GK", "DEF", "MID", "FWD"].map((pos) => {
const posList = team.players.filter((p) => p.position === pos);
return (
<div key={pos}>
<div className="text-xs font-bold text-gray-500 mb-1">{pos}</div>
<div className="flex flex-col gap-1">
{posList.map((p) => (
<button key={p.id} onClick={() => togglePlayer(p.id, pos)}
className={`text-right px-2 py-1.5 rounded text-xs transition ${
selectedPlayerIds.includes(p.id)
? "bg-green-100 text-green-700 font-bold border border-green-500"
: "bg-gray-50 hover:bg-gray-100"
}`}>
{p.name}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -2,13 +2,15 @@ import { db } from "@/lib/db";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import MatchEventManager from "./MatchEventManager"; import MatchEventManager from "./MatchEventManager";
import MatchLineupManager from "./MatchLineupManager";
export default async function MatchDetailPage({ params }: { params: { id: string; matchId: string } }) { export default async function MatchDetailPage({ params }: { params: Promise<{ id: string; matchId: string }> }) {
const { id, matchId } = await params;
const match = await db.match.findUnique({ const match = await db.match.findUnique({
where: { id: params.matchId }, where: { id: matchId },
include: { include: {
homeTeam: { include: { players: { orderBy: { position: "asc" } } } }, homeTeam: { include: { players: { where: { isActive: true }, orderBy: { position: "asc" } } } },
awayTeam: { include: { players: { orderBy: { position: "asc" } } } }, awayTeam: { include: { players: { where: { isActive: true }, orderBy: { position: "asc" } } } },
events: { include: { player: true }, orderBy: { minute: "asc" } }, events: { include: { player: true }, orderBy: { minute: "asc" } },
lineups: true, lineups: true,
playerStats: { include: { player: true } }, playerStats: { include: { player: true } },
@@ -18,14 +20,19 @@ export default async function MatchDetailPage({ params }: { params: { id: string
if (!match) notFound(); if (!match) notFound();
return ( return (
<div> <div className="space-y-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3">
<Link href={`/admin/rounds/${params.id}`} className="text-gray-400 hover:text-gray-600"> دور</Link> <Link href={`/admin/rounds/${id}`} className="text-gray-400 hover:text-gray-600"> دور</Link>
<h1 className="text-xl font-bold"> <h1 className="text-xl font-bold">
{match.homeTeam.name} {match.homeScore ?? "-"} - {match.awayScore ?? "-"} {match.awayTeam.name} {match.homeTeam.name} {match.homeScore ?? "-"} - {match.awayScore ?? "-"} {match.awayTeam.name}
</h1> </h1>
</div> </div>
<MatchEventManager match={match} roundId={params.id} />
{/* مدیریت ترکیب */}
<MatchLineupManager match={match} />
{/* مدیریت رویدادها */}
<MatchEventManager match={match} roundId={id} />
</div> </div>
); );
} }

View File

@@ -1,6 +1,7 @@
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import DeleteMatchButton from "./DeleteMatchButton";
const statusStyle: Record<string, string> = { const statusStyle: Record<string, string> = {
SCHEDULED: "bg-gray-100 text-gray-600", SCHEDULED: "bg-gray-100 text-gray-600",
@@ -9,9 +10,10 @@ const statusStyle: Record<string, string> = {
}; };
const statusLabel: Record<string, string> = { SCHEDULED: "برنامه", LIVE: "🔴 زنده", FINISHED: "پایان" }; const statusLabel: Record<string, string> = { SCHEDULED: "برنامه", LIVE: "🔴 زنده", FINISHED: "پایان" };
export default async function RoundDetailPage({ params }: { params: { id: string } }) { export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const round = await db.round.findUnique({ const round = await db.round.findUnique({
where: { id: params.id }, where: { id },
include: { include: {
matches: { matches: {
include: { include: {
@@ -27,10 +29,16 @@ export default async function RoundDetailPage({ params }: { params: { id: string
return ( return (
<div> <div>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center justify-between mb-6">
<Link href="/admin/rounds" className="text-gray-400 hover:text-gray-600"> دورها</Link> <div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{round.name}</h1> <Link href="/admin/rounds" className="text-gray-400 hover:text-gray-600"> دورها</Link>
{round.isActive && <span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">فعال</span>} <h1 className="text-2xl font-bold">{round.name}</h1>
{round.isActive && <span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">فعال</span>}
</div>
<Link href="/admin/matches/new"
className="bg-green-700 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-green-800 transition">
+ افزودن بازی
</Link>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -39,7 +47,7 @@ export default async function RoundDetailPage({ params }: { params: { id: string
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1 justify-end"> <div className="flex items-center gap-4 flex-1 justify-end">
<span className="font-bold">{m.homeTeam.name}</span> <span className="font-bold">{m.homeTeam.name}</span>
<span>{m.homeTeam.flagUrl}</span> <span className="text-2xl">{m.homeTeam.flagUrl}</span>
</div> </div>
<div className="mx-6 text-center min-w-[120px]"> <div className="mx-6 text-center min-w-[120px]">
{m.status !== "SCHEDULED" ? ( {m.status !== "SCHEDULED" ? (
@@ -52,22 +60,36 @@ export default async function RoundDetailPage({ params }: { params: { id: string
</span> </span>
</div> </div>
<div className="flex items-center gap-4 flex-1 justify-start"> <div className="flex items-center gap-4 flex-1 justify-start">
<span>{m.awayTeam.flagUrl}</span> <span className="text-2xl">{m.awayTeam.flagUrl}</span>
<span className="font-bold">{m.awayTeam.name}</span> <span className="font-bold">{m.awayTeam.name}</span>
</div> </div>
<div className="flex gap-2 mr-4"> <div className="flex gap-2 mr-4 items-center">
<div className="text-xs text-gray-400 text-center"> <div className="text-xs text-gray-400 text-center mr-2">
<div>{m._count.events} رویداد</div> <div>{m._count.events} رویداد</div>
<div>{m._count.lineups > 0 ? "✓ ترکیب" : "بدون ترکیب"}</div> <div>{m._count.lineups > 0 ? "✓ ترکیب" : "بدون ترکیب"}</div>
</div> </div>
<Link href={`/admin/rounds/${round.id}/match/${m.id}`} <Link href={`/admin/rounds/${id}/match/${m.id}`}
className="bg-green-700 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-green-800 transition"> className="bg-green-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-green-800 transition">
جزئیات جزئیات
</Link> </Link>
<Link href={`/admin/matches/${m.id}/edit`}
className="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-blue-700 transition">
ویرایش
</Link>
<DeleteMatchButton matchId={m.id} hasEvents={m._count.events > 0} />
</div> </div>
</div> </div>
</div> </div>
))} ))}
{round.matches.length === 0 && (
<div className="text-center py-12 text-gray-400">
<p className="mb-4">هنوز بازیای برای این دور ثبت نشده</p>
<Link href="/admin/matches/new"
className="inline-block bg-green-700 text-white px-6 py-3 rounded-xl font-bold hover:bg-green-800 transition">
افزودن اولین بازی
</Link>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -2,6 +2,7 @@ import { db } from "@/lib/db";
import Link from "next/link"; import Link from "next/link";
import RoundForm from "./RoundForm"; import RoundForm from "./RoundForm";
import ActivateRoundButton from "./ActivateRoundButton"; import ActivateRoundButton from "./ActivateRoundButton";
import DeleteRoundButton from "./DeleteRoundButton";
export default async function AdminRoundsPage() { export default async function AdminRoundsPage() {
const rounds = await db.round.findMany({ const rounds = await db.round.findMany({
@@ -17,7 +18,7 @@ export default async function AdminRoundsPage() {
{rounds.map((r) => ( {rounds.map((r) => (
<div key={r.id} className={`bg-white rounded-2xl shadow p-5 border-2 ${r.isActive ? "border-green-500" : "border-transparent"}`}> <div key={r.id} className={`bg-white rounded-2xl shadow p-5 border-2 ${r.isActive ? "border-green-500" : "border-transparent"}`}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="flex-1">
<div className="font-bold flex items-center gap-2"> <div className="font-bold flex items-center gap-2">
دور {r.number} - {r.name} دور {r.number} - {r.name}
{r.isActive && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">فعال</span>} {r.isActive && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">فعال</span>}
@@ -32,6 +33,11 @@ export default async function AdminRoundsPage() {
className="bg-green-700 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-green-800 transition"> className="bg-green-700 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-green-800 transition">
بازیها بازیها
</Link> </Link>
<Link href={`/admin/rounds/${r.id}/edit`}
className="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition">
ویرایش
</Link>
<DeleteRoundButton roundId={r.id} hasMatches={r._count.matches > 0} />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,139 @@
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
import CountryFlag from "@/components/CountryFlag";
export default async function CountryProfilePage({ params }: { params: Promise<{ code: string }> }) {
const { code } = await params;
const country = await db.country.findUnique({
where: { code: code.toUpperCase() },
include: {
group: true,
players: {
where: { isActive: true },
orderBy: [{ position: "asc" }, { totalPoints: "desc" }],
},
_count: {
select: {
homeMatches: true,
awayMatches: true,
},
},
},
});
if (!country) notFound();
const totalMatches = country._count.homeMatches + country._count.awayMatches;
return (
<div className="max-w-5xl mx-auto py-8 px-4">
{/* هدر */}
<div className="bg-gradient-to-br from-green-700 to-green-900 rounded-3xl shadow-2xl p-8 mb-8 text-white">
<div className="flex items-center gap-6">
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-4">
<CountryFlag
flagImage={country.flagImage}
flagEmoji={country.flagUrl}
countryName={country.name}
size="xl"
/>
</div>
<div className="flex-1">
<h1 className="text-4xl font-bold mb-2">{country.name}</h1>
<div className="flex items-center gap-4 text-white/80">
<span>{country.code}</span>
{country.confederation && <span>· {country.confederation}</span>}
{country.group && <span>· گروه {country.group.name}</span>}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
{/* آمار */}
<div className="bg-white rounded-2xl shadow p-6 text-center">
<div className="text-3xl font-bold text-green-700">{country.players.length}</div>
<div className="text-sm text-gray-500 mt-1">بازیکن</div>
</div>
<div className="bg-white rounded-2xl shadow p-6 text-center">
<div className="text-3xl font-bold text-blue-700">{totalMatches}</div>
<div className="text-sm text-gray-500 mt-1">بازی</div>
</div>
<div className="bg-white rounded-2xl shadow p-6 text-center">
<div className="text-3xl font-bold text-purple-700">{country.defaultFormation}</div>
<div className="text-sm text-gray-500 mt-1">ترکیب پیشفرض</div>
</div>
</div>
{/* اطلاعات راه‌یابی */}
{(country.qualificationMethod || country.qualificationDate || country.participationHistory || country.bestResult) && (
<div className="bg-white rounded-2xl shadow p-6 mb-8">
<h2 className="text-xl font-bold mb-4">اطلاعات راهیابی</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{country.qualificationMethod && (
<div>
<div className="text-sm text-gray-500">شیوه راهیابی</div>
<div className="font-medium mt-1">{country.qualificationMethod}</div>
</div>
)}
{country.qualificationDate && (
<div>
<div className="text-sm text-gray-500">تاریخ راهیابی</div>
<div className="font-medium mt-1">{country.qualificationDate}</div>
</div>
)}
{country.participationHistory && (
<div>
<div className="text-sm text-gray-500">سابقه شرکت</div>
<div className="font-medium mt-1">{country.participationHistory}</div>
</div>
)}
{country.bestResult && (
<div>
<div className="text-sm text-gray-500">بهترین نتیجه</div>
<div className="font-medium mt-1">{country.bestResult}</div>
</div>
)}
</div>
</div>
)}
{/* توضیحات */}
{country.description && (
<div className="bg-white rounded-2xl shadow p-6 mb-8">
<h2 className="text-xl font-bold mb-4">درباره تیم</h2>
<p className="text-gray-700 leading-relaxed whitespace-pre-line">{country.description}</p>
</div>
)}
{/* لیست بازیکنان */}
<div className="bg-white rounded-2xl shadow p-6">
<h2 className="text-xl font-bold mb-4">بازیکنان</h2>
<div className="grid grid-cols-1 gap-3">
{["GK", "DEF", "MID", "FWD"].map((pos) => {
const players = country.players.filter((p) => p.position === pos);
if (players.length === 0) return null;
return (
<div key={pos}>
<div className="text-sm font-bold text-gray-500 mb-2">{pos}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{players.map((p) => (
<div key={p.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-4 py-3">
<span className="font-medium">{p.name}</span>
<div className="flex items-center gap-3 text-sm">
<span className="text-green-700 font-bold">{p.price}M</span>
<span className="text-blue-700 font-bold">{p.totalPoints} pts</span>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -2,10 +2,12 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import PositionBadge from "@/components/PositionBadge"; import PositionBadge from "@/components/PositionBadge";
import Image from "next/image";
type Player = { type Player = {
id: string; id: string;
name: string; name: string;
image: string | null;
position: string; position: string;
price: number; price: number;
totalPoints: number; totalPoints: number;
@@ -346,42 +348,56 @@ export default function TeamBuilder({
onChange={(e) => setFilter(e.target.value)} onChange={(e) => setFilter(e.target.value)}
className="w-full border rounded-xl px-4 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-green-400" /> className="w-full border rounded-xl px-4 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-green-400" />
<div className="bg-white rounded-2xl shadow overflow-hidden" style={{ maxHeight: 520, overflowY: "auto" }}> <div className="bg-white rounded-2xl shadow p-4" style={{ maxHeight: 520, overflowY: "auto" }}>
<table className="w-full text-sm"> <div className="flex gap-3 flex-wrap">
<thead className="bg-green-800 text-white sticky top-0"> {filtered.map((p) => (
<tr> <div
<th className="text-right px-3 py-3">بازیکن</th> key={p.id}
<th className="px-2 py-3">قیمت</th> draggable
<th className="px-2 py-3">pts</th> onDragStart={() => setDraggedId(p.id)}
<th className="px-2 py-3"></th> className="flex-shrink-0 bg-gray-50 rounded-xl p-2 cursor-move hover:bg-gray-100 transition border-2 border-transparent hover:border-green-500"
</tr> style={{ width: "90px" }}
</thead> >
<tbody> <div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto">
{filtered.map((p) => ( {p.image ? (
<tr key={p.id} className="border-t hover:bg-green-50 transition"> <Image
<td className="px-3 py-2"> src={`/uploads/players/${p.image}`}
<div className="font-medium text-sm">{p.name}</div> alt={p.name}
<div className="flex items-center gap-1 mt-0.5"> fill
<span className="text-xs text-gray-400">{p.country.flagUrl} {p.country.name}</span> className="object-cover"
<PositionBadge position={p.position} /> />
) : (
<div className="w-full h-full flex items-center justify-center text-gray-400 text-3xl">
👤
</div> </div>
</td> )}
<td className="px-2 py-2 text-center text-green-700 font-bold text-xs">{p.price}M</td> </div>
<td className="px-2 py-2 text-center text-blue-700 font-bold text-xs">{p.totalPoints}</td> <div className="text-[10px] font-bold text-gray-800 text-center leading-tight mb-1">
<td className="px-2 py-2"> {p.name.split(" ").slice(-1)[0]}
<button onClick={() => addPlayer(p.id)} </div>
disabled={loading || p.price > remaining + 0.01} <div className="flex items-center justify-center gap-1 mb-1">
className="bg-green-600 text-white w-7 h-7 rounded-lg text-lg font-bold hover:bg-green-700 disabled:opacity-30 transition flex items-center justify-center"> <span className="text-xs">{p.country.flagUrl}</span>
+ <PositionBadge position={p.position} />
</button> </div>
</td> <div className="text-[9px] text-center text-gray-600 mb-2">
</tr> <span className="text-green-700 font-bold">{p.price}M</span>
))} <span className="mx-1">·</span>
{filtered.length === 0 && ( <span className="text-blue-700 font-bold">{p.totalPoints}pts</span>
<tr><td colSpan={4} className="text-center text-gray-400 py-8">بازیکنی پیدا نشد</td></tr> </div>
)} <button
</tbody> onClick={() => addPlayer(p.id)}
</table> onPointerDown={(e) => e.stopPropagation()}
disabled={loading || p.price > remaining + 0.01}
className="w-full bg-green-600 text-white text-xs py-1 rounded-lg hover:bg-green-700 transition disabled:opacity-30 font-bold"
>
+ افزودن
</button>
</div>
))}
{filtered.length === 0 && (
<div className="w-full text-center text-gray-400 py-8">بازیکنی پیدا نشد</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -418,42 +434,79 @@ function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, sm
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const isDragging = draggedId === tp.playerId; const isDragging = draggedId === tp.playerId;
const isEliminated = (tp.player as any).country?.isEliminated; const isEliminated = (tp.player as any).country?.isEliminated;
const color = isEliminated
? "bg-gray-500 text-gray-300 border-gray-600"
: POS_COLORS[tp.player.position] ?? "bg-gray-400 text-white border-gray-500";
const shortName = tp.player.name.split(" ").slice(-1)[0]; const shortName = tp.player.name.split(" ").slice(-1)[0];
return ( return (
<div className={`relative flex flex-col items-center gap-1 cursor-grab select-none transition-opacity ${isDragging ? "opacity-40" : ""} ${small ? "w-14" : "w-16"} group`} <div
className={`relative group ${isDragging ? "opacity-50" : ""}`}
draggable draggable
onDragStart={() => onDragStart(tp.playerId)} onDragStart={() => onDragStart(tp.playerId)}
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(tp.playerId)} onDrop={() => onDrop(tp.playerId)}
onClick={() => setShowMenu((v) => !v)}> >
<div className={`relative ${small ? "w-11 h-11 text-lg" : "w-14 h-14 text-xl"} rounded-full border-2 flex items-center justify-center font-bold shadow-lg ${color} ${isEliminated ? "grayscale opacity-60" : ""}`}> <div className={`bg-white/95 rounded-xl p-2 cursor-move hover:bg-white transition shadow-lg ${small ? "w-16" : "w-20"}`}>
{tp.player.position === "GK" ? "🧤" : tp.player.position === "DEF" ? "🛡️" : tp.player.position === "MID" ? "⚙️" : "⚡"} <div className={`relative ${small ? "w-12 h-12" : "w-16 h-16"} rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto`}>
{isEliminated && <div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center text-white text-[8px] font-bold"></div>} {tp.player.image ? (
</div> <Image
<div className={`text-center font-medium leading-tight truncate w-full ${small ? "text-[9px]" : "text-[10px]"} ${isEliminated ? "text-gray-400" : "text-white"}`}> src={`/uploads/players/${tp.player.image}`}
{shortName} alt={tp.player.name}
</div> fill
<div className="flex items-center gap-1"> className="object-cover"
{tp.isCaptain && <span className="text-yellow-300 text-xs font-bold">©</span>} />
{tp.isViceCaptain && <span className="text-gray-300 text-xs font-bold">VC</span>} ) : (
<span className={`text-[9px] ${isEliminated ? "text-gray-500" : "text-white/60"}`}>{tp.player.totalPoints}pts</span> <div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">
</div> 👤
{isEliminated && ( </div>
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-[9px] px-2 py-1 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity"> )}
تیم ملی حذف شده {isEliminated && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<span className="text-white text-xs font-bold"></span>
</div>
)}
</div> </div>
)}
{showMenu && ( <div className={`text-[10px] font-bold text-gray-800 text-center leading-tight ${isEliminated ? "opacity-50" : ""}`}>
<div className="absolute top-full mt-1 bg-white rounded-xl shadow-xl z-50 text-xs w-36 overflow-hidden border" onClick={(e) => e.stopPropagation()}> {shortName}
{isEliminated && <div className="px-3 py-2 bg-red-50 text-red-600 text-[10px] border-b"> تیم ملی حذف شده</div>}
<button onClick={() => { onCaptain(tp.playerId, "captain"); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-gray-50 border-b">کاپیتان ©</button>
<button onClick={() => { onCaptain(tp.playerId, "vice"); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-gray-50 border-b">نایب کاپیتان VC</button>
<button onClick={() => { onRemove(tp.playerId); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-red-50 text-red-600">حذف از تیم</button>
</div> </div>
)}
<div className="flex items-center justify-center gap-1 mt-1">
{tp.isCaptain && (
<div className="bg-yellow-400 text-yellow-900 rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">
C
</div>
)}
{tp.isViceCaptain && (
<div className="bg-gray-400 text-white rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">
V
</div>
)}
</div>
<div className="text-[8px] text-center text-gray-600 mt-1">
{tp.player.totalPoints}pts
</div>
</div>
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition flex gap-1 z-20">
<button
onClick={(e) => {
e.stopPropagation();
onCaptain(tp.playerId, tp.isCaptain ? "vice" : "captain");
}}
className="bg-yellow-400 text-yellow-900 text-[8px] px-2 py-0.5 rounded-full font-bold whitespace-nowrap shadow"
>
{tp.isCaptain ? "VC" : "C"}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onRemove(tp.playerId);
}}
className="bg-red-500 text-white text-[8px] px-2 py-0.5 rounded-full font-bold shadow"
>
حذف
</button>
</div>
</div> </div>
); );
} }

View File

@@ -4,13 +4,14 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
import { calculateMatchPoints } from "@/lib/points"; import { calculateMatchPoints } from "@/lib/points";
export async function POST(_: NextRequest, { params }: { params: { id: string } }) { export async function POST(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const events = await db.matchEvent.findMany({ const events = await db.matchEvent.findMany({
where: { matchId: params.id }, where: { matchId: id },
include: { player: true }, include: { player: true },
}); });

View File

@@ -3,11 +3,12 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
export async function DELETE(_: NextRequest, { params }: { params: { id: string; eventId: string } }) { export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string; eventId: string }> }) {
const { eventId } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await db.matchEvent.delete({ where: { id: params.eventId } }); await db.matchEvent.delete({ where: { id: eventId } });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }

View File

@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) { export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -11,7 +12,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
const { playerId, type, minute, extraInfo } = await req.json(); const { playerId, type, minute, extraInfo } = await req.json();
const event = await db.matchEvent.create({ const event = await db.matchEvent.create({
data: { matchId: params.id, playerId, type, minute: minute ?? null, extraInfo: extraInfo || null }, data: { matchId: id, playerId, type, minute: minute ?? null, extraInfo: extraInfo || null },
}); });
return NextResponse.json(event, { status: 201 }); return NextResponse.json(event, { status: 201 });

View File

@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
export async function POST(req: NextRequest, { params }: { params: { id: string } }) { export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -11,11 +12,11 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
const lineups: Array<{ countryId: string; formation: string; playerIds: string[] }> = await req.json(); const lineups: Array<{ countryId: string; formation: string; playerIds: string[] }> = await req.json();
// حذف ترکیب‌های قبلی // حذف ترکیب‌های قبلی
await db.matchLineup.deleteMany({ where: { matchId: params.id } }); await db.matchLineup.deleteMany({ where: { matchId: id } });
for (const l of lineups) { for (const l of lineups) {
await db.matchLineup.create({ await db.matchLineup.create({
data: { matchId: params.id, countryId: l.countryId, formation: l.formation, playerIds: l.playerIds }, data: { matchId: id, countryId: l.countryId, formation: l.formation, playerIds: l.playerIds },
}); });
} }

View File

@@ -3,21 +3,23 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) { export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json(); const body = await req.json();
const country = await db.country.update({ where: { id: params.id }, data: body }); const country = await db.country.update({ where: { id }, data: body });
return NextResponse.json(country); return NextResponse.json(country);
} }
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) { export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await db.country.delete({ where: { id: params.id } }); await db.country.delete({ where: { id } });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }

View File

@@ -3,30 +3,33 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
export async function GET(_: NextRequest, { params }: { params: { id: string } }) { export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const match = await db.match.findUnique({ const match = await db.match.findUnique({
where: { id: params.id }, where: { id },
include: { homeTeam: true, awayTeam: true, playerStats: { include: { player: true } } }, include: { homeTeam: true, awayTeam: true, playerStats: { include: { player: true } } },
}); });
if (!match) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!match) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(match); return NextResponse.json(match);
} }
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) { export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json(); const body = await req.json();
const match = await db.match.update({ where: { id: params.id }, data: body }); const match = await db.match.update({ where: { id }, data: body });
return NextResponse.json(match); return NextResponse.json(match);
} }
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) { export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
await db.match.delete({ where: { id: params.id } }); await db.match.delete({ where: { id } });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }

View File

@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) { export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") { if (!session || (session.user as any).role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -11,18 +12,19 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
const body = await req.json(); const body = await req.json();
const player = await db.player.update({ const player = await db.player.update({
where: { id: params.id }, where: { id },
data: body, data: body,
}); });
return NextResponse.json(player); return NextResponse.json(player);
} }
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") { if (!session || (session.user as any).role !== "ADMIN") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
} }
await db.player.delete({ where: { id: params.id } }); await db.player.delete({ where: { id } });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }

View File

@@ -3,12 +3,32 @@ import { db } from "@/lib/db";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth"; import { authOptions } from "@/lib/auth";
export async function POST(_: NextRequest, { params }: { params: { id: string } }) { export async function POST(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN") if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const currentRound = await db.round.findUnique({ where: { id } });
if (!currentRound) {
return NextResponse.json({ error: "Round not found" }, { status: 404 });
}
// اگه فعاله، غیرفعالش کن
if (currentRound.isActive) {
const round = await db.round.update({
where: { id },
data: { isActive: false }
});
return NextResponse.json(round);
}
// اگه غیرفعاله، همه رو غیرفعال کن و این رو فعال کن
await db.round.updateMany({ data: { isActive: false } }); await db.round.updateMany({ data: { isActive: false } });
const round = await db.round.update({ where: { id: params.id }, data: { isActive: true } }); const round = await db.round.update({
where: { id },
data: { isActive: true }
});
return NextResponse.json(round); return NextResponse.json(round);
} }

View File

@@ -23,3 +23,34 @@ export async function POST(req: NextRequest) {
}); });
return NextResponse.json(round, { status: 201 }); return NextResponse.json(round, { status: 201 });
} }
export async function PUT(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id, number, name, deadline } = await req.json();
const round = await db.round.update({
where: { id },
data: { number, name, deadline: new Date(deadline) },
});
return NextResponse.json(round);
}
export async function DELETE(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== "ADMIN")
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { id } = await req.json();
// چک کنیم که بازی نداشته باشه
const matchCount = await db.match.count({ where: { roundId: id } });
if (matchCount > 0) {
return NextResponse.json({ error: "این دور دارای بازی است و قابل حذف نیست" }, { status: 400 });
}
await db.round.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@@ -23,12 +23,22 @@ export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { name } = await req.json(); const { name, formation } = await req.json();
const userId = (session.user as any).id; const userId = (session.user as any).id;
// بررسی وجود کاربر
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
const existing = await db.team.findUnique({ where: { userId } }); const existing = await db.team.findUnique({ where: { userId } });
if (existing) return NextResponse.json({ error: "Team already exists" }, { status: 400 }); if (existing) return NextResponse.json({ error: "Team already exists" }, { status: 400 });
const team = await db.team.create({ data: { name, userId } }); const team = await db.team.create({
data: {
name,
userId,
formation: formation || "4-3-3"
}
});
return NextResponse.json(team, { status: 201 }); return NextResponse.json(team, { status: 201 });
} }

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "No session" }, { status: 401 });
}
const userId = (session.user as any).id;
// بررسی وجود کاربر در دیتابیس
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true, role: true }
});
return NextResponse.json({
session: {
user: session.user,
userId: userId,
},
userInDb: user,
exists: !!user
});
}

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile } from "fs/promises";
import path from "path";
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json(
{ error: "فایلی انتخاب نشده است" },
{ status: 400 }
);
}
// بررسی نوع فایل
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: "فقط فایل‌های تصویری مجاز هستند" },
{ status: 400 }
);
}
// بررسی حجم فایل (حداکثر 5MB)
if (file.size > 5 * 1024 * 1024) {
return NextResponse.json(
{ error: "حجم فایل نباید بیشتر از 5 مگابایت باشد" },
{ status: 400 }
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// ساخت نام یونیک برای فایل
const timestamp = Date.now();
const originalName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
const fileName = `${timestamp}-${originalName}`;
// مسیر ذخیره فایل
const uploadDir = path.join(process.cwd(), "public", "uploads", "players");
const filePath = path.join(uploadDir, fileName);
// ذخیره فایل
await writeFile(filePath, buffer);
return NextResponse.json({
success: true,
fileName: fileName,
url: `/uploads/players/${fileName}`,
});
} catch (error) {
console.error("خطا در آپلود:", error);
return NextResponse.json(
{ error: "خطا در آپلود فایل" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,25 @@
interface CountryFlagProps {
flagImage?: string | null;
flagEmoji?: string | null;
countryName: string;
size?: 'sm' | 'md' | 'lg';
}
export default function CountryFlag({
flagImage,
flagEmoji,
countryName,
size = 'md',
}: CountryFlagProps) {
const sizeClasses = {
sm: 'text-lg',
md: 'text-2xl',
lg: 'text-4xl',
};
return (
<span className={sizeClasses[size]} title={countryName}>
{flagEmoji || ''}
</span>
);
}

603
package-lock.json generated
View File

@@ -1,11 +1,14 @@
{ {
"name": "football", "name": "football-next",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
@@ -26,7 +29,8 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"ts-node": "^10.9.2" "ts-node": "^10.9.2",
"tsx": "^4.21.0"
} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
@@ -131,6 +135,59 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://package-mirror.liara.ir/repository/npm/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.2", "version": "1.9.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.9.2.tgz", "resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.9.2.tgz",
@@ -141,6 +198,448 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@img/colour": { "node_modules/@img/colour": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://package-mirror.liara.ir/repository/npm/@img/colour/-/colour-1.1.0.tgz",
@@ -1654,6 +2153,48 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/escalade/-/escalade-3.2.0.tgz", "resolved": "https://package-mirror.liara.ir/repository/npm/escalade/-/escalade-3.2.0.tgz",
@@ -1706,6 +2247,34 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://package-mirror.liara.ir/repository/npm/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://package-mirror.liara.ir/repository/npm/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/giget": { "node_modules/giget": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/giget/-/giget-2.0.0.tgz", "resolved": "https://package-mirror.liara.ir/repository/npm/giget/-/giget-2.0.0.tgz",
@@ -2532,6 +3101,16 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://package-mirror.liara.ir/repository/npm/scheduler/-/scheduler-0.27.0.tgz",
@@ -2716,6 +3295,26 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://package-mirror.liara.ir/repository/npm/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://package-mirror.liara.ir/repository/npm/typescript/-/typescript-6.0.2.tgz", "resolved": "https://package-mirror.liara.ir/repository/npm/typescript/-/typescript-6.0.2.tgz",

View File

@@ -2,13 +2,22 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start" "start": "next start",
"db:push": "prisma db push",
"db:generate": "prisma generate",
"db:studio": "prisma studio",
"setup:test-user": "tsx scripts/create-test-user.ts",
"setup:admin": "tsx scripts/create-admin-user.ts",
"check:users": "tsx scripts/check-users.ts"
}, },
"prisma": { "prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^6.19.3", "@prisma/client": "^6.19.3",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
@@ -29,6 +38,7 @@
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"ts-node": "^10.9.2" "ts-node": "^10.9.2",
"tsx": "^4.21.0"
} }
} }

View File

@@ -63,17 +63,26 @@ enum EventType {
} }
model Country { model Country {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
code String @unique code String @unique
flagUrl String? flagUrl String?
defaultFormation String @default("4-3-3") flagImage String? // نام فایل پرچم مثل Flag_of_Australia.webp
group Group? @relation(fields: [groupId], references: [id]) confederation String? // کنفدراسیون (UEFA, AFC, CAF, ...)
groupId String? qualificationMethod String? // شیوه راه‌یابی
isEliminated Boolean @default(false) qualificationDate String? // تاریخ راه‌یابی
players Player[] participationHistory String? // سابقه شرکت
homeMatches Match[] @relation("HomeTeam") bestResult String? // بهترین نتیجه
awayMatches Match[] @relation("AwayTeam") description String? @db.Text // توضیحات کامل
defaultFormation String @default("4-3-3")
defaultLineupPlayerIds String[] @default([])
defaultCaptainId String? // شناسه کاپیتان پیش‌فرض
group Group? @relation(fields: [groupId], references: [id])
groupId String?
isEliminated Boolean @default(false)
players Player[]
homeMatches Match[] @relation("HomeTeam")
awayMatches Match[] @relation("AwayTeam")
} }
model Group { model Group {
@@ -85,6 +94,7 @@ model Group {
model Player { model Player {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
image String? // نام فایل تصویر در public/uploads/players/
position Position position Position
countryId String countryId String
country Country @relation(fields: [countryId], references: [id]) country Country @relation(fields: [countryId], references: [id])
@@ -109,6 +119,16 @@ model Match {
stage MatchStage @default(GROUP) stage MatchStage @default(GROUP)
status MatchStatus @default(SCHEDULED) status MatchStatus @default(SCHEDULED)
matchDate DateTime matchDate DateTime
matchDatePersian String? // تاریخ شمسی
stadium String? // نام ورزشگاه
city String? // شهر
referee String? // داور اصلی
assistant1 String? // کمک داور 1
assistant2 String? // کمک داور 2
fourthOfficial String? // داور چهارم
attendance Int? // تعداد تماشاگر
weather String? // وضعیت آب و هوا
description String? @db.Text // توضیحات بازی
roundId String? roundId String?
round Round? @relation(fields: [roundId], references: [id]) round Round? @relation(fields: [roundId], references: [id])
playerStats PlayerMatchStat[] playerStats PlayerMatchStat[]

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 690 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -0,0 +1 @@
# این فایل برای حفظ پوشه در گیت است

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

70
scripts/check-users.ts Normal file
View File

@@ -0,0 +1,70 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
console.log("🔍 بررسی کاربران...\n");
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
team: {
select: {
id: true,
name: true,
},
},
},
});
console.log(`تعداد کاربران: ${users.length}\n`);
users.forEach((user, index) => {
console.log(`${index + 1}. ${user.email}`);
console.log(` ID: ${user.id}`);
console.log(` نام: ${user.name || "ندارد"}`);
console.log(` نقش: ${user.role}`);
console.log(` تیم: ${user.team ? user.team.name : "ندارد"}`);
console.log(` تاریخ ثبت‌نام: ${user.createdAt.toISOString()}`);
console.log("");
});
// بررسی Session ها
const sessions = await prisma.session.findMany({
select: {
id: true,
userId: true,
expires: true,
user: {
select: {
email: true,
},
},
},
});
console.log(`\n📋 تعداد Session های فعال: ${sessions.length}\n`);
sessions.forEach((session, index) => {
const isExpired = session.expires < new Date();
console.log(`${index + 1}. User: ${session.user.email}`);
console.log(` Session ID: ${session.id}`);
console.log(` User ID: ${session.userId}`);
console.log(` وضعیت: ${isExpired ? "منقضی شده ❌" : "فعال ✓"}`);
console.log(` انقضا: ${session.expires.toISOString()}`);
console.log("");
});
}
main()
.catch((e) => {
console.error("❌ خطا:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,44 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
console.log("🔧 ساخت کاربر ادمین...\n");
const email = "admin@admin.com";
const password = "admin123";
const hashedPassword = await bcrypt.hash(password, 10);
// حذف کاربر قبلی اگر وجود دارد
await prisma.user.deleteMany({
where: { email },
});
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
name: "ادمین",
role: "ADMIN",
},
});
console.log("✅ کاربر ادمین ساخته شد:");
console.log(` ایمیل: ${email}`);
console.log(` رمز عبور: ${password}`);
console.log(` ID: ${user.id}`);
console.log(` نقش: ${user.role}`);
console.log("\n🔐 برای ورود از این اطلاعات استفاده کنید:");
console.log(` Email: ${email}`);
console.log(` Password: ${password}`);
}
main()
.catch((e) => {
console.error("❌ خطا:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,44 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
console.log("🔧 ساخت کاربر تست...\n");
const email = "test@test.com";
const password = "123456";
const hashedPassword = await bcrypt.hash(password, 10);
// حذف کاربر قبلی اگر وجود دارد
await prisma.user.deleteMany({
where: { email },
});
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
name: "کاربر تست",
role: "USER",
},
});
console.log("✅ کاربر تست ساخته شد:");
console.log(` ایمیل: ${email}`);
console.log(` رمز عبور: ${password}`);
console.log(` ID: ${user.id}`);
console.log(` نقش: ${user.role}`);
console.log("\n🔐 برای ورود از این اطلاعات استفاده کنید:");
console.log(` Email: ${email}`);
console.log(` Password: ${password}`);
}
main()
.catch((e) => {
console.error("❌ خطا:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});