admin
233
README.md
Normal 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
@@ -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
@@ -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. با ادمین تماس بگیرید
|
||||
|
||||
---
|
||||
|
||||
**موفق باشید! ⚽🏆**
|
||||
@@ -11,7 +11,19 @@ export default function CountryForm({
|
||||
countryId,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -19,52 +31,194 @@ export default function CountryForm({
|
||||
name: initial?.name ?? "",
|
||||
code: initial?.code ?? "",
|
||||
flagUrl: initial?.flagUrl ?? "",
|
||||
flagImage: initial?.flagImage ?? "",
|
||||
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);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
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", {
|
||||
method: countryId ? "PUT" : "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.ok) { router.push("/admin/countries"); router.refresh(); }
|
||||
if (res.ok) {
|
||||
router.push("/admin/countries");
|
||||
router.refresh();
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
|
||||
<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 />
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4 max-w-3xl">
|
||||
<h3 className="text-lg font-bold mb-2">اطلاعات پایه</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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>
|
||||
<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 className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">ایموجی پرچم</label>
|
||||
<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>
|
||||
<label className="block text-sm font-medium mb-1">ایموجی پرچم (اختیاری)</label>
|
||||
<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 className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">گروه</label>
|
||||
<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>
|
||||
<label className="block text-sm font-medium mb-1">گروه</label>
|
||||
<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>
|
||||
|
||||
<hr className="my-2" />
|
||||
<h3 className="text-lg font-bold mb-2">اطلاعات راهیابی</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<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 ? "ذخیره تغییرات" : "افزودن تیم"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { db } from "@/lib/db";
|
||||
import { notFound } from "next/navigation";
|
||||
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([
|
||||
db.country.findUnique({ where: { id: params.id } }),
|
||||
db.country.findUnique({ where: { id } }),
|
||||
db.group.findMany({ orderBy: { name: "asc" } }),
|
||||
]);
|
||||
if (!country) notFound();
|
||||
|
||||
794
app/(admin)/admin/countries/[id]/lineup/DefaultLineupEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
app/(admin)/admin/countries/[id]/lineup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
import CountryFlag from "@/components/CountryFlag";
|
||||
|
||||
export default async function AdminCountriesPage() {
|
||||
const countries = await db.country.findMany({
|
||||
@@ -18,23 +19,39 @@ export default async function AdminCountriesPage() {
|
||||
+ تیم جدید
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{countries.map((c) => (
|
||||
<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="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center text-2xl">
|
||||
{c.flagUrl ?? "🏳️"}
|
||||
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
|
||||
<CountryFlag
|
||||
flagImage={c.flagImage}
|
||||
flagEmoji={c.flagUrl}
|
||||
countryName={c.name}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">{c.name}</div>
|
||||
<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>
|
||||
<Link href={`/admin/countries/${c.id}/edit`} className="text-blue-600 hover:underline text-sm">
|
||||
ویرایش
|
||||
</Link>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/admin/countries/${c.id}/lineup`}
|
||||
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>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { db } from "@/lib/db";
|
||||
import { notFound } from "next/navigation";
|
||||
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([
|
||||
db.match.findUnique({ where: { id: params.id } }),
|
||||
db.match.findUnique({ where: { id } }),
|
||||
db.country.findMany({ orderBy: { name: "asc" } }),
|
||||
db.round.findMany({ orderBy: { number: "asc" } }),
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
|
||||
type Country = { id: string; name: string };
|
||||
|
||||
@@ -11,7 +12,7 @@ export default function PlayerForm({
|
||||
playerId,
|
||||
}: {
|
||||
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;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -20,10 +21,42 @@ export default function PlayerForm({
|
||||
position: initial?.position ?? "FWD",
|
||||
countryId: initial?.countryId ?? "",
|
||||
price: initial?.price ?? 5.0,
|
||||
image: initial?.image ?? "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
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) {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -45,6 +78,33 @@ export default function PlayerForm({
|
||||
return (
|
||||
<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>}
|
||||
|
||||
<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>
|
||||
<label className="block text-sm font-medium mb-1">نام بازیکن</label>
|
||||
<input
|
||||
|
||||
@@ -3,9 +3,10 @@ import { notFound } from "next/navigation";
|
||||
import PlayerForm from "../../PlayerForm";
|
||||
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([
|
||||
db.player.findUnique({ where: { id: params.id } }),
|
||||
db.player.findUnique({ where: { id } }),
|
||||
db.country.findMany({ orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
@@ -25,6 +26,7 @@ export default async function EditPlayerPage({ params }: { params: { id: string
|
||||
position: player.position,
|
||||
countryId: player.countryId,
|
||||
price: player.price,
|
||||
image: player.image,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,19 +7,21 @@ export default function ActivateRoundButton({ roundId, isActive }: { roundId: st
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function activate() {
|
||||
async function toggleActivation() {
|
||||
setLoading(true);
|
||||
await fetch(`/api/rounds/${roundId}/activate`, { method: "POST" });
|
||||
router.refresh();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (isActive) return <span className="text-xs text-green-600 font-medium px-3 py-1.5">✓ فعال</span>;
|
||||
|
||||
return (
|
||||
<button onClick={activate} 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">
|
||||
{loading ? "..." : "فعالسازی"}
|
||||
<button onClick={toggleActivation} disabled={loading}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50 ${
|
||||
isActive
|
||||
? "bg-gray-200 text-gray-600 hover:bg-gray-300"
|
||||
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||
}`}>
|
||||
{loading ? "..." : isActive ? "غیرفعال" : "فعالسازی"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
66
app/(admin)/admin/rounds/DeleteRoundButton.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,20 @@
|
||||
import { useState } from "react";
|
||||
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 [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 [error, setError] = useState("");
|
||||
|
||||
@@ -13,14 +24,22 @@ export default function RoundForm() {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
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", {
|
||||
method: "POST",
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, number: parseInt(form.number) }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setForm({ number: "", name: "", deadline: "" });
|
||||
router.refresh();
|
||||
router.push("/admin/rounds");
|
||||
} else {
|
||||
const d = await res.json();
|
||||
setError(d.error ?? "خطا در ذخیره");
|
||||
@@ -55,7 +74,7 @@ export default function RoundForm() {
|
||||
</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 ? "در حال ذخیره..." : "افزودن دور"}
|
||||
{loading ? "در حال ذخیره..." : editRound ? "ویرایش دور" : "افزودن دور"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
57
app/(admin)/admin/rounds/[id]/DeleteMatchButton.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
app/(admin)/admin/rounds/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { db } from "@/lib/db";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
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({
|
||||
where: { id: params.matchId },
|
||||
where: { id: matchId },
|
||||
include: {
|
||||
homeTeam: { include: { players: { orderBy: { position: "asc" } } } },
|
||||
awayTeam: { include: { players: { orderBy: { position: "asc" } } } },
|
||||
homeTeam: { include: { players: { where: { isActive: true }, orderBy: { position: "asc" } } } },
|
||||
awayTeam: { include: { players: { where: { isActive: true }, orderBy: { position: "asc" } } } },
|
||||
events: { include: { player: true }, orderBy: { minute: "asc" } },
|
||||
lineups: true,
|
||||
playerStats: { include: { player: true } },
|
||||
@@ -18,14 +20,19 @@ export default async function MatchDetailPage({ params }: { params: { id: string
|
||||
if (!match) notFound();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link href={`/admin/rounds/${params.id}`} className="text-gray-400 hover:text-gray-600">← دور</Link>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/${id}`} className="text-gray-400 hover:text-gray-600">← دور</Link>
|
||||
<h1 className="text-xl font-bold">
|
||||
{match.homeTeam.name} {match.homeScore ?? "-"} - {match.awayScore ?? "-"} {match.awayTeam.name}
|
||||
</h1>
|
||||
</div>
|
||||
<MatchEventManager match={match} roundId={params.id} />
|
||||
|
||||
{/* مدیریت ترکیب */}
|
||||
<MatchLineupManager match={match} />
|
||||
|
||||
{/* مدیریت رویدادها */}
|
||||
<MatchEventManager match={match} roundId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from "@/lib/db";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import DeleteMatchButton from "./DeleteMatchButton";
|
||||
|
||||
const statusStyle: Record<string, string> = {
|
||||
SCHEDULED: "bg-gray-100 text-gray-600",
|
||||
@@ -9,9 +10,10 @@ const statusStyle: Record<string, string> = {
|
||||
};
|
||||
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({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: {
|
||||
matches: {
|
||||
include: {
|
||||
@@ -27,10 +29,16 @@ export default async function RoundDetailPage({ params }: { params: { id: string
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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.name}</h1>
|
||||
{round.isActive && <span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">فعال</span>}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds" className="text-gray-400 hover:text-gray-600">← دورها</Link>
|
||||
<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 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 gap-4 flex-1 justify-end">
|
||||
<span className="font-bold">{m.homeTeam.name}</span>
|
||||
<span>{m.homeTeam.flagUrl}</span>
|
||||
<span className="text-2xl">{m.homeTeam.flagUrl}</span>
|
||||
</div>
|
||||
<div className="mx-6 text-center min-w-[120px]">
|
||||
{m.status !== "SCHEDULED" ? (
|
||||
@@ -52,22 +60,36 @@ export default async function RoundDetailPage({ params }: { params: { id: string
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex gap-2 mr-4">
|
||||
<div className="text-xs text-gray-400 text-center">
|
||||
<div className="flex gap-2 mr-4 items-center">
|
||||
<div className="text-xs text-gray-400 text-center mr-2">
|
||||
<div>{m._count.events} رویداد</div>
|
||||
<div>{m._count.lineups > 0 ? "✓ ترکیب" : "بدون ترکیب"}</div>
|
||||
</div>
|
||||
<Link href={`/admin/rounds/${round.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">
|
||||
<Link href={`/admin/rounds/${id}/match/${m.id}`}
|
||||
className="bg-green-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-green-800 transition">
|
||||
جزئیات
|
||||
</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>
|
||||
))}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from "@/lib/db";
|
||||
import Link from "next/link";
|
||||
import RoundForm from "./RoundForm";
|
||||
import ActivateRoundButton from "./ActivateRoundButton";
|
||||
import DeleteRoundButton from "./DeleteRoundButton";
|
||||
|
||||
export default async function AdminRoundsPage() {
|
||||
const rounds = await db.round.findMany({
|
||||
@@ -17,7 +18,7 @@ export default async function AdminRoundsPage() {
|
||||
{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 className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<div className="font-bold flex items-center gap-2">
|
||||
دور {r.number} - {r.name}
|
||||
{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">
|
||||
بازیها
|
||||
</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>
|
||||
|
||||
139
app/(user)/countries/[code]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import PositionBadge from "@/components/PositionBadge";
|
||||
import Image from "next/image";
|
||||
|
||||
type Player = {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string | null;
|
||||
position: string;
|
||||
price: number;
|
||||
totalPoints: number;
|
||||
@@ -346,42 +348,56 @@ export default function TeamBuilder({
|
||||
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" />
|
||||
|
||||
<div className="bg-white rounded-2xl shadow overflow-hidden" style={{ maxHeight: 520, overflowY: "auto" }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-green-800 text-white sticky top-0">
|
||||
<tr>
|
||||
<th className="text-right px-3 py-3">بازیکن</th>
|
||||
<th className="px-2 py-3">قیمت</th>
|
||||
<th className="px-2 py-3">pts</th>
|
||||
<th className="px-2 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((p) => (
|
||||
<tr key={p.id} className="border-t hover:bg-green-50 transition">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-sm">{p.name}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<span className="text-xs text-gray-400">{p.country.flagUrl} {p.country.name}</span>
|
||||
<PositionBadge position={p.position} />
|
||||
<div className="bg-white rounded-2xl shadow p-4" style={{ maxHeight: 520, overflowY: "auto" }}>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{filtered.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
draggable
|
||||
onDragStart={() => setDraggedId(p.id)}
|
||||
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"
|
||||
style={{ width: "90px" }}
|
||||
>
|
||||
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto">
|
||||
{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 text-3xl">
|
||||
👤
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center text-green-700 font-bold text-xs">{p.price}M</td>
|
||||
<td className="px-2 py-2 text-center text-blue-700 font-bold text-xs">{p.totalPoints}</td>
|
||||
<td className="px-2 py-2">
|
||||
<button onClick={() => addPlayer(p.id)}
|
||||
disabled={loading || p.price > remaining + 0.01}
|
||||
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">
|
||||
+
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr><td colSpan={4} className="text-center text-gray-400 py-8">بازیکنی پیدا نشد</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] font-bold text-gray-800 text-center leading-tight mb-1">
|
||||
{p.name.split(" ").slice(-1)[0]}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1 mb-1">
|
||||
<span className="text-xs">{p.country.flagUrl}</span>
|
||||
<PositionBadge position={p.position} />
|
||||
</div>
|
||||
<div className="text-[9px] text-center text-gray-600 mb-2">
|
||||
<span className="text-green-700 font-bold">{p.price}M</span>
|
||||
<span className="mx-1">·</span>
|
||||
<span className="text-blue-700 font-bold">{p.totalPoints}pts</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addPlayer(p.id)}
|
||||
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>
|
||||
@@ -418,42 +434,79 @@ function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, sm
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const isDragging = draggedId === tp.playerId;
|
||||
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];
|
||||
|
||||
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
|
||||
onDragStart={() => onDragStart(tp.playerId)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
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" : ""}`}>
|
||||
{tp.player.position === "GK" ? "🧤" : tp.player.position === "DEF" ? "🛡️" : tp.player.position === "MID" ? "⚙️" : "⚡"}
|
||||
{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>}
|
||||
</div>
|
||||
<div className={`text-center font-medium leading-tight truncate w-full ${small ? "text-[9px]" : "text-[10px]"} ${isEliminated ? "text-gray-400" : "text-white"}`}>
|
||||
{shortName}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{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>
|
||||
{isEliminated && (
|
||||
<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">
|
||||
تیم ملی حذف شده
|
||||
>
|
||||
<div className={`bg-white/95 rounded-xl p-2 cursor-move hover:bg-white transition shadow-lg ${small ? "w-16" : "w-20"}`}>
|
||||
<div className={`relative ${small ? "w-12 h-12" : "w-16 h-16"} rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto`}>
|
||||
{tp.player.image ? (
|
||||
<Image
|
||||
src={`/uploads/players/${tp.player.image}`}
|
||||
alt={tp.player.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">
|
||||
👤
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{showMenu && (
|
||||
<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()}>
|
||||
{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 className={`text-[10px] font-bold text-gray-800 text-center leading-tight ${isEliminated ? "opacity-50" : ""}`}>
|
||||
{shortName}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const events = await db.matchEvent.findMany({
|
||||
where: { matchId: params.id },
|
||||
where: { matchId: id },
|
||||
include: { player: true },
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
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 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 });
|
||||
|
||||
@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
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();
|
||||
|
||||
// حذف ترکیبهای قبلی
|
||||
await db.matchLineup.deleteMany({ where: { matchId: params.id } });
|
||||
await db.matchLineup.deleteMany({ where: { matchId: id } });
|
||||
|
||||
for (const l of lineups) {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,21 +3,23 @@ import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -3,30 +3,33 @@ import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-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({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
include: { homeTeam: true, awayTeam: true, playerStats: { include: { player: true } } },
|
||||
});
|
||||
if (!match) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-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);
|
||||
if (!session || (session.user as any).role !== "ADMIN") {
|
||||
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 player = await db.player.update({
|
||||
where: { id: params.id },
|
||||
where: { id },
|
||||
data: body,
|
||||
});
|
||||
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);
|
||||
if (!session || (session.user as any).role !== "ADMIN") {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -3,12 +3,32 @@ import { db } from "@/lib/db";
|
||||
import { getServerSession } from "next-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);
|
||||
if (!session || (session.user as any).role !== "ADMIN")
|
||||
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 } });
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -23,3 +23,34 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -23,12 +23,22 @@ export async function POST(req: NextRequest) {
|
||||
const session = await getServerSession(authOptions);
|
||||
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 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 } });
|
||||
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 });
|
||||
}
|
||||
|
||||
29
app/api/test-session/route.ts
Normal 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
|
||||
});
|
||||
}
|
||||
61
app/api/upload/player-image/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
25
components/CountryFlag.tsx
Normal 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
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "football",
|
||||
"name": "football-next",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@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/client": "^6.19.3",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
@@ -26,7 +29,8 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prisma": "^6.19.3",
|
||||
"ts-node": "^10.9.2"
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
@@ -131,6 +135,59 @@
|
||||
"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": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||
@@ -141,6 +198,448 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@img/colour/-/colour-1.1.0.tgz",
|
||||
@@ -1654,6 +2153,48 @@
|
||||
"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": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -1706,6 +2247,34 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/giget/-/giget-2.0.0.tgz",
|
||||
@@ -2532,6 +3101,16 @@
|
||||
"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": {
|
||||
"version": "0.27.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://package-mirror.liara.ir/repository/npm/typescript/-/typescript-6.0.2.tgz",
|
||||
|
||||
14
package.json
@@ -2,13 +2,22 @@
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"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": {
|
||||
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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/client": "^6.19.3",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
@@ -29,6 +38,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prisma": "^6.19.3",
|
||||
"ts-node": "^10.9.2"
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,17 +63,26 @@ enum EventType {
|
||||
}
|
||||
|
||||
model Country {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
code String @unique
|
||||
flagUrl String?
|
||||
defaultFormation String @default("4-3-3")
|
||||
group Group? @relation(fields: [groupId], references: [id])
|
||||
groupId String?
|
||||
isEliminated Boolean @default(false)
|
||||
players Player[]
|
||||
homeMatches Match[] @relation("HomeTeam")
|
||||
awayMatches Match[] @relation("AwayTeam")
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
code String @unique
|
||||
flagUrl String?
|
||||
flagImage String? // نام فایل پرچم مثل Flag_of_Australia.webp
|
||||
confederation String? // کنفدراسیون (UEFA, AFC, CAF, ...)
|
||||
qualificationMethod String? // شیوه راهیابی
|
||||
qualificationDate String? // تاریخ راهیابی
|
||||
participationHistory String? // سابقه شرکت
|
||||
bestResult String? // بهترین نتیجه
|
||||
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 {
|
||||
@@ -85,6 +94,7 @@ model Group {
|
||||
model Player {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
image String? // نام فایل تصویر در public/uploads/players/
|
||||
position Position
|
||||
countryId String
|
||||
country Country @relation(fields: [countryId], references: [id])
|
||||
@@ -109,6 +119,16 @@ model Match {
|
||||
stage MatchStage @default(GROUP)
|
||||
status MatchStatus @default(SCHEDULED)
|
||||
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?
|
||||
round Round? @relation(fields: [roundId], references: [id])
|
||||
playerStats PlayerMatchStat[]
|
||||
|
||||
BIN
public/imgs/flags/0qf5w0ww.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/imgs/flags/Flag_of_Algeria.svg.webp
Normal file
|
After Width: | Height: | Size: 368 B |
BIN
public/imgs/flags/Flag_of_Argentina.svg.webp
Normal file
|
After Width: | Height: | Size: 200 B |
BIN
public/imgs/flags/Flag_of_Australia.webp
Normal file
|
After Width: | Height: | Size: 438 B |
BIN
public/imgs/flags/Flag_of_Austria.svg.webp
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
public/imgs/flags/Flag_of_Belgium.webp
Normal file
|
After Width: | Height: | Size: 134 B |
BIN
public/imgs/flags/Flag_of_Brazil.svg.webp
Normal file
|
After Width: | Height: | Size: 568 B |
BIN
public/imgs/flags/Flag_of_Canada.svg.webp
Normal file
|
After Width: | Height: | Size: 254 B |
BIN
public/imgs/flags/Flag_of_Cape_Verde.svg.webp
Normal file
|
After Width: | Height: | Size: 290 B |
BIN
public/imgs/flags/Flag_of_Colombia.svg.webp
Normal file
|
After Width: | Height: | Size: 96 B |
BIN
public/imgs/flags/Flag_of_Croatia.svg.webp
Normal file
|
After Width: | Height: | Size: 396 B |
BIN
public/imgs/flags/Flag_of_Curaçao.svg.webp
Normal file
|
After Width: | Height: | Size: 200 B |
BIN
public/imgs/flags/Flag_of_Côte_d'Ivoire.svg.webp
Normal file
|
After Width: | Height: | Size: 134 B |
BIN
public/imgs/flags/Flag_of_Ecuador.svg.webp
Normal file
|
After Width: | Height: | Size: 566 B |
BIN
public/imgs/flags/Flag_of_Egypt.svg.webp
Normal file
|
After Width: | Height: | Size: 222 B |
BIN
public/imgs/flags/Flag_of_England.svg.webp
Normal file
|
After Width: | Height: | Size: 94 B |
BIN
public/imgs/flags/Flag_of_France.svg.png
Normal file
|
After Width: | Height: | Size: 340 B |
BIN
public/imgs/flags/Flag_of_Germany.svg.webp
Normal file
|
After Width: | Height: | Size: 56 B |
BIN
public/imgs/flags/Flag_of_Ghana.svg.webp
Normal file
|
After Width: | Height: | Size: 206 B |
BIN
public/imgs/flags/Flag_of_Haiti.svg.webp
Normal file
|
After Width: | Height: | Size: 496 B |
BIN
public/imgs/flags/Flag_of_Iran.svg.webp
Normal file
|
After Width: | Height: | Size: 420 B |
BIN
public/imgs/flags/Flag_of_Japan.svg.webp
Normal file
|
After Width: | Height: | Size: 260 B |
BIN
public/imgs/flags/Flag_of_Jordan.svg.webp
Normal file
|
After Width: | Height: | Size: 240 B |
BIN
public/imgs/flags/Flag_of_Mexico.svg.webp
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
public/imgs/flags/Flag_of_Morocco.svg.webp
Normal file
|
After Width: | Height: | Size: 220 B |
BIN
public/imgs/flags/Flag_of_New_Zealand.svg.webp
Normal file
|
After Width: | Height: | Size: 406 B |
BIN
public/imgs/flags/Flag_of_Norway.svg.webp
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
public/imgs/flags/Flag_of_Panama.svg.webp
Normal file
|
After Width: | Height: | Size: 300 B |
BIN
public/imgs/flags/Flag_of_Paraguay.svg.webp
Normal file
|
After Width: | Height: | Size: 156 B |
BIN
public/imgs/flags/Flag_of_Portugal.svg.webp
Normal file
|
After Width: | Height: | Size: 560 B |
BIN
public/imgs/flags/Flag_of_Qatar.svg.webp
Normal file
|
After Width: | Height: | Size: 252 B |
BIN
public/imgs/flags/Flag_of_Saudi_Arabia.svg.webp
Normal file
|
After Width: | Height: | Size: 384 B |
BIN
public/imgs/flags/Flag_of_Scotland.svg.webp
Normal file
|
After Width: | Height: | Size: 214 B |
BIN
public/imgs/flags/Flag_of_Senegal.svg.webp
Normal file
|
After Width: | Height: | Size: 216 B |
BIN
public/imgs/flags/Flag_of_South_Africa.svg.webp
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/imgs/flags/Flag_of_South_Korea.svg.webp
Normal file
|
After Width: | Height: | Size: 690 B |
BIN
public/imgs/flags/Flag_of_Spain.svg.webp
Normal file
|
After Width: | Height: | Size: 448 B |
BIN
public/imgs/flags/Flag_of_Switzerland.svg.webp
Normal file
|
After Width: | Height: | Size: 108 B |
BIN
public/imgs/flags/Flag_of_Tunisia.svg.webp
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
public/imgs/flags/Flag_of_Uruguay.svg.webp
Normal file
|
After Width: | Height: | Size: 470 B |
BIN
public/imgs/flags/Flag_of_Uzbekistan.svg.webp
Normal file
|
After Width: | Height: | Size: 218 B |
BIN
public/imgs/flags/Flag_of_the_Netherlands.svg.webp
Normal file
|
After Width: | Height: | Size: 100 B |
BIN
public/imgs/flags/Flag_of_the_United_States.svg.webp
Normal file
|
After Width: | Height: | Size: 336 B |
BIN
public/imgs/flags/izf03j3p.jpg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
1
public/uploads/players/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# این فایل برای حفظ پوشه در گیت است
|
||||
BIN
public/uploads/players/1775457575652-izf03j3p.jpg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/uploads/players/1775457601278-wdyjvv41.jpg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
public/uploads/players/1775457633947-npxfb0ze.jpg
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/uploads/players/1775457664083-jokccxy4.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/uploads/players/1775457674194-eyatdip1.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
public/uploads/players/1775457689792-0qf5w0ww.jpg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
public/uploads/players/1775457723763-jokccxy4.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/uploads/players/1775457740827-h2oiopig.jpg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/uploads/players/1775457773915-tfljtemw.jpg
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
public/uploads/players/1775457801579-u0eya02o.jpg
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/uploads/players/1775457816751-gfdffuen.jpg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/uploads/players/1775457847547-lbal0jyw.jpg
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/uploads/players/1775457873652-alvxmwu4.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
70
scripts/check-users.ts
Normal 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();
|
||||
});
|
||||
44
scripts/create-admin-user.ts
Normal 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();
|
||||
});
|
||||
44
scripts/create-test-user.ts
Normal 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();
|
||||
});
|
||||