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,
|
countryId,
|
||||||
}: {
|
}: {
|
||||||
groups: Group[];
|
groups: Group[];
|
||||||
initial?: { name: string; code: string; flagUrl?: string | null; groupId?: string | null };
|
initial?: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
flagUrl?: string | null;
|
||||||
|
flagImage?: string | null;
|
||||||
|
groupId?: string | null;
|
||||||
|
confederation?: string | null;
|
||||||
|
qualificationMethod?: string | null;
|
||||||
|
qualificationDate?: string | null;
|
||||||
|
participationHistory?: string | null;
|
||||||
|
bestResult?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
countryId?: string;
|
countryId?: string;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -19,52 +31,194 @@ export default function CountryForm({
|
|||||||
name: initial?.name ?? "",
|
name: initial?.name ?? "",
|
||||||
code: initial?.code ?? "",
|
code: initial?.code ?? "",
|
||||||
flagUrl: initial?.flagUrl ?? "",
|
flagUrl: initial?.flagUrl ?? "",
|
||||||
|
flagImage: initial?.flagImage ?? "",
|
||||||
groupId: initial?.groupId ?? "",
|
groupId: initial?.groupId ?? "",
|
||||||
|
confederation: initial?.confederation ?? "",
|
||||||
|
qualificationMethod: initial?.qualificationMethod ?? "",
|
||||||
|
qualificationDate: initial?.qualificationDate ?? "",
|
||||||
|
participationHistory: initial?.participationHistory ?? "",
|
||||||
|
bestResult: initial?.bestResult ?? "",
|
||||||
|
description: initial?.description ?? "",
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const payload = { ...form, groupId: form.groupId || null, flagUrl: form.flagUrl || null };
|
const payload = {
|
||||||
|
...form,
|
||||||
|
groupId: form.groupId || null,
|
||||||
|
flagUrl: form.flagUrl || null,
|
||||||
|
flagImage: form.flagImage || null,
|
||||||
|
confederation: form.confederation || null,
|
||||||
|
qualificationMethod: form.qualificationMethod || null,
|
||||||
|
qualificationDate: form.qualificationDate || null,
|
||||||
|
participationHistory: form.participationHistory || null,
|
||||||
|
bestResult: form.bestResult || null,
|
||||||
|
description: form.description || null,
|
||||||
|
};
|
||||||
const res = await fetch(countryId ? `/api/countries/${countryId}` : "/api/countries", {
|
const res = await fetch(countryId ? `/api/countries/${countryId}` : "/api/countries", {
|
||||||
method: countryId ? "PUT" : "POST",
|
method: countryId ? "PUT" : "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (res.ok) { router.push("/admin/countries"); router.refresh(); }
|
if (res.ok) {
|
||||||
|
router.push("/admin/countries");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4 max-w-3xl">
|
||||||
<div>
|
<h3 className="text-lg font-bold mb-2">اطلاعات پایه</h3>
|
||||||
<label className="block text-sm font-medium mb-1">نام تیم</label>
|
|
||||||
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required />
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">نام تیم *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">کد (مثلاً IRN) *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })}
|
||||||
|
maxLength={3}
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">کد (مثلاً IRN)</label>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<input type="text" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value.toUpperCase() })}
|
<div>
|
||||||
maxLength={3}
|
<label className="block text-sm font-medium mb-1">ایموجی پرچم</label>
|
||||||
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" required />
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.flagUrl}
|
||||||
|
onChange={(e) => setForm({ ...form, flagUrl: e.target.value })}
|
||||||
|
placeholder="🇮🇷"
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">نام فایل پرچم</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.flagImage}
|
||||||
|
onChange={(e) => setForm({ ...form, flagImage: e.target.value })}
|
||||||
|
placeholder="Flag_of_Iran.webp"
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">فایل باید در public/imgs/flags باشد</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">ایموجی پرچم (اختیاری)</label>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<input type="text" value={form.flagUrl} onChange={(e) => setForm({ ...form, flagUrl: e.target.value })}
|
<div>
|
||||||
placeholder="🇮🇷"
|
<label className="block text-sm font-medium mb-1">گروه</label>
|
||||||
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500" />
|
<select
|
||||||
|
value={form.groupId}
|
||||||
|
onChange={(e) => setForm({ ...form, groupId: e.target.value })}
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
<option value="">بدون گروه</option>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
گروه {g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">کنفدراسیون</label>
|
||||||
|
<select
|
||||||
|
value={form.confederation}
|
||||||
|
onChange={(e) => setForm({ ...form, confederation: e.target.value })}
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
<option value="">انتخاب کنید</option>
|
||||||
|
<option value="UEFA">UEFA (اروپا)</option>
|
||||||
|
<option value="AFC">AFC (آسیا)</option>
|
||||||
|
<option value="CAF">CAF (آفریقا)</option>
|
||||||
|
<option value="CONMEBOL">CONMEBOL (آمریکای جنوبی)</option>
|
||||||
|
<option value="CONCACAF">CONCACAF (آمریکای شمالی)</option>
|
||||||
|
<option value="OFC">OFC (اقیانوسیه)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">گروه</label>
|
<hr className="my-2" />
|
||||||
<select value={form.groupId} onChange={(e) => setForm({ ...form, groupId: e.target.value })}
|
<h3 className="text-lg font-bold mb-2">اطلاعات راهیابی</h3>
|
||||||
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500">
|
|
||||||
<option value="">بدون گروه</option>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{groups.map((g) => <option key={g.id} value={g.id}>گروه {g.name}</option>)}
|
<div>
|
||||||
</select>
|
<label className="block text-sm font-medium mb-1">شیوه راهیابی</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.qualificationMethod}
|
||||||
|
onChange={(e) => setForm({ ...form, qualificationMethod: e.target.value })}
|
||||||
|
placeholder="مثلاً: صعود از مرحله مقدماتی"
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">تاریخ راهیابی</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.qualificationDate}
|
||||||
|
onChange={(e) => setForm({ ...form, qualificationDate: e.target.value })}
|
||||||
|
placeholder="مثلاً: ۲۵ مارس ۲۰۲۵"
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={loading}
|
|
||||||
className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50">
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">سابقه شرکت در مسابقات</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.participationHistory}
|
||||||
|
onChange={(e) => setForm({ ...form, participationHistory: e.target.value })}
|
||||||
|
placeholder="مثلاً: ۶ (۱۹۷۸، ۱۹۹۸، ۲۰۰۶، ۲۰۱۴، ۲۰۱۸، ۲۰۲۲)"
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">بهترین نتیجه در دورههای گذشته</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.bestResult}
|
||||||
|
onChange={(e) => setForm({ ...form, bestResult: e.target.value })}
|
||||||
|
placeholder="مثلاً: مرحله گروهی"
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">توضیحات</label>
|
||||||
|
<textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
placeholder="توضیحات کامل درباره تیم..."
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
{loading ? "در حال ذخیره..." : countryId ? "ذخیره تغییرات" : "افزودن تیم"}
|
{loading ? "در حال ذخیره..." : countryId ? "ذخیره تغییرات" : "افزودن تیم"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { db } from "@/lib/db";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import CountryForm from "../../CountryForm";
|
import CountryForm from "../../CountryForm";
|
||||||
|
|
||||||
export default async function EditCountryPage({ params }: { params: { id: string } }) {
|
export default async function EditCountryPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const [country, groups] = await Promise.all([
|
const [country, groups] = await Promise.all([
|
||||||
db.country.findUnique({ where: { id: params.id } }),
|
db.country.findUnique({ where: { id } }),
|
||||||
db.group.findMany({ orderBy: { name: "asc" } }),
|
db.group.findMany({ orderBy: { name: "asc" } }),
|
||||||
]);
|
]);
|
||||||
if (!country) notFound();
|
if (!country) notFound();
|
||||||
|
|||||||
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 { db } from "@/lib/db";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import CountryFlag from "@/components/CountryFlag";
|
||||||
|
|
||||||
export default async function AdminCountriesPage() {
|
export default async function AdminCountriesPage() {
|
||||||
const countries = await db.country.findMany({
|
const countries = await db.country.findMany({
|
||||||
@@ -18,23 +19,39 @@ export default async function AdminCountriesPage() {
|
|||||||
+ تیم جدید
|
+ تیم جدید
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{countries.map((c) => (
|
{countries.map((c) => (
|
||||||
<div key={c.id} className="bg-white rounded-2xl shadow p-5 flex items-center justify-between">
|
<div key={c.id} className="bg-white rounded-2xl shadow p-5 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center text-2xl">
|
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
|
||||||
{c.flagUrl ?? "🏳️"}
|
<CountryFlag
|
||||||
|
flagImage={c.flagImage}
|
||||||
|
flagEmoji={c.flagUrl}
|
||||||
|
countryName={c.name}
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-bold">{c.name}</div>
|
<div className="font-bold">{c.name}</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{c.code} · گروه {c.group?.name ?? "-"} · {c._count.players} بازیکن
|
{c.code} ·
|
||||||
|
{c.confederation && ` ${c.confederation} · `}
|
||||||
|
گروه {c.group?.name ?? "-"} · {c._count.players} بازیکن ·
|
||||||
|
ترکیب: {c.defaultFormation} ·
|
||||||
|
{(c.defaultLineupPlayerIds?.length ?? 0) > 0 ? `✓ ${c.defaultLineupPlayerIds.length} بازیکن` : "بدون ترکیب"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/admin/countries/${c.id}/edit`} className="text-blue-600 hover:underline text-sm">
|
<div className="flex gap-2">
|
||||||
ویرایش
|
<Link href={`/admin/countries/${c.id}/lineup`}
|
||||||
</Link>
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition font-medium">
|
||||||
|
ترکیب پیشفرض
|
||||||
|
</Link>
|
||||||
|
<Link href={`/admin/countries/${c.id}/edit`}
|
||||||
|
className="bg-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm hover:bg-gray-300 transition font-medium">
|
||||||
|
ویرایش
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { db } from "@/lib/db";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import MatchForm from "../../MatchForm";
|
import MatchForm from "../../MatchForm";
|
||||||
|
|
||||||
export default async function EditMatchPage({ params }: { params: { id: string } }) {
|
export default async function EditMatchPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const [match, countries, rounds] = await Promise.all([
|
const [match, countries, rounds] = await Promise.all([
|
||||||
db.match.findUnique({ where: { id: params.id } }),
|
db.match.findUnique({ where: { id } }),
|
||||||
db.country.findMany({ orderBy: { name: "asc" } }),
|
db.country.findMany({ orderBy: { name: "asc" } }),
|
||||||
db.round.findMany({ orderBy: { number: "asc" } }),
|
db.round.findMany({ orderBy: { number: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
type Country = { id: string; name: string };
|
type Country = { id: string; name: string };
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ export default function PlayerForm({
|
|||||||
playerId,
|
playerId,
|
||||||
}: {
|
}: {
|
||||||
countries: Country[];
|
countries: Country[];
|
||||||
initial?: { name: string; position: string; countryId: string; price: number };
|
initial?: { name: string; position: string; countryId: string; price: number; image?: string | null };
|
||||||
playerId?: string;
|
playerId?: string;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,10 +21,42 @@ export default function PlayerForm({
|
|||||||
position: initial?.position ?? "FWD",
|
position: initial?.position ?? "FWD",
|
||||||
countryId: initial?.countryId ?? "",
|
countryId: initial?.countryId ?? "",
|
||||||
price: initial?.price ?? 5.0,
|
price: initial?.price ?? 5.0,
|
||||||
|
image: initial?.image ?? "",
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/upload/player-image", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setForm({ ...form, image: data.fileName });
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
setError(data.error ?? "خطا در آپلود تصویر");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("خطا در آپلود تصویر");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -45,6 +78,33 @@ export default function PlayerForm({
|
|||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow p-6 flex flex-col gap-4">
|
||||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">تصویر بازیکن</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{form.image && (
|
||||||
|
<div className="relative w-24 h-24 rounded-xl overflow-hidden border">
|
||||||
|
<Image
|
||||||
|
src={`/uploads/players/${form.image}`}
|
||||||
|
alt={form.name}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="w-full border rounded-xl px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
/>
|
||||||
|
{uploading && <p className="text-sm text-gray-500 mt-1">در حال آپلود...</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">نام بازیکن</label>
|
<label className="block text-sm font-medium mb-1">نام بازیکن</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { notFound } from "next/navigation";
|
|||||||
import PlayerForm from "../../PlayerForm";
|
import PlayerForm from "../../PlayerForm";
|
||||||
import DeleteButton from "./DeleteButton";
|
import DeleteButton from "./DeleteButton";
|
||||||
|
|
||||||
export default async function EditPlayerPage({ params }: { params: { id: string } }) {
|
export default async function EditPlayerPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const [player, countries] = await Promise.all([
|
const [player, countries] = await Promise.all([
|
||||||
db.player.findUnique({ where: { id: params.id } }),
|
db.player.findUnique({ where: { id } }),
|
||||||
db.country.findMany({ orderBy: { name: "asc" } }),
|
db.country.findMany({ orderBy: { name: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ export default async function EditPlayerPage({ params }: { params: { id: string
|
|||||||
position: player.position,
|
position: player.position,
|
||||||
countryId: player.countryId,
|
countryId: player.countryId,
|
||||||
price: player.price,
|
price: player.price,
|
||||||
|
image: player.image,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,19 +7,21 @@ export default function ActivateRoundButton({ roundId, isActive }: { roundId: st
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
async function activate() {
|
async function toggleActivation() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await fetch(`/api/rounds/${roundId}/activate`, { method: "POST" });
|
await fetch(`/api/rounds/${roundId}/activate`, { method: "POST" });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isActive) return <span className="text-xs text-green-600 font-medium px-3 py-1.5">✓ فعال</span>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button onClick={activate} disabled={loading}
|
<button onClick={toggleActivation} disabled={loading}
|
||||||
className="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition disabled:opacity-50">
|
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50 ${
|
||||||
{loading ? "..." : "فعالسازی"}
|
isActive
|
||||||
|
? "bg-gray-200 text-gray-600 hover:bg-gray-300"
|
||||||
|
: "bg-blue-600 text-white hover:bg-blue-700"
|
||||||
|
}`}>
|
||||||
|
{loading ? "..." : isActive ? "غیرفعال" : "فعالسازی"}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function RoundForm() {
|
type Round = {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
name: string;
|
||||||
|
deadline: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RoundForm({ editRound }: { editRound?: Round }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [form, setForm] = useState({ number: "", name: "", deadline: "" });
|
const [form, setForm] = useState({
|
||||||
|
number: editRound?.number.toString() ?? "",
|
||||||
|
name: editRound?.name ?? "",
|
||||||
|
deadline: editRound ? new Date(editRound.deadline).toISOString().slice(0, 16) : "",
|
||||||
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -13,14 +24,22 @@ export default function RoundForm() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
|
const method = editRound ? "PUT" : "POST";
|
||||||
|
const body = editRound
|
||||||
|
? { id: editRound.id, ...form, number: parseInt(form.number) }
|
||||||
|
: { ...form, number: parseInt(form.number) };
|
||||||
|
|
||||||
const res = await fetch("/api/rounds", {
|
const res = await fetch("/api/rounds", {
|
||||||
method: "POST",
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...form, number: parseInt(form.number) }),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setForm({ number: "", name: "", deadline: "" });
|
setForm({ number: "", name: "", deadline: "" });
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
router.push("/admin/rounds");
|
||||||
} else {
|
} else {
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
setError(d.error ?? "خطا در ذخیره");
|
setError(d.error ?? "خطا در ذخیره");
|
||||||
@@ -55,7 +74,7 @@ export default function RoundForm() {
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" disabled={loading}
|
<button type="submit" disabled={loading}
|
||||||
className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50">
|
className="bg-green-700 text-white py-3 rounded-xl font-bold hover:bg-green-800 transition disabled:opacity-50">
|
||||||
{loading ? "در حال ذخیره..." : "افزودن دور"}
|
{loading ? "در حال ذخیره..." : editRound ? "ویرایش دور" : "افزودن دور"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
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 { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import MatchEventManager from "./MatchEventManager";
|
import MatchEventManager from "./MatchEventManager";
|
||||||
|
import MatchLineupManager from "./MatchLineupManager";
|
||||||
|
|
||||||
export default async function MatchDetailPage({ params }: { params: { id: string; matchId: string } }) {
|
export default async function MatchDetailPage({ params }: { params: Promise<{ id: string; matchId: string }> }) {
|
||||||
|
const { id, matchId } = await params;
|
||||||
const match = await db.match.findUnique({
|
const match = await db.match.findUnique({
|
||||||
where: { id: params.matchId },
|
where: { id: matchId },
|
||||||
include: {
|
include: {
|
||||||
homeTeam: { include: { players: { orderBy: { position: "asc" } } } },
|
homeTeam: { include: { players: { where: { isActive: true }, orderBy: { position: "asc" } } } },
|
||||||
awayTeam: { include: { players: { orderBy: { position: "asc" } } } },
|
awayTeam: { include: { players: { where: { isActive: true }, orderBy: { position: "asc" } } } },
|
||||||
events: { include: { player: true }, orderBy: { minute: "asc" } },
|
events: { include: { player: true }, orderBy: { minute: "asc" } },
|
||||||
lineups: true,
|
lineups: true,
|
||||||
playerStats: { include: { player: true } },
|
playerStats: { include: { player: true } },
|
||||||
@@ -18,14 +20,19 @@ export default async function MatchDetailPage({ params }: { params: { id: string
|
|||||||
if (!match) notFound();
|
if (!match) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3">
|
||||||
<Link href={`/admin/rounds/${params.id}`} className="text-gray-400 hover:text-gray-600">← دور</Link>
|
<Link href={`/admin/rounds/${id}`} className="text-gray-400 hover:text-gray-600">← دور</Link>
|
||||||
<h1 className="text-xl font-bold">
|
<h1 className="text-xl font-bold">
|
||||||
{match.homeTeam.name} {match.homeScore ?? "-"} - {match.awayScore ?? "-"} {match.awayTeam.name}
|
{match.homeTeam.name} {match.homeScore ?? "-"} - {match.awayScore ?? "-"} {match.awayTeam.name}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<MatchEventManager match={match} roundId={params.id} />
|
|
||||||
|
{/* مدیریت ترکیب */}
|
||||||
|
<MatchLineupManager match={match} />
|
||||||
|
|
||||||
|
{/* مدیریت رویدادها */}
|
||||||
|
<MatchEventManager match={match} roundId={id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import DeleteMatchButton from "./DeleteMatchButton";
|
||||||
|
|
||||||
const statusStyle: Record<string, string> = {
|
const statusStyle: Record<string, string> = {
|
||||||
SCHEDULED: "bg-gray-100 text-gray-600",
|
SCHEDULED: "bg-gray-100 text-gray-600",
|
||||||
@@ -9,9 +10,10 @@ const statusStyle: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
const statusLabel: Record<string, string> = { SCHEDULED: "برنامه", LIVE: "🔴 زنده", FINISHED: "پایان" };
|
const statusLabel: Record<string, string> = { SCHEDULED: "برنامه", LIVE: "🔴 زنده", FINISHED: "پایان" };
|
||||||
|
|
||||||
export default async function RoundDetailPage({ params }: { params: { id: string } }) {
|
export default async function RoundDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const round = await db.round.findUnique({
|
const round = await db.round.findUnique({
|
||||||
where: { id: params.id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
matches: {
|
matches: {
|
||||||
include: {
|
include: {
|
||||||
@@ -27,10 +29,16 @@ export default async function RoundDetailPage({ params }: { params: { id: string
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<Link href="/admin/rounds" className="text-gray-400 hover:text-gray-600">← دورها</Link>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-bold">{round.name}</h1>
|
<Link href="/admin/rounds" className="text-gray-400 hover:text-gray-600">← دورها</Link>
|
||||||
{round.isActive && <span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">فعال</span>}
|
<h1 className="text-2xl font-bold">{round.name}</h1>
|
||||||
|
{round.isActive && <span className="bg-green-100 text-green-700 text-xs px-2 py-1 rounded-full">فعال</span>}
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/matches/new"
|
||||||
|
className="bg-green-700 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-green-800 transition">
|
||||||
|
+ افزودن بازی
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -39,7 +47,7 @@ export default async function RoundDetailPage({ params }: { params: { id: string
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4 flex-1 justify-end">
|
<div className="flex items-center gap-4 flex-1 justify-end">
|
||||||
<span className="font-bold">{m.homeTeam.name}</span>
|
<span className="font-bold">{m.homeTeam.name}</span>
|
||||||
<span>{m.homeTeam.flagUrl}</span>
|
<span className="text-2xl">{m.homeTeam.flagUrl}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-6 text-center min-w-[120px]">
|
<div className="mx-6 text-center min-w-[120px]">
|
||||||
{m.status !== "SCHEDULED" ? (
|
{m.status !== "SCHEDULED" ? (
|
||||||
@@ -52,22 +60,36 @@ export default async function RoundDetailPage({ params }: { params: { id: string
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 flex-1 justify-start">
|
<div className="flex items-center gap-4 flex-1 justify-start">
|
||||||
<span>{m.awayTeam.flagUrl}</span>
|
<span className="text-2xl">{m.awayTeam.flagUrl}</span>
|
||||||
<span className="font-bold">{m.awayTeam.name}</span>
|
<span className="font-bold">{m.awayTeam.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 mr-4">
|
<div className="flex gap-2 mr-4 items-center">
|
||||||
<div className="text-xs text-gray-400 text-center">
|
<div className="text-xs text-gray-400 text-center mr-2">
|
||||||
<div>{m._count.events} رویداد</div>
|
<div>{m._count.events} رویداد</div>
|
||||||
<div>{m._count.lineups > 0 ? "✓ ترکیب" : "بدون ترکیب"}</div>
|
<div>{m._count.lineups > 0 ? "✓ ترکیب" : "بدون ترکیب"}</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/admin/rounds/${round.id}/match/${m.id}`}
|
<Link href={`/admin/rounds/${id}/match/${m.id}`}
|
||||||
className="bg-green-700 text-white px-4 py-2 rounded-xl text-sm font-medium hover:bg-green-800 transition">
|
className="bg-green-700 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-green-800 transition">
|
||||||
جزئیات
|
جزئیات
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={`/admin/matches/${m.id}/edit`}
|
||||||
|
className="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-blue-700 transition">
|
||||||
|
ویرایش
|
||||||
|
</Link>
|
||||||
|
<DeleteMatchButton matchId={m.id} hasEvents={m._count.events > 0} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{round.matches.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-400">
|
||||||
|
<p className="mb-4">هنوز بازیای برای این دور ثبت نشده</p>
|
||||||
|
<Link href="/admin/matches/new"
|
||||||
|
className="inline-block bg-green-700 text-white px-6 py-3 rounded-xl font-bold hover:bg-green-800 transition">
|
||||||
|
افزودن اولین بازی
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db } from "@/lib/db";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import RoundForm from "./RoundForm";
|
import RoundForm from "./RoundForm";
|
||||||
import ActivateRoundButton from "./ActivateRoundButton";
|
import ActivateRoundButton from "./ActivateRoundButton";
|
||||||
|
import DeleteRoundButton from "./DeleteRoundButton";
|
||||||
|
|
||||||
export default async function AdminRoundsPage() {
|
export default async function AdminRoundsPage() {
|
||||||
const rounds = await db.round.findMany({
|
const rounds = await db.round.findMany({
|
||||||
@@ -17,7 +18,7 @@ export default async function AdminRoundsPage() {
|
|||||||
{rounds.map((r) => (
|
{rounds.map((r) => (
|
||||||
<div key={r.id} className={`bg-white rounded-2xl shadow p-5 border-2 ${r.isActive ? "border-green-500" : "border-transparent"}`}>
|
<div key={r.id} className={`bg-white rounded-2xl shadow p-5 border-2 ${r.isActive ? "border-green-500" : "border-transparent"}`}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<div className="font-bold flex items-center gap-2">
|
<div className="font-bold flex items-center gap-2">
|
||||||
دور {r.number} - {r.name}
|
دور {r.number} - {r.name}
|
||||||
{r.isActive && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">فعال</span>}
|
{r.isActive && <span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">فعال</span>}
|
||||||
@@ -32,6 +33,11 @@ export default async function AdminRoundsPage() {
|
|||||||
className="bg-green-700 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-green-800 transition">
|
className="bg-green-700 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-green-800 transition">
|
||||||
بازیها
|
بازیها
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href={`/admin/rounds/${r.id}/edit`}
|
||||||
|
className="bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700 transition">
|
||||||
|
ویرایش
|
||||||
|
</Link>
|
||||||
|
<DeleteRoundButton roundId={r.id} hasMatches={r._count.matches > 0} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 { useState, useRef } from "react";
|
||||||
import PositionBadge from "@/components/PositionBadge";
|
import PositionBadge from "@/components/PositionBadge";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
type Player = {
|
type Player = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
image: string | null;
|
||||||
position: string;
|
position: string;
|
||||||
price: number;
|
price: number;
|
||||||
totalPoints: number;
|
totalPoints: number;
|
||||||
@@ -346,42 +348,56 @@ export default function TeamBuilder({
|
|||||||
onChange={(e) => setFilter(e.target.value)}
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
className="w-full border rounded-xl px-4 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-green-400" />
|
className="w-full border rounded-xl px-4 py-2 text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-green-400" />
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow overflow-hidden" style={{ maxHeight: 520, overflowY: "auto" }}>
|
<div className="bg-white rounded-2xl shadow p-4" style={{ maxHeight: 520, overflowY: "auto" }}>
|
||||||
<table className="w-full text-sm">
|
<div className="flex gap-3 flex-wrap">
|
||||||
<thead className="bg-green-800 text-white sticky top-0">
|
{filtered.map((p) => (
|
||||||
<tr>
|
<div
|
||||||
<th className="text-right px-3 py-3">بازیکن</th>
|
key={p.id}
|
||||||
<th className="px-2 py-3">قیمت</th>
|
draggable
|
||||||
<th className="px-2 py-3">pts</th>
|
onDragStart={() => setDraggedId(p.id)}
|
||||||
<th className="px-2 py-3"></th>
|
className="flex-shrink-0 bg-gray-50 rounded-xl p-2 cursor-move hover:bg-gray-100 transition border-2 border-transparent hover:border-green-500"
|
||||||
</tr>
|
style={{ width: "90px" }}
|
||||||
</thead>
|
>
|
||||||
<tbody>
|
<div className="relative w-20 h-20 rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto">
|
||||||
{filtered.map((p) => (
|
{p.image ? (
|
||||||
<tr key={p.id} className="border-t hover:bg-green-50 transition">
|
<Image
|
||||||
<td className="px-3 py-2">
|
src={`/uploads/players/${p.image}`}
|
||||||
<div className="font-medium text-sm">{p.name}</div>
|
alt={p.name}
|
||||||
<div className="flex items-center gap-1 mt-0.5">
|
fill
|
||||||
<span className="text-xs text-gray-400">{p.country.flagUrl} {p.country.name}</span>
|
className="object-cover"
|
||||||
<PositionBadge position={p.position} />
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-400 text-3xl">
|
||||||
|
👤
|
||||||
</div>
|
</div>
|
||||||
</td>
|
)}
|
||||||
<td className="px-2 py-2 text-center text-green-700 font-bold text-xs">{p.price}M</td>
|
</div>
|
||||||
<td className="px-2 py-2 text-center text-blue-700 font-bold text-xs">{p.totalPoints}</td>
|
<div className="text-[10px] font-bold text-gray-800 text-center leading-tight mb-1">
|
||||||
<td className="px-2 py-2">
|
{p.name.split(" ").slice(-1)[0]}
|
||||||
<button onClick={() => addPlayer(p.id)}
|
</div>
|
||||||
disabled={loading || p.price > remaining + 0.01}
|
<div className="flex items-center justify-center gap-1 mb-1">
|
||||||
className="bg-green-600 text-white w-7 h-7 rounded-lg text-lg font-bold hover:bg-green-700 disabled:opacity-30 transition flex items-center justify-center">
|
<span className="text-xs">{p.country.flagUrl}</span>
|
||||||
+
|
<PositionBadge position={p.position} />
|
||||||
</button>
|
</div>
|
||||||
</td>
|
<div className="text-[9px] text-center text-gray-600 mb-2">
|
||||||
</tr>
|
<span className="text-green-700 font-bold">{p.price}M</span>
|
||||||
))}
|
<span className="mx-1">·</span>
|
||||||
{filtered.length === 0 && (
|
<span className="text-blue-700 font-bold">{p.totalPoints}pts</span>
|
||||||
<tr><td colSpan={4} className="text-center text-gray-400 py-8">بازیکنی پیدا نشد</td></tr>
|
</div>
|
||||||
)}
|
<button
|
||||||
</tbody>
|
onClick={() => addPlayer(p.id)}
|
||||||
</table>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
disabled={loading || p.price > remaining + 0.01}
|
||||||
|
className="w-full bg-green-600 text-white text-xs py-1 rounded-lg hover:bg-green-700 transition disabled:opacity-30 font-bold"
|
||||||
|
>
|
||||||
|
+ افزودن
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="w-full text-center text-gray-400 py-8">بازیکنی پیدا نشد</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -418,42 +434,79 @@ function PitchCard({ tp, onRemove, onDragStart, onDrop, onCaptain, draggedId, sm
|
|||||||
const [showMenu, setShowMenu] = useState(false);
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
const isDragging = draggedId === tp.playerId;
|
const isDragging = draggedId === tp.playerId;
|
||||||
const isEliminated = (tp.player as any).country?.isEliminated;
|
const isEliminated = (tp.player as any).country?.isEliminated;
|
||||||
const color = isEliminated
|
|
||||||
? "bg-gray-500 text-gray-300 border-gray-600"
|
|
||||||
: POS_COLORS[tp.player.position] ?? "bg-gray-400 text-white border-gray-500";
|
|
||||||
const shortName = tp.player.name.split(" ").slice(-1)[0];
|
const shortName = tp.player.name.split(" ").slice(-1)[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative flex flex-col items-center gap-1 cursor-grab select-none transition-opacity ${isDragging ? "opacity-40" : ""} ${small ? "w-14" : "w-16"} group`}
|
<div
|
||||||
|
className={`relative group ${isDragging ? "opacity-50" : ""}`}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={() => onDragStart(tp.playerId)}
|
onDragStart={() => onDragStart(tp.playerId)}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={() => onDrop(tp.playerId)}
|
onDrop={() => onDrop(tp.playerId)}
|
||||||
onClick={() => setShowMenu((v) => !v)}>
|
>
|
||||||
<div className={`relative ${small ? "w-11 h-11 text-lg" : "w-14 h-14 text-xl"} rounded-full border-2 flex items-center justify-center font-bold shadow-lg ${color} ${isEliminated ? "grayscale opacity-60" : ""}`}>
|
<div className={`bg-white/95 rounded-xl p-2 cursor-move hover:bg-white transition shadow-lg ${small ? "w-16" : "w-20"}`}>
|
||||||
{tp.player.position === "GK" ? "🧤" : tp.player.position === "DEF" ? "🛡️" : tp.player.position === "MID" ? "⚙️" : "⚡"}
|
<div className={`relative ${small ? "w-12 h-12" : "w-16 h-16"} rounded-lg overflow-hidden bg-gray-200 mb-1 mx-auto`}>
|
||||||
{isEliminated && <div className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full flex items-center justify-center text-white text-[8px] font-bold">✕</div>}
|
{tp.player.image ? (
|
||||||
</div>
|
<Image
|
||||||
<div className={`text-center font-medium leading-tight truncate w-full ${small ? "text-[9px]" : "text-[10px]"} ${isEliminated ? "text-gray-400" : "text-white"}`}>
|
src={`/uploads/players/${tp.player.image}`}
|
||||||
{shortName}
|
alt={tp.player.name}
|
||||||
</div>
|
fill
|
||||||
<div className="flex items-center gap-1">
|
className="object-cover"
|
||||||
{tp.isCaptain && <span className="text-yellow-300 text-xs font-bold">©</span>}
|
/>
|
||||||
{tp.isViceCaptain && <span className="text-gray-300 text-xs font-bold">VC</span>}
|
) : (
|
||||||
<span className={`text-[9px] ${isEliminated ? "text-gray-500" : "text-white/60"}`}>{tp.player.totalPoints}pts</span>
|
<div className="w-full h-full flex items-center justify-center text-gray-400 text-2xl">
|
||||||
</div>
|
👤
|
||||||
{isEliminated && (
|
</div>
|
||||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-gray-900 text-white text-[9px] px-2 py-1 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none z-50 transition-opacity">
|
)}
|
||||||
تیم ملی حذف شده
|
{isEliminated && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
|
<span className="text-white text-xs font-bold">✕</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{showMenu && (
|
<div className={`text-[10px] font-bold text-gray-800 text-center leading-tight ${isEliminated ? "opacity-50" : ""}`}>
|
||||||
<div className="absolute top-full mt-1 bg-white rounded-xl shadow-xl z-50 text-xs w-36 overflow-hidden border" onClick={(e) => e.stopPropagation()}>
|
{shortName}
|
||||||
{isEliminated && <div className="px-3 py-2 bg-red-50 text-red-600 text-[10px] border-b">⚠️ تیم ملی حذف شده</div>}
|
|
||||||
<button onClick={() => { onCaptain(tp.playerId, "captain"); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-gray-50 border-b">کاپیتان ©</button>
|
|
||||||
<button onClick={() => { onCaptain(tp.playerId, "vice"); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-gray-50 border-b">نایب کاپیتان VC</button>
|
|
||||||
<button onClick={() => { onRemove(tp.playerId); setShowMenu(false); }} className="w-full text-right px-3 py-2 hover:bg-red-50 text-red-600">حذف از تیم</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="flex items-center justify-center gap-1 mt-1">
|
||||||
|
{tp.isCaptain && (
|
||||||
|
<div className="bg-yellow-400 text-yellow-900 rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">
|
||||||
|
C
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tp.isViceCaptain && (
|
||||||
|
<div className="bg-gray-400 text-white rounded-full w-4 h-4 flex items-center justify-center text-[8px] font-bold">
|
||||||
|
V
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[8px] text-center text-gray-600 mt-1">
|
||||||
|
{tp.player.totalPoints}pts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute -bottom-8 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition flex gap-1 z-20">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onCaptain(tp.playerId, tp.isCaptain ? "vice" : "captain");
|
||||||
|
}}
|
||||||
|
className="bg-yellow-400 text-yellow-900 text-[8px] px-2 py-0.5 rounded-full font-bold whitespace-nowrap shadow"
|
||||||
|
>
|
||||||
|
{tp.isCaptain ? "VC" : "C"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(tp.playerId);
|
||||||
|
}}
|
||||||
|
className="bg-red-500 text-white text-[8px] px-2 py-0.5 rounded-full font-bold shadow"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { getServerSession } from "next-auth";
|
|||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
import { calculateMatchPoints } from "@/lib/points";
|
import { calculateMatchPoints } from "@/lib/points";
|
||||||
|
|
||||||
export async function POST(_: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const events = await db.matchEvent.findMany({
|
const events = await db.matchEvent.findMany({
|
||||||
where: { matchId: params.id },
|
where: { matchId: id },
|
||||||
include: { player: true },
|
include: { player: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { db } from "@/lib/db";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
export async function DELETE(_: NextRequest, { params }: { params: { id: string; eventId: string } }) {
|
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string; eventId: string }> }) {
|
||||||
|
const { eventId } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
await db.matchEvent.delete({ where: { id: params.eventId } });
|
await db.matchEvent.delete({ where: { id: eventId } });
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -11,7 +12,7 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
|||||||
const { playerId, type, minute, extraInfo } = await req.json();
|
const { playerId, type, minute, extraInfo } = await req.json();
|
||||||
|
|
||||||
const event = await db.matchEvent.create({
|
const event = await db.matchEvent.create({
|
||||||
data: { matchId: params.id, playerId, type, minute: minute ?? null, extraInfo: extraInfo || null },
|
data: { matchId: id, playerId, type, minute: minute ?? null, extraInfo: extraInfo || null },
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(event, { status: 201 });
|
return NextResponse.json(event, { status: 201 });
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -11,11 +12,11 @@ export async function POST(req: NextRequest, { params }: { params: { id: string
|
|||||||
const lineups: Array<{ countryId: string; formation: string; playerIds: string[] }> = await req.json();
|
const lineups: Array<{ countryId: string; formation: string; playerIds: string[] }> = await req.json();
|
||||||
|
|
||||||
// حذف ترکیبهای قبلی
|
// حذف ترکیبهای قبلی
|
||||||
await db.matchLineup.deleteMany({ where: { matchId: params.id } });
|
await db.matchLineup.deleteMany({ where: { matchId: id } });
|
||||||
|
|
||||||
for (const l of lineups) {
|
for (const l of lineups) {
|
||||||
await db.matchLineup.create({
|
await db.matchLineup.create({
|
||||||
data: { matchId: params.id, countryId: l.countryId, formation: l.formation, playerIds: l.playerIds },
|
data: { matchId: id, countryId: l.countryId, formation: l.formation, playerIds: l.playerIds },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ import { db } from "@/lib/db";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const country = await db.country.update({ where: { id: params.id }, data: body });
|
const country = await db.country.update({ where: { id }, data: body });
|
||||||
return NextResponse.json(country);
|
return NextResponse.json(country);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) {
|
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
await db.country.delete({ where: { id: params.id } });
|
await db.country.delete({ where: { id } });
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,30 +3,33 @@ import { db } from "@/lib/db";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
export async function GET(_: NextRequest, { params }: { params: { id: string } }) {
|
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const match = await db.match.findUnique({
|
const match = await db.match.findUnique({
|
||||||
where: { id: params.id },
|
where: { id },
|
||||||
include: { homeTeam: true, awayTeam: true, playerStats: { include: { player: true } } },
|
include: { homeTeam: true, awayTeam: true, playerStats: { include: { player: true } } },
|
||||||
});
|
});
|
||||||
if (!match) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
if (!match) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
return NextResponse.json(match);
|
return NextResponse.json(match);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const match = await db.match.update({ where: { id: params.id }, data: body });
|
const match = await db.match.update({ where: { id }, data: body });
|
||||||
return NextResponse.json(match);
|
return NextResponse.json(match);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(_: NextRequest, { params }: { params: { id: string } }) {
|
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
await db.match.delete({ where: { id: params.id } });
|
await db.match.delete({ where: { id } });
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { db } from "@/lib/db";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN") {
|
if (!session || (session.user as any).role !== "ADMIN") {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -11,18 +12,19 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
|
|||||||
|
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const player = await db.player.update({
|
const player = await db.player.update({
|
||||||
where: { id: params.id },
|
where: { id },
|
||||||
data: body,
|
data: body,
|
||||||
});
|
});
|
||||||
return NextResponse.json(player);
|
return NextResponse.json(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN") {
|
if (!session || (session.user as any).role !== "ADMIN") {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.player.delete({ where: { id: params.id } });
|
await db.player.delete({ where: { id } });
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,32 @@ import { db } from "@/lib/db";
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
|
||||||
export async function POST(_: NextRequest, { params }: { params: { id: string } }) {
|
export async function POST(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || (session.user as any).role !== "ADMIN")
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const currentRound = await db.round.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!currentRound) {
|
||||||
|
return NextResponse.json({ error: "Round not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// اگه فعاله، غیرفعالش کن
|
||||||
|
if (currentRound.isActive) {
|
||||||
|
const round = await db.round.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: false }
|
||||||
|
});
|
||||||
|
return NextResponse.json(round);
|
||||||
|
}
|
||||||
|
|
||||||
|
// اگه غیرفعاله، همه رو غیرفعال کن و این رو فعال کن
|
||||||
await db.round.updateMany({ data: { isActive: false } });
|
await db.round.updateMany({ data: { isActive: false } });
|
||||||
const round = await db.round.update({ where: { id: params.id }, data: { isActive: true } });
|
const round = await db.round.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: true }
|
||||||
|
});
|
||||||
return NextResponse.json(round);
|
return NextResponse.json(round);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,34 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
return NextResponse.json(round, { status: 201 });
|
return NextResponse.json(round, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id, number, name, deadline } = await req.json();
|
||||||
|
|
||||||
|
const round = await db.round.update({
|
||||||
|
where: { id },
|
||||||
|
data: { number, name, deadline: new Date(deadline) },
|
||||||
|
});
|
||||||
|
return NextResponse.json(round);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session || (session.user as any).role !== "ADMIN")
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await req.json();
|
||||||
|
|
||||||
|
// چک کنیم که بازی نداشته باشه
|
||||||
|
const matchCount = await db.match.count({ where: { roundId: id } });
|
||||||
|
if (matchCount > 0) {
|
||||||
|
return NextResponse.json({ error: "این دور دارای بازی است و قابل حذف نیست" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.round.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,12 +23,22 @@ export async function POST(req: NextRequest) {
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
const { name } = await req.json();
|
const { name, formation } = await req.json();
|
||||||
const userId = (session.user as any).id;
|
const userId = (session.user as any).id;
|
||||||
|
|
||||||
|
// بررسی وجود کاربر
|
||||||
|
const user = await db.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
|
||||||
const existing = await db.team.findUnique({ where: { userId } });
|
const existing = await db.team.findUnique({ where: { userId } });
|
||||||
if (existing) return NextResponse.json({ error: "Team already exists" }, { status: 400 });
|
if (existing) return NextResponse.json({ error: "Team already exists" }, { status: 400 });
|
||||||
|
|
||||||
const team = await db.team.create({ data: { name, userId } });
|
const team = await db.team.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
userId,
|
||||||
|
formation: formation || "4-3-3"
|
||||||
|
}
|
||||||
|
});
|
||||||
return NextResponse.json(team, { status: 201 });
|
return NextResponse.json(team, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
@@ -26,7 +29,8 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -131,6 +135,59 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@emnapi/runtime/-/runtime-1.9.2.tgz",
|
||||||
@@ -141,6 +198,448 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@img/colour": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/@img/colour/-/colour-1.1.0.tgz",
|
||||||
@@ -1654,6 +2153,48 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.7",
|
||||||
|
"@esbuild/android-arm": "0.27.7",
|
||||||
|
"@esbuild/android-arm64": "0.27.7",
|
||||||
|
"@esbuild/android-x64": "0.27.7",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.7",
|
||||||
|
"@esbuild/darwin-x64": "0.27.7",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.7",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.7",
|
||||||
|
"@esbuild/linux-arm": "0.27.7",
|
||||||
|
"@esbuild/linux-arm64": "0.27.7",
|
||||||
|
"@esbuild/linux-ia32": "0.27.7",
|
||||||
|
"@esbuild/linux-loong64": "0.27.7",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.7",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.7",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.7",
|
||||||
|
"@esbuild/linux-s390x": "0.27.7",
|
||||||
|
"@esbuild/linux-x64": "0.27.7",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.7",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.7",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.7",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.7",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.7",
|
||||||
|
"@esbuild/sunos-x64": "0.27.7",
|
||||||
|
"@esbuild/win32-arm64": "0.27.7",
|
||||||
|
"@esbuild/win32-ia32": "0.27.7",
|
||||||
|
"@esbuild/win32-x64": "0.27.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1706,6 +2247,34 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.7",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||||
|
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/giget": {
|
"node_modules/giget": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/giget/-/giget-2.0.0.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/giget/-/giget-2.0.0.tgz",
|
||||||
@@ -2532,6 +3101,16 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2716,6 +3295,26 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://package-mirror.liara.ir/repository/npm/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://package-mirror.liara.ir/repository/npm/typescript/-/typescript-6.0.2.tgz",
|
"resolved": "https://package-mirror.liara.ir/repository/npm/typescript/-/typescript-6.0.2.tgz",
|
||||||
|
|||||||
14
package.json
@@ -2,13 +2,22 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"setup:test-user": "tsx scripts/create-test-user.ts",
|
||||||
|
"setup:admin": "tsx scripts/create-admin-user.ts",
|
||||||
|
"check:users": "tsx scripts/check-users.ts"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.11.1",
|
"@auth/prisma-adapter": "^2.11.1",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
"@tailwindcss/postcss": "^4.2.2",
|
"@tailwindcss/postcss": "^4.2.2",
|
||||||
@@ -29,6 +38,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"ts-node": "^10.9.2"
|
"ts-node": "^10.9.2",
|
||||||
|
"tsx": "^4.21.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,17 +63,26 @@ enum EventType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Country {
|
model Country {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
code String @unique
|
code String @unique
|
||||||
flagUrl String?
|
flagUrl String?
|
||||||
defaultFormation String @default("4-3-3")
|
flagImage String? // نام فایل پرچم مثل Flag_of_Australia.webp
|
||||||
group Group? @relation(fields: [groupId], references: [id])
|
confederation String? // کنفدراسیون (UEFA, AFC, CAF, ...)
|
||||||
groupId String?
|
qualificationMethod String? // شیوه راهیابی
|
||||||
isEliminated Boolean @default(false)
|
qualificationDate String? // تاریخ راهیابی
|
||||||
players Player[]
|
participationHistory String? // سابقه شرکت
|
||||||
homeMatches Match[] @relation("HomeTeam")
|
bestResult String? // بهترین نتیجه
|
||||||
awayMatches Match[] @relation("AwayTeam")
|
description String? @db.Text // توضیحات کامل
|
||||||
|
defaultFormation String @default("4-3-3")
|
||||||
|
defaultLineupPlayerIds String[] @default([])
|
||||||
|
defaultCaptainId String? // شناسه کاپیتان پیشفرض
|
||||||
|
group Group? @relation(fields: [groupId], references: [id])
|
||||||
|
groupId String?
|
||||||
|
isEliminated Boolean @default(false)
|
||||||
|
players Player[]
|
||||||
|
homeMatches Match[] @relation("HomeTeam")
|
||||||
|
awayMatches Match[] @relation("AwayTeam")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Group {
|
model Group {
|
||||||
@@ -85,6 +94,7 @@ model Group {
|
|||||||
model Player {
|
model Player {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
|
image String? // نام فایل تصویر در public/uploads/players/
|
||||||
position Position
|
position Position
|
||||||
countryId String
|
countryId String
|
||||||
country Country @relation(fields: [countryId], references: [id])
|
country Country @relation(fields: [countryId], references: [id])
|
||||||
@@ -109,6 +119,16 @@ model Match {
|
|||||||
stage MatchStage @default(GROUP)
|
stage MatchStage @default(GROUP)
|
||||||
status MatchStatus @default(SCHEDULED)
|
status MatchStatus @default(SCHEDULED)
|
||||||
matchDate DateTime
|
matchDate DateTime
|
||||||
|
matchDatePersian String? // تاریخ شمسی
|
||||||
|
stadium String? // نام ورزشگاه
|
||||||
|
city String? // شهر
|
||||||
|
referee String? // داور اصلی
|
||||||
|
assistant1 String? // کمک داور 1
|
||||||
|
assistant2 String? // کمک داور 2
|
||||||
|
fourthOfficial String? // داور چهارم
|
||||||
|
attendance Int? // تعداد تماشاگر
|
||||||
|
weather String? // وضعیت آب و هوا
|
||||||
|
description String? @db.Text // توضیحات بازی
|
||||||
roundId String?
|
roundId String?
|
||||||
round Round? @relation(fields: [roundId], references: [id])
|
round Round? @relation(fields: [roundId], references: [id])
|
||||||
playerStats PlayerMatchStat[]
|
playerStats PlayerMatchStat[]
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||