mirror of
https://github.com/opengram-server/opengram.git
synced 2026-07-23 22:16:10 +03:00
Initial commit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Star Gifts Admin Panel
|
||||
|
||||
Веб-панель для управления подарками (Star Gifts) в Opengram. Состоит из backend на
|
||||
Node.js и frontend на React. Проект организован как npm-workspace.
|
||||
|
||||
## Возможности
|
||||
|
||||
- панель с аналитикой и статистикой;
|
||||
- создание, редактирование и удаление подарков;
|
||||
- загрузка анимаций (JSON/TGS);
|
||||
- фильтрация и поиск;
|
||||
- обновление данных в реальном времени.
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Установка зависимостей
|
||||
|
||||
```bash
|
||||
cd stargift-admin
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
### 2. База данных
|
||||
|
||||
Убедитесь, что MongoDB запущена. Её можно поднять из основного compose-файла:
|
||||
|
||||
```bash
|
||||
docker-compose -f ../docker/compose/docker-compose.yml up mongodb -d
|
||||
```
|
||||
|
||||
### 3. Запуск в режиме разработки
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Панель будет доступна по адресу http://localhost:5173, backend — на порту 3001.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
stargift-admin/
|
||||
├── backend/ # Node.js API
|
||||
│ └── src/
|
||||
│ ├── routes/
|
||||
│ └── server.js
|
||||
├── frontend/ # React UI
|
||||
│ └── src/
|
||||
└── package.json # npm-workspace
|
||||
```
|
||||
|
||||
## Основные эндпоинты API
|
||||
|
||||
- `GET /api/gifts` — список подарков;
|
||||
- `POST /api/gifts` — создать подарок;
|
||||
- `PUT /api/gifts/:id` — обновить подарок;
|
||||
- `DELETE /api/gifts/:id` — удалить подарок;
|
||||
- `GET /api/stats` — статистика.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Настройки backend задаются в `backend/.env`. Перед запуском замените значения
|
||||
паролей и ключей на свои (в репозитории они заменены на плейсхолдеры).
|
||||
|
||||
```
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
DB_NAME=tg
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,24 @@
|
||||
PORT=3001
|
||||
# Connect to Docker MongoDB container (same as MyTelegram server)
|
||||
MONGODB_URI=mongodb://localhost:27017
|
||||
DB_NAME=tg
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# MyTelegram Admin API for verification commands
|
||||
ADMIN_API_URL=http://localhost:5555
|
||||
|
||||
# MinIO configuration (file storage)
|
||||
# Must match docker/compose/.env settings
|
||||
MINIO_ENDPOINT=localhost
|
||||
MINIO_PORT=9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=CHANGE_ME
|
||||
|
||||
# Database Passwords
|
||||
MONGODB_PASSWORD=CHANGE_ME
|
||||
RABBITMQ_PASSWORD=CHANGE_ME
|
||||
REDIS_PASSWORD=CHANGE_ME
|
||||
MINIO_PASSWORD=CHANGE_ME
|
||||
|
||||
# Admin API Key
|
||||
ADMIN_API_KEY=CHANGE_ME
|
||||
@@ -0,0 +1,21 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Environment
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Start server
|
||||
CMD ["node", "src/server.js"]
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "stargift-admin-backend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"worker": "node src/workers/serviceNotificationWorker.js",
|
||||
"worker:dev": "nodemon src/workers/serviceNotificationWorker.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"amqplib": "^0.10.3",
|
||||
"archiver": "^7.0.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"joi": "^17.11.0",
|
||||
"minio": "^7.1.3",
|
||||
"mongodb": "^6.3.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pako": "^2.1.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import { ObjectId } from 'mongodb';
|
||||
import minioHelper from '../utils/minioHelper.js';
|
||||
import { generateFileReference, generateAccessHash, generateDocumentId } from '../utils/telegramIds.js';
|
||||
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
const router = express.Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = 'uploads/attributes';
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedExts = ['.png', '.tgs'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .png and .tgs files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Отдельная конфигурация multer для загрузки ZIP-архивов
|
||||
const zipStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = 'uploads/zip';
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, `${file.fieldname}-${uniqueSuffix}.zip`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadZip = multer({
|
||||
storage: zipStorage,
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // до 50 МБ для ZIP
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ext === '.zip') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .zip files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Приводит имя к единому виду для сопоставления: пробелы и дефисы -> подчёркивания,
|
||||
// убирает известные префиксы и расширение
|
||||
function normalizeName(name) {
|
||||
return String(name || '')
|
||||
.toLowerCase()
|
||||
.replace(/\\+/g, '/')
|
||||
.replace(/.*\//, '') // оставляем только имя файла
|
||||
.replace(/\.(tgs|json|png)$/i, '') // убираем расширение
|
||||
.replace(/^(models?|patterns?)_/, '') // убираем известные префиксы
|
||||
.replace(/[\s-]+/g, '_') // пробелы и дефисы -> подчёркивание
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Ищет в ZIP запись .tgs, устойчиво сопоставляя имена
|
||||
function findZipEntryByName(zipEntries, targetName) {
|
||||
if (!targetName) return { entry: null, reason: 'empty targetName' };
|
||||
const normTarget = normalizeName(targetName);
|
||||
|
||||
const tgsEntries = zipEntries.filter(e => e.entryName.toLowerCase().endsWith('.tgs'));
|
||||
|
||||
// Заранее считаем кандидатов с нормализованными именами файлов
|
||||
const candidates = tgsEntries.map(e => ({
|
||||
entry: e,
|
||||
base: e.entryName.replace(/\\+/g,'/').split('/').pop(),
|
||||
})).map(x => ({ ...x, norm: normalizeName(x.base) }));
|
||||
|
||||
// 1) точное совпадение нормализованных имён
|
||||
let found = candidates.find(c => c.norm === normTarget);
|
||||
if (found) return { entry: found.entry, reason: 'norm exact' };
|
||||
|
||||
// 2) совпадение по началу строки (допускает суффиксы вроде _30)
|
||||
found = candidates.find(c => c.norm.startsWith(normTarget));
|
||||
if (found) return { entry: found.entry, reason: 'norm startsWith' };
|
||||
|
||||
// 3) ослабленное: отбрасываем хвост из _цифр у кандидата и сравниваем
|
||||
found = candidates.find(c => c.norm.replace(/_[0-9]+$/, '') === normTarget);
|
||||
if (found) return { entry: found.entry, reason: 'strip _digits equals' };
|
||||
|
||||
// 4) совпадение по началу после отбрасывания цифрового хвоста
|
||||
found = candidates.find(c => c.norm.replace(/_[0-9]+$/, '').startsWith(normTarget));
|
||||
if (found) return { entry: found.entry, reason: 'strip _digits startsWith' };
|
||||
|
||||
// 5) убираем подчёркивания у целевого имени и сравниваем по началу
|
||||
const targetTight = normTarget.replace(/_/g, '');
|
||||
found = candidates.find(c => c.norm.replace(/_/g,'').startsWith(targetTight));
|
||||
if (found) return { entry: found.entry, reason: 'tight startsWith' };
|
||||
|
||||
return { entry: null, reason: `no match for ${normTarget}` };
|
||||
}
|
||||
|
||||
const toJSON = (data) => {
|
||||
return JSON.parse(JSON.stringify(data, (key, value) => {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
return value;
|
||||
}));
|
||||
};
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const attributeSets = await req.db.collection('StarGiftAttributeSets').find({}).sort({ _id: -1 }).toArray();
|
||||
res.json(toJSON(attributeSets));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', upload.fields([
|
||||
{ name: 'model', maxCount: 1 },
|
||||
{ name: 'pattern', maxCount: 1 }
|
||||
]), async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
giftId,
|
||||
name,
|
||||
modelName,
|
||||
patternName,
|
||||
modelRarity,
|
||||
patternRarity
|
||||
} = req.body;
|
||||
|
||||
if (!giftId) return res.status(400).json({ error: 'Gift ID is required' });
|
||||
if (!name) return res.status(400).json({ error: 'Name is required' });
|
||||
|
||||
const attributeSet = {
|
||||
_id: Date.now().toString(),
|
||||
GiftId: parseInt(giftId),
|
||||
Name: name,
|
||||
ModelName: modelName || name + ' Model',
|
||||
PatternName: patternName || name + ' Pattern',
|
||||
Model: null,
|
||||
Pattern: null,
|
||||
CreatedAt: new Date()
|
||||
};
|
||||
|
||||
// Модель (TGS)
|
||||
if (req.files.model && req.files.model[0]) {
|
||||
const modelFile = req.files.model[0];
|
||||
const documentId = generateDocumentId();
|
||||
const accessHash = generateAccessHash();
|
||||
const fileReference = generateFileReference();
|
||||
const fileBuffer = await fs.readFile(modelFile.path);
|
||||
|
||||
try {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
JSON.parse(decompressed.toString('utf8'));
|
||||
} catch (error) {
|
||||
try {
|
||||
JSON.parse(fileBuffer.toString('utf8'));
|
||||
} catch (jsonError) {
|
||||
await fs.unlink(modelFile.path);
|
||||
return res.status(400).json({ error: 'Invalid TGS file for model' });
|
||||
}
|
||||
}
|
||||
|
||||
await minioHelper.uploadFile(modelFile.path, documentId, 'application/x-tgsticker');
|
||||
|
||||
// Сохраняем DocumentId и AccessHash как объекты Long (формат MongoDB)
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
AccessHash: BigInt(accessHash),
|
||||
FileReference: fileReference,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: fileBuffer.length,
|
||||
Name: modelFile.originalname,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null,
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Version: 1,
|
||||
Fingerprint: null,
|
||||
Attributes: null,
|
||||
Attributes2: [
|
||||
{ _t: 'TDocumentAttributeFilename', FileName: modelFile.originalname },
|
||||
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 },
|
||||
{ _t: 'TDocumentAttributeAnimated' },
|
||||
{ _t: 'TDocumentAttributeSticker', Mask: false, Alt: '⭐️', Stickerset: { _t: 'TInputStickerSetEmpty' }, MaskCoords: null }
|
||||
]
|
||||
};
|
||||
|
||||
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
|
||||
await fs.unlink(modelFile.path);
|
||||
attributeSet.Model = { DocumentId: documentId, RarityPermille: parseInt(modelRarity) || 1000 };
|
||||
console.log(`Model uploaded: DocumentId=${documentId}, AccessHash=${accessHash}`);
|
||||
}
|
||||
|
||||
// Узор (PNG или TGS)
|
||||
if (req.files.pattern && req.files.pattern[0]) {
|
||||
const patternFile = req.files.pattern[0];
|
||||
const documentId = generateDocumentId();
|
||||
const accessHash = generateAccessHash();
|
||||
const fileReference = generateFileReference();
|
||||
const ext = path.extname(patternFile.originalname).toLowerCase();
|
||||
let mimeType = 'image/png';
|
||||
let attributes = [
|
||||
{ _t: 'TDocumentAttributeFilename', FileName: patternFile.originalname },
|
||||
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 }
|
||||
];
|
||||
|
||||
const fileBuffer = await fs.readFile(patternFile.path);
|
||||
|
||||
if (ext === '.tgs') {
|
||||
mimeType = 'application/x-tgsticker';
|
||||
try {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
JSON.parse(decompressed.toString('utf8'));
|
||||
} catch (error) {
|
||||
try {
|
||||
JSON.parse(fileBuffer.toString('utf8'));
|
||||
} catch (jsonError) {
|
||||
await fs.unlink(patternFile.path);
|
||||
return res.status(400).json({ error: 'Invalid TGS file for pattern' });
|
||||
}
|
||||
}
|
||||
attributes.push({ _t: 'TDocumentAttributeAnimated' });
|
||||
attributes.push({ _t: 'TDocumentAttributeSticker', Alt: '⭐️', Stickerset: { _t: 'TInputStickerSetEmpty' }, Mask: false });
|
||||
}
|
||||
|
||||
await minioHelper.uploadFile(patternFile.path, documentId, mimeType);
|
||||
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
AccessHash: BigInt(accessHash),
|
||||
FileReference: fileReference,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: mimeType,
|
||||
Size: fileBuffer.length,
|
||||
Name: patternFile.originalname,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null,
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Version: 1,
|
||||
Fingerprint: null,
|
||||
Attributes: null,
|
||||
Attributes2: attributes
|
||||
};
|
||||
|
||||
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
|
||||
await fs.unlink(patternFile.path);
|
||||
attributeSet.Pattern = { DocumentId: documentId, RarityPermille: parseInt(patternRarity) || 1000 };
|
||||
}
|
||||
|
||||
// Фон генерируется на сервере автоматически из 100 цветовых схем,
|
||||
// загружать файлы фона или задавать цвета вручную не требуется
|
||||
|
||||
await req.db.collection('StarGiftAttributeSets').insertOne(attributeSet);
|
||||
res.status(201).json(toJSON(attributeSet));
|
||||
} catch (error) {
|
||||
console.error('Create attribute set error:', error);
|
||||
if (req.files) {
|
||||
const allFiles = [...(req.files.model || []), ...(req.files.pattern || [])];
|
||||
for (const file of allFiles) {
|
||||
await fs.unlink(file.path).catch(() => { });
|
||||
}
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const setId = req.params.id;
|
||||
console.log(`[DELETE] Attempting to delete attribute set with ID: ${setId}`);
|
||||
|
||||
// Сначала пробуем найти по _id как по строке
|
||||
let attributeSet = await req.db.collection('StarGiftAttributeSets').findOne({ _id: setId });
|
||||
|
||||
// Если не нашли, а ID похож на ObjectId, пробуем как ObjectId
|
||||
if (!attributeSet && setId.match(/^[0-9a-fA-F]{24}$/)) {
|
||||
console.log(`Trying with ObjectId format...`);
|
||||
attributeSet = await req.db.collection('StarGiftAttributeSets').findOne({ _id: new ObjectId(setId) });
|
||||
}
|
||||
|
||||
if (!attributeSet) {
|
||||
console.log(`Attribute set not found with ID: ${setId}`);
|
||||
return res.status(404).json({ error: 'Attribute set not found' });
|
||||
}
|
||||
|
||||
console.log(`Found attribute set: ${attributeSet.Name}`);
|
||||
|
||||
const documentIds = [];
|
||||
if (attributeSet.Model) documentIds.push(attributeSet.Model.DocumentId);
|
||||
if (attributeSet.Pattern) documentIds.push(attributeSet.Pattern.DocumentId);
|
||||
// Фон генерируется автоматически, удалять документ не нужно
|
||||
|
||||
for (const docId of documentIds) {
|
||||
await req.db.collection('ReadModel-DocumentReadModel').deleteOne({ DocumentId: docId });
|
||||
try {
|
||||
await minioHelper.deleteFile(docId);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to delete file ${docId} from MinIO:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем в том же формате _id, в каком запись была найдена
|
||||
await req.db.collection('StarGiftAttributeSets').deleteOne({ _id: attributeSet._id });
|
||||
console.log(`Successfully deleted attribute set: ${attributeSet.Name}`);
|
||||
res.json({ message: 'Attribute set deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /attributes/upload-zip - загрузка ZIP-архива с улучшениями подарка
|
||||
router.post('/upload-zip', uploadZip.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const { giftId } = req.body;
|
||||
if (!giftId) {
|
||||
return res.status(400).json({ error: 'giftId is required' });
|
||||
}
|
||||
|
||||
console.log(`Processing ZIP upload for gift ${giftId}: ${req.file.originalname}`);
|
||||
|
||||
const AdmZip = (await import('adm-zip')).default;
|
||||
const zip = new AdmZip(req.file.path);
|
||||
const zipEntries = zip.getEntries();
|
||||
|
||||
// Ищем info.json
|
||||
const infoEntry = zipEntries.find(entry => entry.entryName === 'info.json' || entry.entryName.endsWith('/info.json'));
|
||||
if (!infoEntry) {
|
||||
return res.status(400).json({ error: 'info.json not found in ZIP archive' });
|
||||
}
|
||||
|
||||
// Разбираем info.json
|
||||
const infoContent = infoEntry.getData().toString('utf8');
|
||||
const info = JSON.parse(infoContent);
|
||||
|
||||
const giftName = info.gift_name || `Gift ${giftId}`;
|
||||
const models = info.models || [];
|
||||
const patterns = info.patterns || [];
|
||||
const backdrops = info.backdrops || [];
|
||||
|
||||
console.log(`Found info.json: gift="${giftName}", models=${models.length}, patterns=${patterns.length}, backdrops=${backdrops.length}`);
|
||||
|
||||
if (models.length === 0 || patterns.length === 0) {
|
||||
return res.status(400).json({ error: 'No models or patterns found in info.json' });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const db = req.db;
|
||||
|
||||
// Формируем комбинации: каждая модель с каждым узором и случайным фоном
|
||||
const maxCombinations = Math.min(models.length * patterns.length, 100); // не более 100 комбинаций
|
||||
console.log(`Creating ${maxCombinations} attribute combinations...`);
|
||||
|
||||
for (let i = 0; i < Math.min(models.length, 20); i++) {
|
||||
const model = models[i];
|
||||
for (let j = 0; j < Math.min(patterns.length, 5); j++) {
|
||||
if (results.length >= 50) break; // общее ограничение
|
||||
|
||||
const pattern = patterns[j];
|
||||
const backdrop = backdrops[Math.floor(Math.random() * backdrops.length)];
|
||||
|
||||
console.log(`\nProcessing combination ${results.length + 1}: ${model.name} + ${pattern.name}`);
|
||||
|
||||
let modelDocId = null;
|
||||
let patternDocId = null;
|
||||
|
||||
// Загружаем файл модели
|
||||
if (model.file) {
|
||||
const { entry: modelEntry, reason } = findZipEntryByName(zipEntries, model.file);
|
||||
if (modelEntry) {
|
||||
const modelBuffer = modelEntry.getData();
|
||||
modelDocId = await uploadDocument(db, modelBuffer, model.file, 'model');
|
||||
console.log(` Model uploaded: ${model.file} -> ${modelDocId} (${reason})`);
|
||||
} else {
|
||||
console.log(` Model file not found: ${model.file} (${reason})`);
|
||||
continue; // пропускаем эту комбинацию
|
||||
}
|
||||
}
|
||||
|
||||
// Загружаем файл узора
|
||||
if (pattern.file) {
|
||||
const { entry: patternEntry, reason } = findZipEntryByName(zipEntries, pattern.file);
|
||||
if (patternEntry) {
|
||||
const patternBuffer = patternEntry.getData();
|
||||
patternDocId = await uploadDocument(db, patternBuffer, pattern.file, 'pattern');
|
||||
console.log(` Pattern uploaded: ${pattern.file} -> ${patternDocId} (${reason})`);
|
||||
} else {
|
||||
console.log(` Pattern file not found: ${pattern.file} (${reason})`);
|
||||
continue; // пропускаем эту комбинацию
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём запись набора атрибутов
|
||||
const attributeSet = {
|
||||
_id: Date.now().toString() + '-' + Math.random().toString(36).substring(7), // генерируем уникальный ID
|
||||
GiftId: parseInt(giftId),
|
||||
Name: `${model.name} ${pattern.name}`,
|
||||
Model: modelDocId ? {
|
||||
DocumentId: modelDocId,
|
||||
RarityPermille: Math.round(model.rarity_percent * 10) // переводим проценты в промилле
|
||||
} : null,
|
||||
Pattern: patternDocId ? {
|
||||
DocumentId: patternDocId,
|
||||
RarityPermille: Math.round(pattern.rarity_percent * 10)
|
||||
} : null,
|
||||
Backdrop: backdrop ? {
|
||||
BackdropId: backdrop.id || parseInt(giftId),
|
||||
CenterColor: parseInt(backdrop.center_color?.replace('#', '') || 'FF0000', 16),
|
||||
EdgeColor: parseInt(backdrop.edge_color?.replace('#', '') || '0000FF', 16),
|
||||
PatternColor: parseInt(backdrop.pattern_color?.replace('#', '') || '5AC8FA', 16),
|
||||
TextColor: parseInt(backdrop.text_color?.replace('#', '') || 'FFFFFF', 16),
|
||||
RarityPermille: Math.round(backdrop.rarity_percent * 10)
|
||||
} : null,
|
||||
ModelName: model.name,
|
||||
PatternName: pattern.name,
|
||||
BackdropName: backdrop?.name || 'Random',
|
||||
CreatedAt: new Date() // отметка времени для единообразия
|
||||
};
|
||||
|
||||
// Добавляем запись в StarGiftAttributeSets
|
||||
await db.collection('StarGiftAttributeSets').insertOne(attributeSet);
|
||||
console.log(` Attribute set created: ${model.name} + ${pattern.name}`);
|
||||
|
||||
results.push({
|
||||
model: model.name,
|
||||
pattern: pattern.name,
|
||||
backdrop: backdrop?.name,
|
||||
modelDocId,
|
||||
patternDocId
|
||||
});
|
||||
}
|
||||
if (results.length >= 50) break;
|
||||
}
|
||||
|
||||
// Удаляем загруженный ZIP-файл
|
||||
await fs.unlink(req.file.path);
|
||||
|
||||
console.log(`\nSuccessfully processed ${results.length} upgrades for gift ${giftId}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
giftId: parseInt(giftId),
|
||||
title: giftName,
|
||||
upgradesProcessed: results.length,
|
||||
results
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('ZIP upload error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Вспомогательная функция: сохраняет документ в MongoDB
|
||||
async function uploadDocument(db, fileBuffer, filename, type) {
|
||||
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
||||
|
||||
const document = {
|
||||
_id: `document-${documentId}`,
|
||||
Id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
AccessHash: generateAccessHash(),
|
||||
FileReference: generateFileReference(),
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: fileBuffer.length,
|
||||
DcId: 2,
|
||||
// Attributes2 оставляем пустым: сервер ждёт TL-типы, а не сырой JSON
|
||||
Attributes2: [],
|
||||
// Attributes не задаём массивом — опускаем или ставим null, сервер ждёт byte[].
|
||||
// Здесь поле опущено, чтобы соответствовать схеме
|
||||
Thumbs: [],
|
||||
VideoThumbs: []
|
||||
};
|
||||
|
||||
// Пишем в обе коллекции, чтобы query-server увидел документ
|
||||
await db.collection('eventflow-documentreadmodel').insertOne(document);
|
||||
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
|
||||
|
||||
// Заливаем содержимое в MinIO под именем объекта = DocumentId (без расширения).
|
||||
// Сохраняем буфер во временный файл, загружаем и удаляем его
|
||||
const tmpDir = 'uploads/tmp';
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
const tmpPath = `${tmpDir}/doc_${documentId}.bin`;
|
||||
await fs.writeFile(tmpPath, fileBuffer);
|
||||
try {
|
||||
// отложенный импорт, чтобы избежать циклической зависимости
|
||||
const { uploadFile } = (await import('../utils/minioHelper.js')).default ? (await import('../utils/minioHelper.js')).default : await import('../utils/minioHelper.js');
|
||||
// minioHelper экспортирует default с методами; поддерживаем и default, и именованный экспорт
|
||||
const helper = (await import('../utils/minioHelper.js')).default;
|
||||
if (helper && helper.uploadFile) {
|
||||
await helper.uploadFile(tmpPath, documentId, 'application/x-tgsticker');
|
||||
} else if (uploadFile) {
|
||||
await uploadFile(tmpPath, documentId, 'application/x-tgsticker');
|
||||
}
|
||||
} finally {
|
||||
try { await fs.unlink(tmpPath); } catch {}
|
||||
}
|
||||
|
||||
return documentId;
|
||||
}
|
||||
|
||||
export default router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,374 @@
|
||||
import express from 'express';
|
||||
import { MongoClient } from 'mongodb';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017';
|
||||
const DB_NAME = 'tg';
|
||||
|
||||
// Get MongoDB client
|
||||
async function getMongoClient() {
|
||||
const client = new MongoClient(MONGO_URI);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
// Get all frozen accounts
|
||||
router.get('/', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
const frozenAccounts = await frozenCollection.find({}).sort({ CreatedDate: -1 }).toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: frozenAccounts,
|
||||
count: frozenAccounts.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching frozen accounts:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Get frozen account by user ID
|
||||
router.get('/user/:userId', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
const frozenAccount = await frozenCollection.findOne({ UserId: userId });
|
||||
|
||||
if (!frozenAccount) {
|
||||
return res.status(404).json({ success: false, error: 'Frozen account not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: frozenAccount
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching frozen account:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Freeze account
|
||||
router.post('/freeze', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
const {
|
||||
userId: userIdRaw,
|
||||
reason,
|
||||
durationDays = 7,
|
||||
moderatorUserId,
|
||||
note
|
||||
} = req.body;
|
||||
|
||||
if (!userIdRaw) {
|
||||
return res.status(400).json({ success: false, error: 'userId is required' });
|
||||
}
|
||||
|
||||
// Преобразуем userId в число
|
||||
const userId = parseInt(userIdRaw);
|
||||
if (isNaN(userId)) {
|
||||
return res.status(400).json({ success: false, error: 'userId must be a valid number' });
|
||||
}
|
||||
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
const usersCollection = db.collection('eventflow-userreadmodel');
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
// Check if user exists
|
||||
let user = await usersCollection.findOne({ UserId: userId });
|
||||
if (!user) {
|
||||
// Try alternative collection
|
||||
user = await db.collection('ReadModel-UserReadModel').findOne({ UserId: userId });
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `User ${userId} not found. Make sure the user is registered in the system.`,
|
||||
hint: 'The user must have logged in at least once to MyTelegram'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already frozen (any status)
|
||||
const existingFrozen = await frozenCollection.findOne({ UserId: userId });
|
||||
|
||||
if (existingFrozen) {
|
||||
// Если уже заморожен с Status: 1 (Active)
|
||||
if (existingFrozen.Status === 1) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'User is already frozen with active status'
|
||||
});
|
||||
}
|
||||
|
||||
// Если запись существует но не активна (Status: 2 или 3), обновляем её
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const freezeUntil = now + (durationDays * 24 * 60 * 60);
|
||||
|
||||
await frozenCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
FreezeSinceDate: now,
|
||||
FreezeUntilDate: freezeUntil,
|
||||
Reason: reason || 4,
|
||||
Status: 1, // Active
|
||||
AppealUrl: 'https://t.me/spambot',
|
||||
ModeratorUserId: moderatorUserId || null,
|
||||
FreezeNote: note || null,
|
||||
LastModifiedDate: new Date(),
|
||||
Metadata: {
|
||||
frozenBy: 'admin_panel',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update user record
|
||||
await usersCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
IsFrozen: true,
|
||||
FreezeSinceDate: now,
|
||||
FreezeUntilDate: freezeUntil,
|
||||
FreezeReason: reason || 4,
|
||||
FreezeAppealUrl: 'https://t.me/spambot'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Account re-frozen successfully (updated existing record)',
|
||||
data: { ...existingFrozen, Status: 1, FreezeSinceDate: now, FreezeUntilDate: freezeUntil }
|
||||
});
|
||||
}
|
||||
|
||||
// Новая заморозка - создаем запись
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const freezeUntil = now + (durationDays * 24 * 60 * 60);
|
||||
|
||||
const frozenDoc = {
|
||||
_id: `frozen-${userId}`,
|
||||
UserId: userId,
|
||||
FreezeSinceDate: now,
|
||||
FreezeUntilDate: freezeUntil,
|
||||
Reason: reason || 4, // Default: TosViolation
|
||||
Status: 1, // Active
|
||||
AppealUrl: 'https://t.me/spambot',
|
||||
ModeratorUserId: moderatorUserId || null,
|
||||
FreezeNote: note || null,
|
||||
CreatedDate: new Date(),
|
||||
Metadata: {
|
||||
frozenBy: 'admin_panel',
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
await frozenCollection.insertOne(frozenDoc);
|
||||
|
||||
// Update user record
|
||||
await usersCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
IsFrozen: true,
|
||||
FreezeSinceDate: now,
|
||||
FreezeUntilDate: freezeUntil,
|
||||
FreezeReason: reason || 4,
|
||||
FreezeAppealUrl: 'https://t.me/spambot'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Account frozen successfully',
|
||||
data: frozenDoc
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error freezing account:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Unfreeze account
|
||||
router.post('/unfreeze', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
const { userId: userIdRaw, moderatorUserId, note } = req.body;
|
||||
|
||||
if (!userIdRaw) {
|
||||
return res.status(400).json({ success: false, error: 'userId is required' });
|
||||
}
|
||||
|
||||
const userId = parseInt(userIdRaw);
|
||||
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
const usersCollection = db.collection('eventflow-userreadmodel');
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
// Check if frozen
|
||||
const frozenAccount = await frozenCollection.findOne({ UserId: userId });
|
||||
if (!frozenAccount) {
|
||||
return res.status(404).json({ success: false, error: 'Frozen account not found' });
|
||||
}
|
||||
|
||||
// Update frozen record
|
||||
await frozenCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Status: 3, // Approved
|
||||
LastModifiedDate: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update user record
|
||||
await usersCollection.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
IsFrozen: false
|
||||
},
|
||||
$unset: {
|
||||
FreezeSinceDate: "",
|
||||
FreezeUntilDate: "",
|
||||
FreezeReason: "",
|
||||
FreezeAppealUrl: ""
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Account unfrozen successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error unfreezing account:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Get appeals
|
||||
router.get('/appeals', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
const appealsCollection = db.collection('ReadModel-UserFrozenAppealHistoryReadModel');
|
||||
|
||||
const appeals = await appealsCollection.find({}).sort({ SubmittedDate: -1 }).toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: appeals,
|
||||
count: appeals.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching appeals:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Review appeal
|
||||
router.post('/appeals/:appealId/review', async (req, res) => {
|
||||
let client;
|
||||
try {
|
||||
const { appealId } = req.params;
|
||||
const { status, moderatorUserId, reviewNote } = req.body;
|
||||
|
||||
if (!status || ![2, 3].includes(status)) {
|
||||
return res.status(400).json({ success: false, error: 'Invalid status (2=Approved, 3=Rejected)' });
|
||||
}
|
||||
|
||||
client = await getMongoClient();
|
||||
const db = client.db(DB_NAME);
|
||||
const appealsCollection = db.collection('ReadModel-UserFrozenAppealHistoryReadModel');
|
||||
|
||||
const appeal = await appealsCollection.findOne({ AppealId: appealId });
|
||||
if (!appeal) {
|
||||
return res.status(404).json({ success: false, error: 'Appeal not found' });
|
||||
}
|
||||
|
||||
// Update appeal
|
||||
await appealsCollection.updateOne(
|
||||
{ AppealId: appealId },
|
||||
{
|
||||
$set: {
|
||||
Status: status,
|
||||
ReviewerModeratorId: moderatorUserId,
|
||||
ReviewNote: reviewNote,
|
||||
ReviewedDate: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// If approved, unfreeze the account
|
||||
if (status === 2) {
|
||||
const usersCollection = db.collection('eventflow-userreadmodel');
|
||||
const frozenCollection = db.collection('ReadModel-UserFrozenAccountReadModel');
|
||||
|
||||
await frozenCollection.updateOne(
|
||||
{ UserId: appeal.UserId },
|
||||
{ $set: { Status: 3 } } // Approved
|
||||
);
|
||||
|
||||
await usersCollection.updateOne(
|
||||
{ UserId: appeal.UserId },
|
||||
{
|
||||
$set: { IsFrozen: false },
|
||||
$unset: {
|
||||
FreezeSinceDate: "",
|
||||
FreezeUntilDate: "",
|
||||
FreezeReason: "",
|
||||
FreezeAppealUrl: ""
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: status === 2 ? 'Appeal approved' : 'Appeal rejected'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reviewing appeal:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
} finally {
|
||||
if (client) await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,172 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { MongoClient } from 'mongodb';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configure multer for TGS file upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
const uploadDir = path.join(__dirname, '../../uploads/frozen');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: function (req, file, cb) {
|
||||
cb(null, 'snowflake-' + Date.now() + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: function (req, file, cb) {
|
||||
// Accept TGS files
|
||||
if (file.mimetype === 'application/x-tgsticker' ||
|
||||
file.originalname.endsWith('.tgs') ||
|
||||
file.mimetype === 'application/gzip') {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only TGS files are allowed!'), false);
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 64 * 1024 // 64KB max for TGS
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/frozen-settings
|
||||
* Get current frozen account settings
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
|
||||
|
||||
await client.connect();
|
||||
const db = client.db('tg');
|
||||
const settingsCollection = db.collection('settings');
|
||||
|
||||
// Get frozen settings
|
||||
let settings = await settingsCollection.findOne({ _id: 'frozen-account-settings' });
|
||||
|
||||
if (!settings) {
|
||||
settings = {
|
||||
_id: 'frozen-account-settings',
|
||||
snowflakeEmojiId: null,
|
||||
snowflakeFileName: null,
|
||||
snowflakeUploadedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
await client.close();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
settings: settings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching frozen settings:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/frozen-settings/upload-snowflake
|
||||
* Upload TGS snowflake sticker
|
||||
*/
|
||||
router.post('/upload-snowflake', upload.single('snowflake'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ success: false, error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
|
||||
|
||||
await client.connect();
|
||||
const db = client.db('tg');
|
||||
const settingsCollection = db.collection('settings');
|
||||
|
||||
// Save file info to settings
|
||||
const settings = {
|
||||
_id: 'frozen-account-settings',
|
||||
snowflakeFileName: req.file.filename,
|
||||
snowflakeFilePath: req.file.path,
|
||||
snowflakeUploadedAt: new Date(),
|
||||
snowflakeEmojiId: null // Will be set after uploading to MyTelegram
|
||||
};
|
||||
|
||||
await settingsCollection.replaceOne(
|
||||
{ _id: 'frozen-account-settings' },
|
||||
settings,
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Snowflake TGS uploaded successfully',
|
||||
file: {
|
||||
filename: req.file.filename,
|
||||
size: req.file.size,
|
||||
path: req.file.path
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading snowflake:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/frozen-settings/set-emoji-id
|
||||
* Set the Document ID for frozen snowflake emoji
|
||||
*/
|
||||
router.post('/set-emoji-id', async (req, res) => {
|
||||
try {
|
||||
const { documentId } = req.body;
|
||||
|
||||
if (!documentId || isNaN(documentId)) {
|
||||
return res.status(400).json({ success: false, error: 'Valid documentId is required' });
|
||||
}
|
||||
|
||||
const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
|
||||
|
||||
await client.connect();
|
||||
const db = client.db('tg');
|
||||
const settingsCollection = db.collection('settings');
|
||||
|
||||
// Update emoji ID
|
||||
await settingsCollection.updateOne(
|
||||
{ _id: 'frozen-account-settings' },
|
||||
{
|
||||
$set: {
|
||||
snowflakeEmojiId: parseInt(documentId),
|
||||
emojiIdUpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Frozen snowflake emoji ID updated',
|
||||
documentId: parseInt(documentId)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting emoji ID:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,350 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import Joi from 'joi';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import minioHelper from '../utils/minioHelper.js';
|
||||
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
const router = express.Router();
|
||||
|
||||
// --- Configuration ---
|
||||
const UPLOAD_DIR = 'uploads/stickers';
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_MIME_TYPES = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
|
||||
const ALLOWED_EXTENSIONS = ['.json', '.tgs'];
|
||||
|
||||
// --- Multer Setup ---
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1E9)}`;
|
||||
cb(null, `sticker-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: MAX_FILE_SIZE },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ALLOWED_EXTENSIONS.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .json and .tgs files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
const toJSON = (data) => {
|
||||
return JSON.parse(JSON.stringify(data, (key, value) => {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (value && typeof value === 'object' && (value._bsontype === 'Long' || (value.low !== undefined && value.high !== undefined))) {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
}));
|
||||
};
|
||||
|
||||
const generateId = () => Date.now() * 10000 + Math.floor(Math.random() * 10000);
|
||||
const generateAccessHash = () => Date.now() * 100000 + Math.floor(Math.random() * 100000);
|
||||
|
||||
// --- Validation ---
|
||||
const giftSchema = Joi.object({
|
||||
giftId: Joi.alt([Joi.number().integer().positive(), Joi.string().pattern(/^\d+$/)]).required(),
|
||||
|
||||
stars: Joi.number().integer().positive().required(),
|
||||
|
||||
convertStars: Joi.number().integer().positive()
|
||||
.less(Joi.ref('stars'))
|
||||
.messages({
|
||||
'number.less': 'convertStars must be less than stars'
|
||||
})
|
||||
.required(),
|
||||
|
||||
limited: Joi.boolean().default(false),
|
||||
soldOut: Joi.boolean().default(false),
|
||||
birthday: Joi.boolean().default(false),
|
||||
requirePremium: Joi.boolean().default(false),
|
||||
limitedPerUser: Joi.boolean().default(false),
|
||||
|
||||
availabilityTotal: Joi.number().integer().positive().optional(),
|
||||
|
||||
availabilityRemains: Joi.number().integer().min(0).max(Joi.ref('availabilityTotal')).optional(),
|
||||
|
||||
perUserTotal: Joi.when('limitedPerUser', {
|
||||
is: true,
|
||||
then: Joi.number().integer().positive().required(),
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
|
||||
upgradeStars: Joi.number().integer().positive().optional().allow(null),
|
||||
title: Joi.string().max(100).optional().allow(null, ''),
|
||||
resellMinStars: Joi.number().integer().positive().optional().allow(null),
|
||||
|
||||
stickerId: Joi.alt([Joi.number().integer().positive(), Joi.string().pattern(/^\d+$/)]).optional(),
|
||||
|
||||
releasedBy: Joi.object({
|
||||
type: Joi.string().valid('user', 'channel').required(),
|
||||
id: Joi.number().integer().positive().required()
|
||||
}).optional().allow(null),
|
||||
|
||||
// Эти поля не должны устанавливаться вручную
|
||||
firstSaleDate: Joi.forbidden(),
|
||||
lastSaleDate: Joi.forbidden()
|
||||
});
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
// GET All Gifts
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { soldOut, limited, sort = 'stars' } = req.query;
|
||||
const filter = {};
|
||||
if (soldOut !== undefined) filter.SoldOut = soldOut === 'true';
|
||||
if (limited !== undefined) filter.Limited = limited === 'true';
|
||||
|
||||
const sortOptions = {};
|
||||
if (sort === 'stars') sortOptions.Stars = 1;
|
||||
else if (sort === 'date') sortOptions._id = -1;
|
||||
else if (sort === 'availability') sortOptions.AvailabilityRemains = 1;
|
||||
|
||||
const gifts = await req.db.collection('AvailableStarGiftReadModel')
|
||||
.find(filter).sort(sortOptions).toArray();
|
||||
|
||||
res.json(toJSON(gifts));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET Gift by ID
|
||||
router.get('/:giftId', async (req, res) => {
|
||||
try {
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const gift = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: giftId });
|
||||
if (!gift) return res.status(404).json({ error: 'Gift not found' });
|
||||
res.json(toJSON(gift));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST Create Gift
|
||||
router.post('/', upload.single('sticker'), async (req, res) => {
|
||||
let uploadedFilePath = null;
|
||||
try {
|
||||
const giftData = typeof req.body.data === 'string' ? JSON.parse(req.body.data) : req.body;
|
||||
const { error, value } = giftSchema.validate(giftData);
|
||||
if (error) return res.status(400).json({ error: error.details[0].message });
|
||||
|
||||
const safeGiftId = BigInt(value.giftId);
|
||||
const existing = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: safeGiftId });
|
||||
if (existing) return res.status(409).json({ error: 'Gift with this ID already exists' });
|
||||
|
||||
let stickerDocumentId = null;
|
||||
if (req.file) {
|
||||
uploadedFilePath = req.file.path;
|
||||
const fileBuffer = await fs.readFile(uploadedFilePath);
|
||||
|
||||
// Проверяем TGS
|
||||
try {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
JSON.parse(decompressed.toString('utf8'));
|
||||
} catch (e) {
|
||||
try {
|
||||
JSON.parse(fileBuffer.toString('utf8'));
|
||||
} catch (jsonErr) {
|
||||
throw new Error('Invalid TGS file');
|
||||
}
|
||||
}
|
||||
|
||||
const documentId = generateId();
|
||||
const accessHash = generateAccessHash();
|
||||
|
||||
await minioHelper.uploadFile(uploadedFilePath, documentId, 'application/x-tgsticker');
|
||||
|
||||
// Правильный FileReference на 24 байта вместо 8
|
||||
const fileRefBuffer = Buffer.allocUnsafe(24);
|
||||
fileRefBuffer.writeUInt8(2, 0); // версия 2
|
||||
fileRefBuffer.writeUInt8(2, 1); // ID дата-центра
|
||||
fileRefBuffer.writeBigInt64BE(BigInt(documentId), 2); // ID документа (8 байт)
|
||||
fileRefBuffer.writeBigInt64BE(BigInt(accessHash), 10); // access hash (8 байт)
|
||||
fileRefBuffer.writeUInt32BE(Math.floor(Date.now() / 1000), 18); // метка времени (4 байта)
|
||||
fileRefBuffer.writeUInt16BE(Math.floor(Math.random() * 0xFFFF), 22); // случайные данные (2 байта)
|
||||
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`,
|
||||
Id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
Version: 1,
|
||||
AccessHash: accessHash,
|
||||
FileReference: fileRefBuffer,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: fileBuffer.length,
|
||||
Name: `${documentId}`,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null, // null вместо хардкода — Telegram сгенерирует сам
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Attributes: null,
|
||||
Attributes2: [
|
||||
{ _t: 'TDocumentAttributeImageSize', W: 512, H: 512 },
|
||||
{
|
||||
_t: 'TDocumentAttributeSticker',
|
||||
Alt: '🎁',
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetEmpty'
|
||||
},
|
||||
Mask: false
|
||||
},
|
||||
{ _t: 'TDocumentAttributeAnimated' },
|
||||
{ _t: 'TDocumentAttributeFilename', FileName: 'sticker.tgs' }
|
||||
]
|
||||
};
|
||||
|
||||
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
|
||||
stickerDocumentId = documentId;
|
||||
await fs.unlink(uploadedFilePath);
|
||||
uploadedFilePath = null;
|
||||
} else if (value.stickerId) {
|
||||
stickerDocumentId = BigInt(value.stickerId);
|
||||
}
|
||||
|
||||
const gift = {
|
||||
_id: value.giftId.toString(),
|
||||
GiftId: safeGiftId,
|
||||
Limited: value.limited,
|
||||
SoldOut: value.soldOut,
|
||||
Birthday: value.birthday,
|
||||
RequirePremium: value.requirePremium || false, // берём из value
|
||||
LimitedPerUser: value.limitedPerUser || false, // берём из value
|
||||
Stars: value.stars,
|
||||
ConvertStars: value.convertStars,
|
||||
UpgradeStars: value.upgradeStars || null,
|
||||
AvailabilityTotal: value.availabilityTotal || null,
|
||||
AvailabilityRemains: value.availabilityRemains ?? value.availabilityTotal ?? null,
|
||||
AvailabilityResale: null,
|
||||
FirstSaleDate: null, // null — установится при первой покупке
|
||||
LastSaleDate: null, // null — установится при покупке
|
||||
Title: value.title || null, // null вместо пустой строки
|
||||
ResellMinStars: value.resellMinStars || null,
|
||||
Sticker: stickerDocumentId,
|
||||
ReleasedBy: value.releasedBy ? { // поддержка ReleasedBy
|
||||
Type: value.releasedBy.type,
|
||||
Id: value.releasedBy.id
|
||||
} : null,
|
||||
PerUserTotal: value.limitedPerUser ? value.perUserTotal : null, // берём из value
|
||||
PerUserRemains: value.limitedPerUser ? value.perUserTotal : null, // берём из value
|
||||
LockedUntilDate: null,
|
||||
Version: 1
|
||||
};
|
||||
|
||||
await req.db.collection('AvailableStarGiftReadModel').insertOne(gift);
|
||||
res.status(201).json(toJSON(gift));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Create gift error:', error);
|
||||
if (uploadedFilePath) await fs.unlink(uploadedFilePath).catch(() => { });
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT Update Gift
|
||||
router.put('/:giftId', async (req, res) => {
|
||||
try {
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const giftData = req.body;
|
||||
delete giftData.giftId;
|
||||
|
||||
const result = await req.db.collection('AvailableStarGiftReadModel').findOneAndUpdate(
|
||||
{ GiftId: giftId },
|
||||
{ $set: { ...giftData, UpdatedAt: new Date() } },
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) return res.status(404).json({ error: 'Gift not found' });
|
||||
res.json(toJSON(result));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE Gift
|
||||
router.delete('/:giftId', async (req, res) => {
|
||||
try {
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const result = await req.db.collection('AvailableStarGiftReadModel').deleteOne({ GiftId: giftId });
|
||||
if (result.deletedCount === 0) return res.status(404).json({ error: 'Gift not found' });
|
||||
res.json({ message: 'Gift deleted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST Send Gift to User (Admin/Test)
|
||||
router.post('/:giftId/send-to-user', async (req, res) => {
|
||||
try {
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const { userId, fromUserId, message, nameHidden, count } = req.body;
|
||||
if (!userId) return res.status(400).json({ error: 'userId is required' });
|
||||
|
||||
const gift = await req.db.collection('AvailableStarGiftReadModel').findOne({ GiftId: giftId });
|
||||
if (!gift) return res.status(404).json({ error: 'Gift not found' });
|
||||
|
||||
const fromId = fromUserId ? Number(fromUserId) : 0;
|
||||
if (!fromId || fromId <= 0) return res.status(400).json({ success: false, error: 'fromUserId is required (stars will be spent)' });
|
||||
|
||||
const payload = {
|
||||
fromUserId: fromId,
|
||||
toUserId: parseInt(userId),
|
||||
giftId: Number(giftId),
|
||||
count: count || 1,
|
||||
nameHidden: !!nameHidden,
|
||||
canUpgrade: null,
|
||||
message: message || null
|
||||
};
|
||||
|
||||
const adminUrl = process.env.ADMIN_API_URL || 'http://localhost:5000';
|
||||
const adminKey = process.env.ADMIN_API_KEY || '';
|
||||
|
||||
const response = await fetch(`${adminUrl}/api/admin/star-gifts/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-API-Key': adminKey
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const rawText = await response.text();
|
||||
let data = {};
|
||||
try {
|
||||
data = rawText ? JSON.parse(rawText) : {};
|
||||
} catch {
|
||||
data = { error: rawText };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Send gift error:', { status: response.status, body: data });
|
||||
return res.status(response.status).json(data);
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, ...data });
|
||||
} catch (error) {
|
||||
console.error('Send gift error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,588 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import Joi from 'joi';
|
||||
import zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
import { Long } from 'mongodb';
|
||||
import minioHelper from '../utils/minioHelper.js';
|
||||
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// File upload configuration
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = 'uploads/stickers';
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, `sticker-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
|
||||
const allowedExts = ['.json', '.tgs'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .json and .tgs files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to handle BigInt and BSON Long serialization
|
||||
const toJSON = (data) => {
|
||||
return JSON.parse(JSON.stringify(data, (key, value) => {
|
||||
// Handle native BigInt
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
// Handle BSON Long objects (which have low, high, unsigned properties)
|
||||
if (value && typeof value === 'object' && (value._bsontype === 'Long' || (value.hasOwnProperty('low') && value.hasOwnProperty('high')))) {
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
}));
|
||||
};
|
||||
|
||||
// Validation schemas
|
||||
const giftSchema = Joi.object({
|
||||
// ✅ FIX: Allow string input for large 64-bit IDs
|
||||
giftId: Joi.alt([
|
||||
Joi.number().integer().positive(),
|
||||
Joi.string().pattern(/^\d+$/)
|
||||
]).required(),
|
||||
limited: Joi.boolean().default(false),
|
||||
soldOut: Joi.boolean().default(false),
|
||||
birthday: Joi.boolean().default(false),
|
||||
stars: Joi.number().integer().positive().required(),
|
||||
convertStars: Joi.number().integer().positive().required(),
|
||||
upgradeStars: Joi.number().integer().positive().optional(),
|
||||
availabilityTotal: Joi.number().integer().positive().optional().allow(null),
|
||||
availabilityRemains: Joi.number().integer().min(0).optional().allow(null),
|
||||
firstSaleDate: Joi.number().integer().optional().allow(null),
|
||||
lastSaleDate: Joi.number().integer().optional().allow(null),
|
||||
title: Joi.string().max(100).optional().allow(null, ''),
|
||||
resellMinStars: Joi.number().integer().positive().optional().allow(null)
|
||||
});
|
||||
|
||||
// GET all gifts
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { soldOut, limited, sort = 'stars' } = req.query;
|
||||
|
||||
const filter = {};
|
||||
if (soldOut !== undefined) filter.SoldOut = soldOut === 'true';
|
||||
if (limited !== undefined) filter.Limited = limited === 'true';
|
||||
|
||||
const sortOptions = {};
|
||||
if (sort === 'stars') sortOptions.Stars = 1;
|
||||
else if (sort === 'date') sortOptions._id = -1;
|
||||
else if (sort === 'availability') sortOptions.AvailabilityRemains = 1;
|
||||
|
||||
const gifts = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.find(filter)
|
||||
.sort(sortOptions)
|
||||
.toArray();
|
||||
|
||||
res.json(toJSON(gifts));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET gift by ID
|
||||
router.get('/:giftId', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt for large IDs
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const gift = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOne({ GiftId: giftId });
|
||||
|
||||
if (!gift) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
res.json(toJSON(gift));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create new gift
|
||||
router.post('/', upload.single('sticker'), async (req, res) => {
|
||||
try {
|
||||
// Parse JSON data from form
|
||||
const giftData = typeof req.body.data === 'string'
|
||||
? JSON.parse(req.body.data)
|
||||
: req.body;
|
||||
|
||||
// Validate
|
||||
const { error, value } = giftSchema.validate(giftData);
|
||||
if (error) {
|
||||
return res.status(400).json({ error: error.details[0].message });
|
||||
}
|
||||
|
||||
// ✅ FIX: Convert to BigInt for DB check
|
||||
const safeGiftId = BigInt(value.giftId);
|
||||
|
||||
// Check if gift ID already exists
|
||||
const existing = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOne({ GiftId: safeGiftId });
|
||||
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Gift with this ID already exists' });
|
||||
}
|
||||
|
||||
// ✅ Upload sticker file to MinIO and create DocumentReadModel
|
||||
let stickerDocumentId = null;
|
||||
if (req.file) {
|
||||
try {
|
||||
console.log(`📤 Uploading sticker: ${req.file.originalname}, Size: ${req.file.size} bytes`);
|
||||
|
||||
// Validate TGS file
|
||||
const fileBuffer = await fs.readFile(req.file.path);
|
||||
|
||||
// Try to decompress, if fails assume it's already JSON
|
||||
let json;
|
||||
try {
|
||||
const decompressed = await gunzip(fileBuffer);
|
||||
json = JSON.parse(decompressed.toString('utf8'));
|
||||
} catch (error) {
|
||||
// If gunzip fails, try parsing as plain JSON
|
||||
try {
|
||||
json = JSON.parse(fileBuffer.toString('utf8'));
|
||||
console.log('⚠️ File is not gzipped, treating as plain JSON');
|
||||
} catch (jsonError) {
|
||||
throw new Error('Invalid TGS file: not gzipped JSON or plain JSON');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate document ID for sticker
|
||||
const documentId = Date.now() * 10000 + Math.floor(Math.random() * 10000);
|
||||
const accessHash = Date.now() * 100000 + Math.floor(Math.random() * 100000);
|
||||
|
||||
// Upload to MinIO (same format as emoji - root path, no extension)
|
||||
await minioHelper.uploadFile(req.file.path, documentId, 'application/x-tgsticker');
|
||||
console.log(`✅ Uploaded sticker to MinIO: ${documentId}`);
|
||||
|
||||
// Generate FileReference (8 bytes)
|
||||
const fileRefBuffer = Buffer.allocUnsafe(8);
|
||||
fileRefBuffer.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
|
||||
fileRefBuffer.writeUInt32BE(Math.floor(Math.random() * 0xFFFFFFFF), 4);
|
||||
|
||||
// Create DocumentReadModel (same as emoji)
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`, // EventFlow ID format
|
||||
Id: `document-${documentId}`, // EventFlow requires this
|
||||
DocumentId: documentId,
|
||||
Version: 1,
|
||||
AccessHash: accessHash,
|
||||
FileReference: fileRefBuffer,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: fileBuffer.length,
|
||||
Name: `${documentId}`,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: [
|
||||
{
|
||||
Type: 'i',
|
||||
// Minimal valid stripped thumbnail (blue-ish placeholder)
|
||||
Bytes: Buffer.from([0x01, 0x08, 0x08, 0x2a, 0x8c, 0x47, 0x92, 0x48, 0x20])
|
||||
}
|
||||
],
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Attributes: null,
|
||||
Attributes2: [
|
||||
{
|
||||
_t: 'TDocumentAttributeImageSize',
|
||||
W: 512,
|
||||
H: 512
|
||||
},
|
||||
{
|
||||
_t: 'TDocumentAttributeAnimated' // ✅ CRITICAL for TGS stickers!
|
||||
},
|
||||
{
|
||||
_t: 'TDocumentAttributeSticker',
|
||||
Alt: '🎁',
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetShortName',
|
||||
ShortName: 'premium_gifts'
|
||||
},
|
||||
Mask: false
|
||||
},
|
||||
{
|
||||
_t: 'TDocumentAttributeFilename',
|
||||
FileName: 'sticker.tgs'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// NOTE: Use EventFlow naming convention for ReadModel collections
|
||||
await req.db.collection('ReadModel-DocumentReadModel').insertOne(documentReadModel);
|
||||
console.log(`✅ Created DocumentReadModel for sticker: ${documentId}`);
|
||||
|
||||
stickerDocumentId = documentId;
|
||||
|
||||
// Cleanup temp file
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
|
||||
// Parse JSON data
|
||||
const giftData = typeof req.body.data === 'string'
|
||||
? JSON.parse(req.body.data)
|
||||
: req.body;
|
||||
|
||||
const updateData = { ...giftData };
|
||||
delete updateData.giftId; // Don't allow changing ID
|
||||
|
||||
// Read new sticker if uploaded - store as Buffer not base64
|
||||
if (req.file) {
|
||||
const fileBuffer = await fs.readFile(req.file.path);
|
||||
updateData.Sticker = fileBuffer; // Store as Binary (Buffer)
|
||||
}
|
||||
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ GiftId: giftId },
|
||||
{
|
||||
$set: {
|
||||
...updateData,
|
||||
UpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
res.json(toJSON(result));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE gift
|
||||
router.delete('/:giftId', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.deleteOne({ GiftId: giftId });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Gift deleted successfully' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH mark as sold out
|
||||
router.patch('/:giftId/soldout', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const { soldOut } = req.body;
|
||||
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ GiftId: giftId },
|
||||
{
|
||||
$set: {
|
||||
SoldOut: soldOut,
|
||||
LastSaleDate: soldOut ? Math.floor(Date.now() / 1000) : null,
|
||||
UpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
res.json(toJSON(result));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE all gifts (clear collection)
|
||||
router.delete('/', async (req, res) => {
|
||||
try {
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.deleteMany({});
|
||||
|
||||
res.json({
|
||||
message: 'All gifts deleted successfully',
|
||||
deletedCount: result.deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH decrease availability
|
||||
router.patch('/:giftId/purchase', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
|
||||
const gift = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOne({ GiftId: giftId });
|
||||
|
||||
if (!gift) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
if (!gift.Limited) {
|
||||
return res.status(400).json({ error: 'Gift is not limited' });
|
||||
}
|
||||
|
||||
if (gift.AvailabilityRemains <= 0) {
|
||||
return res.status(400).json({ error: 'Gift sold out' });
|
||||
}
|
||||
|
||||
const result = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ GiftId: giftId },
|
||||
{
|
||||
$inc: { AvailabilityRemains: -1 },
|
||||
$set: {
|
||||
UpdatedAt: new Date(),
|
||||
SoldOut: gift.AvailabilityRemains - 1 <= 0
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
res.json(toJSON(result));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST send gift to user (admin function)
|
||||
router.post('/:giftId/send-to-user', async (req, res) => {
|
||||
try {
|
||||
// ✅ FIX: Use BigInt
|
||||
const giftId = BigInt(req.params.giftId);
|
||||
const { userId, fromUserId, message, nameHidden } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'userId is required' });
|
||||
}
|
||||
|
||||
// Get gift from catalog
|
||||
const gift = await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.findOne({ GiftId: giftId });
|
||||
|
||||
if (!gift) {
|
||||
return res.status(404).json({ error: 'Gift not found' });
|
||||
}
|
||||
|
||||
// Generate unique messageId (small int32 compatible)
|
||||
// Use random number within int32 range: 1 to 2147483647
|
||||
const messageId = Math.floor(Math.random() * 2000000000) + 1000000;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Create received gift record
|
||||
const receivedGift = {
|
||||
_id: `${userId}_${messageId}`,
|
||||
Id: `${userId}_${messageId}`,
|
||||
GiftId: giftId, // ✅ Store as BigInt
|
||||
FromUserId: fromUserId || 0, // 0 = system/admin
|
||||
ToUserId: parseInt(userId),
|
||||
ToPeerId: null,
|
||||
MessageId: messageId,
|
||||
Stars: gift.Stars,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
Message: message || null,
|
||||
NameHidden: nameHidden || false,
|
||||
Saved: true, // Auto-save admin gifts
|
||||
Converted: false,
|
||||
Upgraded: false,
|
||||
Refunded: false,
|
||||
CanUpgrade: gift.UpgradeStars ? true : false,
|
||||
Pinned: false,
|
||||
SavedId: null,
|
||||
UpgradeStars: gift.UpgradeStars,
|
||||
UpgradeMsgId: null,
|
||||
Date: now,
|
||||
ConvertDate: null,
|
||||
UpgradeDate: null,
|
||||
StickerDocumentId: gift.Sticker, // DocumentId for sticker loading
|
||||
Title: gift.Title || '', // Gift title
|
||||
Version: 1
|
||||
};
|
||||
|
||||
// Insert into eventflow-stargiftreadmodel collection
|
||||
await req.db
|
||||
.collection('eventflow-stargiftreadmodel')
|
||||
.insertOne(receivedGift);
|
||||
|
||||
// Create inbox message for the recipient with ALL required fields
|
||||
const inboxMessage = {
|
||||
_id: `message-${userId}_${messageId}`,
|
||||
Id: `message-${userId}_${messageId}`,
|
||||
OwnerPeerId: parseInt(userId),
|
||||
ToPeerId: parseInt(userId),
|
||||
ToPeerType: 1, // User
|
||||
SenderUserId: fromUserId || 0,
|
||||
SenderPeerId: fromUserId || 0,
|
||||
SenderMessageId: 0,
|
||||
MessageId: messageId,
|
||||
Message: '',
|
||||
Date: now,
|
||||
Out: false, // Inbox message
|
||||
Pinned: false,
|
||||
Post: false,
|
||||
Silent: false,
|
||||
Pts: 0, // Will be set by server
|
||||
Views: null,
|
||||
Replies: 0,
|
||||
EditDate: null,
|
||||
EditHide: false,
|
||||
PostAuthor: null,
|
||||
GroupedId: null,
|
||||
ReplyToMsgId: null,
|
||||
TopMsgId: null,
|
||||
FwdHeader: null,
|
||||
Media: null,
|
||||
Media2: null,
|
||||
Entities: null,
|
||||
Entities2: null,
|
||||
ReplyMarkup: null,
|
||||
ReplyMarkup2: null,
|
||||
ReplyTo: null,
|
||||
SendAs: null,
|
||||
Reply: null,
|
||||
MessageType: 1, // Text
|
||||
SendMessageType: 1, // MessageService
|
||||
MessageActionType: 19, // CustomAction
|
||||
MessageActionData: JSON.stringify({
|
||||
_t: 'TMessageActionStarGift',
|
||||
NameHidden: nameHidden || false,
|
||||
Saved: false,
|
||||
Converted: false,
|
||||
Upgraded: false,
|
||||
Refunded: false,
|
||||
CanUpgrade: gift.UpgradeStars ? true : false,
|
||||
Gift: {
|
||||
Id: giftId.toString(), // ✅ Serialize BigInt to string for JSON
|
||||
Stars: gift.Stars,
|
||||
Title: gift.Title
|
||||
},
|
||||
Message: message ? {
|
||||
Text: message,
|
||||
Entities: []
|
||||
} : null,
|
||||
ConvertStars: gift.ConvertStars,
|
||||
UpgradeStars: gift.UpgradeStars
|
||||
}),
|
||||
LinkedChannelId: null,
|
||||
SavedFromMsgId: null,
|
||||
SavedFromPeerId: null,
|
||||
SavedPeerId: null,
|
||||
PollId: null,
|
||||
PostChannelId: null,
|
||||
PostMessageId: null,
|
||||
IsQuickReplyMessage: false,
|
||||
ShortcutId: null,
|
||||
QuickReplyItem: null,
|
||||
BatchId: null,
|
||||
Effect: null,
|
||||
FromScheduled: false,
|
||||
ScheduleDate: null,
|
||||
TtlPeriod: null,
|
||||
Reactions: null,
|
||||
RecentReactions2: null,
|
||||
RecentReactions: null,
|
||||
TopReactors: null,
|
||||
CanSeeList: false,
|
||||
ExpirationTime: null,
|
||||
InvertMedia: false,
|
||||
PublicPosts: false,
|
||||
Hashtags: [],
|
||||
MentionedUserIds: null,
|
||||
TodoId: null,
|
||||
Version: 1
|
||||
};
|
||||
|
||||
// Insert inbox message into ReadModel-MessageReadModel
|
||||
await req.db
|
||||
.collection('ReadModel-MessageReadModel')
|
||||
.insertOne(inboxMessage);
|
||||
|
||||
// Decrease availability if limited
|
||||
if (gift.Limited && gift.AvailabilityRemains > 0) {
|
||||
await req.db
|
||||
.collection('AvailableStarGiftReadModel')
|
||||
.updateOne(
|
||||
{ GiftId: giftId },
|
||||
{
|
||||
$inc: { AvailabilityRemains: -1 },
|
||||
$set: {
|
||||
UpdatedAt: new Date(),
|
||||
SoldOut: gift.AvailabilityRemains - 1 <= 0
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: `Gift ${giftId} sent to user ${userId}`,
|
||||
warning: 'Gift inserted into database. Restart Query Server to see it in user profile.',
|
||||
receivedGift: {
|
||||
userId: parseInt(userId),
|
||||
giftId: giftId.toString(), // ✅ Serialize BigInt
|
||||
messageId: messageId,
|
||||
stars: gift.Stars,
|
||||
title: gift.Title
|
||||
},
|
||||
instructions: {
|
||||
step1: 'Gift has been added to database',
|
||||
step2: 'To make it visible in user profile, restart Query Server:',
|
||||
command: 'docker restart messenger-query-server-1'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Send gift error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,391 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { MongoClient, ObjectId } from 'mongodb';
|
||||
import AdmZip from 'adm-zip';
|
||||
import pako from 'pako';
|
||||
import * as Minio from 'minio';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Клиент MinIO
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'test',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '12345678'
|
||||
});
|
||||
|
||||
const BUCKET_NAME = process.env.MINIO_BUCKET || 'tg-files';
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 100 * 1024 * 1024 } // 100MB
|
||||
});
|
||||
|
||||
const MONGO_URL = process.env.MONGODB_URI || 'mongodb://localhost:27017';
|
||||
const DB_NAME = process.env.DB_NAME || 'tg';
|
||||
|
||||
// Список всех реакций
|
||||
router.get('/', async (req, res) => {
|
||||
const client = new MongoClient(MONGO_URL);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const db = client.db(DB_NAME);
|
||||
const collection = db.collection('reactions');
|
||||
|
||||
const reactions = await collection.find({}).toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reactions: reactions.map(r => ({
|
||||
id: r._id.toString(),
|
||||
emoji: r.emoji,
|
||||
title: r.title,
|
||||
premium: r.premium || false,
|
||||
inactive: r.inactive || false,
|
||||
hasAllAnimations: !!(r.staticIcon && r.appearAnimation && r.selectAnimation && r.activateAnimation && r.effectAnimation),
|
||||
selectAnimation: r.selectAnimationPreview
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching reactions:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch reactions' });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Создать реакцию
|
||||
router.post('/', upload.fields([
|
||||
{ name: 'staticIcon', maxCount: 1 },
|
||||
{ name: 'appearAnimation', maxCount: 1 },
|
||||
{ name: 'selectAnimation', maxCount: 1 },
|
||||
{ name: 'activateAnimation', maxCount: 1 },
|
||||
{ name: 'effectAnimation', maxCount: 1 },
|
||||
{ name: 'aroundAnimation', maxCount: 1 },
|
||||
{ name: 'centerIcon', maxCount: 1 }
|
||||
]), async (req, res) => {
|
||||
const client = new MongoClient(MONGO_URL);
|
||||
|
||||
try {
|
||||
const data = JSON.parse(req.body.data);
|
||||
await client.connect();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
// Загружаем анимации в MinIO и создаём документы
|
||||
const animations = {};
|
||||
const animationFields = [
|
||||
'staticIcon', 'appearAnimation', 'selectAnimation',
|
||||
'activateAnimation', 'effectAnimation', 'aroundAnimation', 'centerIcon'
|
||||
];
|
||||
|
||||
for (const field of animationFields) {
|
||||
if (req.files[field] && req.files[field][0]) {
|
||||
const file = req.files[field][0];
|
||||
const documentId = Date.now() + Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Загружаем в MinIO (file-server ожидает файлы в корне: {documentId})
|
||||
await minioClient.putObject(BUCKET_NAME, `${documentId}`, file.buffer, file.size, {
|
||||
'Content-Type': 'application/x-tgsticker'
|
||||
});
|
||||
|
||||
// Создаём документ в MongoDB
|
||||
const fileReference = Buffer.from([
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
(documentId >> 24) & 0xFF,
|
||||
(documentId >> 16) & 0xFF,
|
||||
(documentId >> 8) & 0xFF,
|
||||
documentId & 0xFF
|
||||
]);
|
||||
|
||||
const documentReadModelId = `document-${documentId}`;
|
||||
const document = {
|
||||
_id: documentReadModelId,
|
||||
Id: documentReadModelId,
|
||||
Version: 1,
|
||||
DocumentId: documentId,
|
||||
AccessHash: documentId,
|
||||
FileReference: fileReference,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: file.size,
|
||||
Name: `${documentId}`,
|
||||
DcId: 2,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null,
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Attributes: null,
|
||||
Attributes2: [{
|
||||
_t: 'TDocumentAttributeSticker',
|
||||
Alt: data.emoji || '😀',
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetEmpty'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
|
||||
animations[field] = documentId;
|
||||
|
||||
// Сохраняем превью для анимации выбора
|
||||
if (field === 'selectAnimation') {
|
||||
try {
|
||||
const decompressed = pako.inflate(file.buffer, { to: 'string' });
|
||||
animations.selectAnimationPreview = JSON.parse(decompressed);
|
||||
} catch (e) {
|
||||
console.log('Could not create preview:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём реакцию
|
||||
const reaction = {
|
||||
emoji: data.emoji,
|
||||
title: data.title,
|
||||
premium: data.premium || false,
|
||||
inactive: data.inactive || false,
|
||||
...animations,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
const result = await db.collection('reactions').insertOne(reaction);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
reactionId: result.insertedId.toString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating reaction:', error);
|
||||
res.status(500).json({ error: 'Failed to create reaction' });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Массовая загрузка из ZIP (по папкам: emoji/asset.ext)
|
||||
router.post('/bulk-upload', upload.single('zipFile'), async (req, res) => {
|
||||
const client = new MongoClient(MONGO_URL);
|
||||
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No ZIP file provided' });
|
||||
}
|
||||
|
||||
const premium = req.body.premium === 'true';
|
||||
const zip = new AdmZip(req.file.buffer);
|
||||
const zipEntries = zip.getEntries();
|
||||
|
||||
await client.connect();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
const results = [];
|
||||
const reactionsMap = new Map(); // Map<emoji, { assets: {}, title: string }>
|
||||
|
||||
// 1. Группируем файлы по эмодзи (имени папки)
|
||||
for (const entry of zipEntries) {
|
||||
if (entry.isDirectory || entry.entryName.startsWith('__MACOSX')) continue;
|
||||
|
||||
// Приводим разделители пути к единому виду
|
||||
const entryPath = entry.entryName.replace(/\\/g, '/');
|
||||
const parts = entryPath.split('/');
|
||||
|
||||
// Ожидаем как минимум папку и файл: folder/file.ext
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
// Папка с эмодзи — это непосредственный родитель файла
|
||||
const emojiFolder = parts[parts.length - 2];
|
||||
const filename = parts[parts.length - 1];
|
||||
const nameWithoutExt = filename.split('.')[0];
|
||||
|
||||
// Проверяем тип ассета
|
||||
const validAssets = [
|
||||
'static_icon', 'appear_animation', 'select_animation',
|
||||
'activate_animation', 'effect_animation', 'around_animation', 'center_icon'
|
||||
];
|
||||
|
||||
if (!validAssets.includes(nameWithoutExt)) continue;
|
||||
|
||||
if (!reactionsMap.has(emojiFolder)) {
|
||||
reactionsMap.set(emojiFolder, {
|
||||
emoji: emojiFolder,
|
||||
assets: {}
|
||||
});
|
||||
}
|
||||
|
||||
const reactionData = reactionsMap.get(emojiFolder);
|
||||
reactionData.assets[nameWithoutExt] = {
|
||||
buffer: entry.getData(),
|
||||
filename: filename
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Обрабатываем каждую реакцию
|
||||
for (const [emoji, data] of reactionsMap) {
|
||||
try {
|
||||
console.log(`Processing reaction: ${emoji}`);
|
||||
const animationIds = {};
|
||||
|
||||
// Загружаем ассеты
|
||||
for (const [assetType, fileData] of Object.entries(data.assets)) {
|
||||
const documentId = Date.now() + Math.floor(Math.random() * 1000000);
|
||||
|
||||
// Определяем MIME-тип по расширению
|
||||
const ext = fileData.filename.split('.').pop().toLowerCase();
|
||||
let mimeType = 'application/x-tgsticker'; // по умолчанию для TGS
|
||||
if (ext === 'webm') mimeType = 'video/webm';
|
||||
if (ext === 'webp') mimeType = 'image/webp';
|
||||
if (ext === 'png') mimeType = 'image/png';
|
||||
|
||||
// Загружаем в MinIO
|
||||
await minioClient.putObject(BUCKET_NAME, `${documentId}`, fileData.buffer, fileData.buffer.length, {
|
||||
'Content-Type': mimeType
|
||||
});
|
||||
|
||||
// Создаём DocumentReadModel
|
||||
const fileReference = Buffer.from([
|
||||
0x01, 0x00, 0x00, 0x00,
|
||||
(documentId >> 24) & 0xFF,
|
||||
(documentId >> 16) & 0xFF,
|
||||
(documentId >> 8) & 0xFF,
|
||||
documentId & 0xFF
|
||||
]);
|
||||
|
||||
const documentReadModelId = `document-${documentId}`;
|
||||
const document = {
|
||||
_id: documentReadModelId,
|
||||
Id: documentReadModelId,
|
||||
Version: 1,
|
||||
DocumentId: documentId,
|
||||
AccessHash: documentId,
|
||||
FileReference: fileReference,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: mimeType,
|
||||
Size: fileData.buffer.length,
|
||||
Name: `${documentId}`,
|
||||
DcId: 2,
|
||||
Md5CheckSum: null,
|
||||
CreatorId: null,
|
||||
Thumbs: null,
|
||||
ThumbId: null,
|
||||
VideoThumbs: null,
|
||||
VideoThumbId: null,
|
||||
Attributes: null,
|
||||
Attributes2: [{
|
||||
_t: 'TDocumentAttributeSticker',
|
||||
Alt: emoji,
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetEmpty'
|
||||
}
|
||||
}]
|
||||
};
|
||||
|
||||
await db.collection('ReadModel-DocumentReadModel').insertOne(document);
|
||||
animationIds[assetType] = documentId;
|
||||
|
||||
// Сохраняем превью для анимации выбора
|
||||
if (assetType === 'selectAnimation' && mimeType === 'application/x-tgsticker') {
|
||||
try {
|
||||
const decompressed = pako.inflate(fileData.buffer, { to: 'string' });
|
||||
animationIds.selectAnimationPreview = JSON.parse(decompressed);
|
||||
} catch (e) {
|
||||
console.log(`Could not create preview for ${emoji}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Создаём или обновляем реакцию
|
||||
const reaction = {
|
||||
emoji: emoji,
|
||||
title: emoji,
|
||||
premium: premium,
|
||||
inactive: false,
|
||||
...animationIds,
|
||||
createdAt: new Date()
|
||||
};
|
||||
|
||||
// Проверяем, есть ли уже такая реакция
|
||||
const existing = await db.collection('reactions').findOne({ emoji: emoji });
|
||||
if (existing) {
|
||||
await db.collection('reactions').updateOne({ _id: existing._id }, { $set: reaction });
|
||||
} else {
|
||||
await db.collection('reactions').insertOne(reaction);
|
||||
}
|
||||
|
||||
results.push({
|
||||
success: true,
|
||||
emoji: emoji,
|
||||
assets: Object.keys(animationIds)
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error processing ${emoji}:`, error);
|
||||
results.push({
|
||||
success: false,
|
||||
emoji: emoji,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error bulk uploading reactions:', error);
|
||||
res.status(500).json({ error: 'Failed to process ZIP file' });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить реакцию
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const client = new MongoClient(MONGO_URL);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const db = client.db(DB_NAME);
|
||||
|
||||
const reaction = await db.collection('reactions').findOne({ _id: new ObjectId(req.params.id) });
|
||||
|
||||
if (!reaction) {
|
||||
return res.status(404).json({ error: 'Reaction not found' });
|
||||
}
|
||||
|
||||
// Удаляем документы анимаций из MinIO и MongoDB
|
||||
const animationFields = [
|
||||
'staticIcon', 'appearAnimation', 'selectAnimation',
|
||||
'activateAnimation', 'effectAnimation', 'aroundAnimation', 'centerIcon'
|
||||
];
|
||||
|
||||
for (const field of animationFields) {
|
||||
if (reaction[field]) {
|
||||
try {
|
||||
await minioClient.removeObject(BUCKET_NAME, `${reaction[field]}`);
|
||||
await db.collection('eventflow-documentreadmodel').deleteOne({ DocumentId: reaction[field] });
|
||||
} catch (e) {
|
||||
console.log(`Failed to delete ${field}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем реакцию
|
||||
await db.collection('reactions').deleteOne({ _id: new ObjectId(req.params.id) });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting reaction:', error);
|
||||
res.status(500).json({ error: 'Failed to delete reaction' });
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,347 @@
|
||||
import express from 'express';
|
||||
import { ObjectId } from 'mongodb';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Формирует ID уведомления
|
||||
function createNotificationId() {
|
||||
return `notification-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Возвращает текущую метку времени в формате Unix
|
||||
function getCurrentTimestamp() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
// GET /api/service-notifications - получить все шаблоны уведомлений
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const templates = await collection.find({}).sort({ CreatedDate: -1 }).toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: templates.length,
|
||||
templates: templates
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification templates:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/service-notifications/:id - получить конкретный шаблон
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const template = await collection.findOne({ Id: req.params.id });
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: template
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching notification template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/service-notifications - создать новый шаблон
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { type, title, message, mediaUrl, mediaType, isPopup, isActive } = req.body;
|
||||
|
||||
if (!type || !title || !message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Type, title, and message are required'
|
||||
});
|
||||
}
|
||||
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const notificationId = createNotificationId();
|
||||
const currentDate = getCurrentTimestamp();
|
||||
|
||||
const template = {
|
||||
_id: notificationId,
|
||||
Id: notificationId,
|
||||
Type: type,
|
||||
Title: title,
|
||||
Message: message,
|
||||
MediaUrl: mediaUrl || null,
|
||||
MediaType: mediaType || null,
|
||||
IsPopup: isPopup !== undefined ? isPopup : true,
|
||||
IsActive: isActive !== undefined ? isActive : true,
|
||||
CreatedDate: currentDate,
|
||||
UpdatedDate: null
|
||||
};
|
||||
|
||||
await collection.insertOne(template);
|
||||
|
||||
console.log(`Created notification template: ${notificationId} (${type})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: template
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating notification template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/service-notifications/:id - обновить шаблон
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { type, title, message, mediaUrl, mediaType, isPopup, isActive } = req.body;
|
||||
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const updateFields = {
|
||||
UpdatedDate: getCurrentTimestamp()
|
||||
};
|
||||
|
||||
if (type !== undefined) updateFields.Type = type;
|
||||
if (title !== undefined) updateFields.Title = title;
|
||||
if (message !== undefined) updateFields.Message = message;
|
||||
if (mediaUrl !== undefined) updateFields.MediaUrl = mediaUrl;
|
||||
if (mediaType !== undefined) updateFields.MediaType = mediaType;
|
||||
if (isPopup !== undefined) updateFields.IsPopup = isPopup;
|
||||
if (isActive !== undefined) updateFields.IsActive = isActive;
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ Id: req.params.id },
|
||||
{ $set: updateFields }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
const updatedTemplate = await collection.findOne({ Id: req.params.id });
|
||||
|
||||
console.log(`Updated notification template: ${req.params.id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
template: updatedTemplate
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating notification template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/service-notifications/:id - удалить шаблон
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const collection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
|
||||
const result = await collection.deleteOne({ Id: req.params.id });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Deleted notification template: ${req.params.id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Template deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting notification template:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/service-notifications/:id/send - отправить уведомление пользователям
|
||||
router.post('/:id/send', async (req, res) => {
|
||||
try {
|
||||
const { userIds, sendToAll } = req.body;
|
||||
|
||||
console.log(`Database name: ${req.db.databaseName}`);
|
||||
console.log(`sendToAll: ${sendToAll}, userIds: ${userIds}`);
|
||||
|
||||
// Загружаем шаблон
|
||||
const templatesCollection = req.db.collection('ReadModel-ServiceNotificationTemplateReadModel');
|
||||
const template = await templatesCollection.findOne({ Id: req.params.id });
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Template not found'
|
||||
});
|
||||
}
|
||||
|
||||
if (!template.IsActive) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Template is not active'
|
||||
});
|
||||
}
|
||||
|
||||
// Готовим данные уведомления
|
||||
const notification = {
|
||||
type: template.Type,
|
||||
message: template.Message,
|
||||
popup: template.IsPopup,
|
||||
mediaUrl: template.MediaUrl,
|
||||
mediaType: template.MediaType,
|
||||
timestamp: getCurrentTimestamp()
|
||||
};
|
||||
|
||||
let targetUserIds = [];
|
||||
|
||||
if (sendToAll) {
|
||||
// Берём все ID пользователей из eventflow-userreadmodel (в EventFlow имена в нижнем регистре)
|
||||
const usersCollection = req.db.collection('eventflow-userreadmodel');
|
||||
const users = await usersCollection.find({}).project({ UserId: 1 }).toArray();
|
||||
console.log(`Found ${users.length} users in database`);
|
||||
console.log(`First 3 users:`, users.slice(0, 3));
|
||||
targetUserIds = users.map(u => u.UserId).filter(id => id != null && id !== 777000 && id !== 569999);
|
||||
|
||||
console.log(`Sending notification to ALL users (${targetUserIds.length} users, excluding system users)`);
|
||||
} else if (userIds && Array.isArray(userIds) && userIds.length > 0) {
|
||||
targetUserIds = userIds.map(id => parseInt(id));
|
||||
|
||||
console.log(`Sending notification to ${targetUserIds.length} specific users`);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Either sendToAll must be true or userIds must be provided'
|
||||
});
|
||||
}
|
||||
|
||||
// Сохраняем события уведомлений в коллекцию для последующей обработки
|
||||
const notificationEventsCollection = req.db.collection('ServiceNotificationEvents');
|
||||
|
||||
const events = targetUserIds.map(userId => ({
|
||||
userId: userId,
|
||||
templateId: template.Id,
|
||||
type: notification.type,
|
||||
message: notification.message,
|
||||
popup: notification.popup,
|
||||
mediaUrl: notification.mediaUrl,
|
||||
mediaType: notification.mediaType,
|
||||
createdAt: new Date(),
|
||||
processed: false
|
||||
}));
|
||||
|
||||
if (events.length > 0) {
|
||||
await notificationEventsCollection.insertMany(events);
|
||||
}
|
||||
|
||||
console.log(`Created ${events.length} notification events for processing`);
|
||||
|
||||
// В реальной реализации здесь запускался бы C#-сервис.
|
||||
// Пока возвращаем успех, а события обработает фоновый воркер.
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Notification queued for ${targetUserIds.length} users`,
|
||||
targetUserCount: targetUserIds.length,
|
||||
notification: notification
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending notification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/service-notifications/events/pending - получить необработанные события уведомлений
|
||||
router.get('/events/pending', async (req, res) => {
|
||||
try {
|
||||
const collection = db.collection('ServiceNotificationEvents');
|
||||
|
||||
const pendingEvents = await collection
|
||||
.find({ processed: false })
|
||||
.sort({ createdAt: 1 })
|
||||
.limit(100)
|
||||
.toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: pendingEvents.length,
|
||||
events: pendingEvents
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching pending events:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/service-notifications/events/:id/mark-processed - пометить событие как обработанное
|
||||
router.post('/events/:id/mark-processed', async (req, res) => {
|
||||
try {
|
||||
const collection = db.collection('ServiceNotificationEvents');
|
||||
|
||||
const result = await collection.updateOne(
|
||||
{ _id: new ObjectId(req.params.id) },
|
||||
{
|
||||
$set: {
|
||||
processed: true,
|
||||
processedAt: new Date()
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Event not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Event marked as processed'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error marking event as processed:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,96 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Получить настройки иконки заморозки
|
||||
router.get('/frozen-icon', async (req, res) => {
|
||||
try {
|
||||
// Берём глобальные настройки иконки заморозки из БД
|
||||
const settings = await req.db
|
||||
.collection('global_settings')
|
||||
.findOne({ key: 'frozen_icon' });
|
||||
|
||||
if (!settings) {
|
||||
// Настройки по умолчанию
|
||||
return res.json({
|
||||
type: 'emoji',
|
||||
emoji: '❄️',
|
||||
documentId: null
|
||||
});
|
||||
}
|
||||
|
||||
res.json(settings.value);
|
||||
} catch (error) {
|
||||
console.error('Get frozen icon settings error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить настройки иконки заморозки
|
||||
router.post('/frozen-icon', async (req, res) => {
|
||||
try {
|
||||
const { type, emoji, documentId } = req.body;
|
||||
|
||||
const settings = {
|
||||
type: type || 'emoji',
|
||||
emoji: emoji || '❄️',
|
||||
documentId: documentId || null
|
||||
};
|
||||
|
||||
// Сохраняем глобальные настройки
|
||||
await req.db
|
||||
.collection('global_settings')
|
||||
.updateOne(
|
||||
{ key: 'frozen_icon' },
|
||||
{
|
||||
$set: {
|
||||
key: 'frozen_icon',
|
||||
value: settings,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
// Если выбрана анимация, применяем её ко всем замороженным пользователям
|
||||
if (type === 'animation' && documentId) {
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateMany(
|
||||
{ Restricted: true },
|
||||
{
|
||||
$set: {
|
||||
FrozenAnimationDocumentId: parseInt(documentId)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Applied frozen animation ${documentId} to all restricted users`);
|
||||
} else if (type === 'emoji') {
|
||||
// При переключении на эмодзи убираем анимацию у всех пользователей
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateMany(
|
||||
{ Restricted: true },
|
||||
{
|
||||
$unset: {
|
||||
FrozenAnimationDocumentId: ""
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`Removed frozen animation from all restricted users`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Frozen icon settings updated',
|
||||
settings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update frozen icon settings error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,326 @@
|
||||
import express from 'express';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Схема валидации
|
||||
const sponsoredMessageSchema = Joi.object({
|
||||
channelId: Joi.number().integer().positive().required(),
|
||||
title: Joi.string().max(100).required(),
|
||||
message: Joi.string().max(500).required(),
|
||||
url: Joi.string().uri().required(),
|
||||
buttonText: Joi.string().max(50).required(),
|
||||
photoUrl: Joi.string().uri().optional().allow(null, ''),
|
||||
sponsorInfo: Joi.string().max(200).optional().allow(null, ''),
|
||||
additionalInfo: Joi.string().max(200).optional().allow(null, ''),
|
||||
isActive: Joi.boolean().default(true),
|
||||
recommended: Joi.boolean().default(false),
|
||||
canReport: Joi.boolean().default(true),
|
||||
postsBetween: Joi.number().integer().min(1).max(100).default(10),
|
||||
expiresDate: Joi.number().integer().optional().allow(null)
|
||||
});
|
||||
|
||||
// Список всех рекламных сообщений
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { channelId, isActive } = req.query;
|
||||
|
||||
const filter = {};
|
||||
if (channelId) filter.ChannelId = parseInt(channelId);
|
||||
if (isActive !== undefined) filter.IsActive = isActive === 'true';
|
||||
|
||||
const messages = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.find(filter)
|
||||
.sort({ CreatedDate: -1 })
|
||||
.toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
count: messages.length,
|
||||
messages
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching sponsored messages:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Получить рекламное сообщение по ID
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const message = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.findOne({ Id: req.params.id });
|
||||
|
||||
if (!message) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Создать новое рекламное сообщение
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = sponsoredMessageSchema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const id = `sponsored-${value.channelId}-${now}`;
|
||||
|
||||
const sponsoredMessage = {
|
||||
Id: id,
|
||||
ChannelId: value.channelId,
|
||||
Title: value.title,
|
||||
Message: value.message,
|
||||
Url: value.url,
|
||||
ButtonText: value.buttonText,
|
||||
PhotoUrl: value.photoUrl || null,
|
||||
SponsorInfo: value.sponsorInfo || null,
|
||||
AdditionalInfo: value.additionalInfo || null,
|
||||
IsActive: value.isActive,
|
||||
Recommended: value.recommended,
|
||||
CanReport: value.canReport,
|
||||
PostsBetween: value.postsBetween,
|
||||
CreatedDate: now,
|
||||
ExpiresDate: value.expiresDate || null,
|
||||
DisplayCount: 0,
|
||||
ClickCount: 0,
|
||||
Version: 1
|
||||
};
|
||||
|
||||
await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.insertOne(sponsoredMessage);
|
||||
|
||||
console.log(`Created sponsored message: ${id} for channel ${value.channelId}`);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: sponsoredMessage
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить рекламное сообщение
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = sponsoredMessageSchema.validate(req.body);
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
ChannelId: value.channelId,
|
||||
Title: value.title,
|
||||
Message: value.message,
|
||||
Url: value.url,
|
||||
ButtonText: value.buttonText,
|
||||
PhotoUrl: value.photoUrl || null,
|
||||
SponsorInfo: value.sponsorInfo || null,
|
||||
AdditionalInfo: value.additionalInfo || null,
|
||||
IsActive: value.isActive,
|
||||
Recommended: value.recommended,
|
||||
CanReport: value.canReport,
|
||||
PostsBetween: value.postsBetween,
|
||||
ExpiresDate: value.expiresDate || null
|
||||
};
|
||||
|
||||
const result = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.updateOne(
|
||||
{ Id: req.params.id },
|
||||
{ $set: updateData, $inc: { Version: 1 } }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Updated sponsored message: ${req.params.id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sponsored message updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Переключить статус активности
|
||||
router.patch('/:id/toggle', async (req, res) => {
|
||||
try {
|
||||
const message = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.findOne({ Id: req.params.id });
|
||||
|
||||
if (!message) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
const newStatus = !message.IsActive;
|
||||
|
||||
await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.updateOne(
|
||||
{ Id: req.params.id },
|
||||
{ $set: { IsActive: newStatus }, $inc: { Version: 1 } }
|
||||
);
|
||||
|
||||
console.log(`Toggled sponsored message ${req.params.id} to ${newStatus ? 'active' : 'inactive'}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
isActive: newStatus
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить рекламное сообщение
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.deleteOne({ Id: req.params.id });
|
||||
|
||||
if (result.deletedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Deleted sponsored message: ${req.params.id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Sponsored message deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting sponsored message:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Статистика по каналу
|
||||
router.get('/stats/:channelId', async (req, res) => {
|
||||
try {
|
||||
const channelId = parseInt(req.params.channelId);
|
||||
|
||||
const messages = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.find({ ChannelId: channelId })
|
||||
.toArray();
|
||||
|
||||
const stats = {
|
||||
totalMessages: messages.length,
|
||||
activeMessages: messages.filter(m => m.IsActive).length,
|
||||
totalDisplays: messages.reduce((sum, m) => sum + (m.DisplayCount || 0), 0),
|
||||
totalClicks: messages.reduce((sum, m) => sum + (m.ClickCount || 0), 0),
|
||||
clickThroughRate: 0
|
||||
};
|
||||
|
||||
if (stats.totalDisplays > 0) {
|
||||
stats.clickThroughRate = ((stats.totalClicks / stats.totalDisplays) * 100).toFixed(2);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
channelId,
|
||||
stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Учёт перехода по ссылке (вызывается из ссылки отслеживания)
|
||||
router.post('/click/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await req.db
|
||||
.collection('ReadModel-SponsoredMessageReadModel')
|
||||
.updateOne(
|
||||
{ Id: id },
|
||||
{ $inc: { ClickCount: 1 } }
|
||||
);
|
||||
|
||||
if (result.matchedCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Sponsored message not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Click tracked for sponsored message: ${id}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Click tracked successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,385 @@
|
||||
import express from 'express';
|
||||
import { ObjectId, Long } from 'mongodb';
|
||||
import { v5 as uuidv5, v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Пространство имён EventFlow (получено через рефлексию из EventFlow.Core.GuidFactories)
|
||||
const EVENTFLOW_COMMANDS_NAMESPACE = '4286d89f-7f92-430b-8e00-e468fe3c3f59';
|
||||
|
||||
// POST /api/stars/issue - начислить звёзды пользователю
|
||||
router.post('/issue', async (req, res) => {
|
||||
try {
|
||||
const { userId, amount, reason } = req.body;
|
||||
|
||||
// Проверка входных данных
|
||||
if (!userId || !amount || amount <= 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid request. userId and positive amount are required.'
|
||||
});
|
||||
}
|
||||
|
||||
const db = req.db;
|
||||
const eventsCollection = db.collection('eventflow.events');
|
||||
const starsCollection = db.collection('eventflow-starsreadmodel');
|
||||
const userCollection = db.collection('eventflow-userreadmodel');
|
||||
|
||||
// Проверяем, существует ли пользователь
|
||||
const user = await userCollection.findOne({ UserId: parseInt(userId) });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Генерируем детерминированный ID на основе пространства имён EventFlow
|
||||
const guid = uuidv5(`stars_${userId}`, EVENTFLOW_COMMANDS_NAMESPACE);
|
||||
const aggregateId = `stars-${guid}`;
|
||||
const documentId = aggregateId;
|
||||
|
||||
console.log(`Generated StarsAggregate ID: ${aggregateId} for user ${userId}`);
|
||||
|
||||
// --- Обновление write-модели EventFlow ---
|
||||
|
||||
// Находим последнее событие агрегата, чтобы определить номер в последовательности
|
||||
const lastEvent = await eventsCollection.findOne(
|
||||
{ AggregateId: aggregateId },
|
||||
{ sort: { AggregateSequenceNumber: -1 }, projection: { AggregateSequenceNumber: 1, Data: 1 } }
|
||||
);
|
||||
|
||||
let currentSequence = lastEvent ? lastEvent.AggregateSequenceNumber : 0;
|
||||
let currentBalance = 0;
|
||||
|
||||
// Разбираем Data, чтобы получить текущий баланс
|
||||
if (lastEvent && lastEvent.Data) {
|
||||
try {
|
||||
const data = JSON.parse(lastEvent.Data);
|
||||
if (data.NewBalance !== undefined) {
|
||||
currentBalance = data.NewBalance;
|
||||
} else if (data.Balance !== undefined) {
|
||||
currentBalance = data.Balance;
|
||||
}
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки разбора
|
||||
}
|
||||
}
|
||||
|
||||
const eventsToInsert = [];
|
||||
const now = new Date();
|
||||
const batchId = uuidv4();
|
||||
const timestampEpoch = Math.floor(now.getTime() / 1000);
|
||||
const baseId = Date.now();
|
||||
|
||||
// 1. Если агрегата ещё нет, создаём StarsAccountCreatedEvent
|
||||
if (currentSequence === 0) {
|
||||
currentSequence++;
|
||||
|
||||
const createEventData = {
|
||||
PeerId: parseInt(userId),
|
||||
RequestInfo: {
|
||||
DeviceType: 1,
|
||||
AddRequestIdToCache: true
|
||||
}
|
||||
};
|
||||
|
||||
const createMetadata = {
|
||||
timestamp: now.toISOString(),
|
||||
aggregate_sequence_number: currentSequence.toString(),
|
||||
aggregate_name: 'StarsAggregate',
|
||||
aggregate_id: aggregateId,
|
||||
event_id: `event-${new ObjectId().toString()}`,
|
||||
timestamp_epoch: timestampEpoch.toString(),
|
||||
batch_id: batchId,
|
||||
source_id: `command-${new ObjectId().toString()}`,
|
||||
event_name: 'StarsAccountCreatedEvent',
|
||||
event_version: '1'
|
||||
};
|
||||
|
||||
eventsToInsert.push({
|
||||
_id: baseId,
|
||||
BatchId: batchId,
|
||||
AggregateId: aggregateId,
|
||||
AggregateName: 'StarsAggregate',
|
||||
AggregateSequenceNumber: currentSequence,
|
||||
Data: JSON.stringify(createEventData),
|
||||
Metadata: JSON.stringify(createMetadata)
|
||||
});
|
||||
|
||||
console.log(`Creating new StarsAggregate for user ${userId}`);
|
||||
}
|
||||
|
||||
// 2. Создаём StarsAddedEvent
|
||||
currentSequence++;
|
||||
const newBalance = currentBalance + parseInt(amount);
|
||||
const transactionId = uuidv4();
|
||||
|
||||
const eventData = {
|
||||
PeerId: parseInt(userId),
|
||||
Amount: parseInt(amount),
|
||||
TransactionId: transactionId,
|
||||
Reason: reason || 'Admin issued stars',
|
||||
NewBalance: newBalance,
|
||||
RequestInfo: {
|
||||
DeviceType: 1,
|
||||
AddRequestIdToCache: true
|
||||
}
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
timestamp: now.toISOString(),
|
||||
aggregate_sequence_number: currentSequence.toString(),
|
||||
aggregate_name: 'StarsAggregate',
|
||||
aggregate_id: aggregateId,
|
||||
event_id: `event-${new ObjectId().toString()}`,
|
||||
timestamp_epoch: timestampEpoch.toString(),
|
||||
batch_id: batchId,
|
||||
source_id: `command-${new ObjectId().toString()}`,
|
||||
event_name: 'StarsAddedEvent',
|
||||
event_version: '1'
|
||||
};
|
||||
|
||||
eventsToInsert.push({
|
||||
_id: baseId + eventsToInsert.length, // сдвигаем, если уже добавили StarsAccountCreatedEvent
|
||||
BatchId: batchId,
|
||||
AggregateId: aggregateId,
|
||||
AggregateName: 'StarsAggregate',
|
||||
AggregateSequenceNumber: currentSequence,
|
||||
Data: JSON.stringify(eventData),
|
||||
Metadata: JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
// Сохраняем события
|
||||
if (eventsToInsert.length > 0) {
|
||||
await eventsCollection.insertMany(eventsToInsert);
|
||||
console.log(`Inserted StarsAddedEvent for user ${userId}: +${amount} stars, new balance: ${newBalance}`);
|
||||
}
|
||||
|
||||
// --- Обновление read-модели (для немедленного отклика интерфейса) ---
|
||||
// Это может дублировать обновление от EventFlow, но повышает отзывчивость UI
|
||||
|
||||
const transaction = {
|
||||
Id: new ObjectId().toString(),
|
||||
Amount: parseInt(amount),
|
||||
Date: now,
|
||||
Type: 'TopUp',
|
||||
Reason: reason || 'Admin issued stars',
|
||||
TransactionId: transactionId
|
||||
};
|
||||
|
||||
// Проверяем, есть ли read-модель (должна быть, если агрегат существует, но может быть не синхронизирована)
|
||||
let starsAccount = await starsCollection.findOne({ _id: documentId });
|
||||
|
||||
if (!starsAccount) {
|
||||
// Создаём новый счёт звёзд в read-модели
|
||||
starsAccount = {
|
||||
_id: documentId,
|
||||
Id: documentId,
|
||||
PeerId: parseInt(userId), // храним как обычный int64, не Long
|
||||
Balance: newBalance, // храним как обычный int64, не Long
|
||||
Transactions: [transaction],
|
||||
Version: Long.fromNumber(1)
|
||||
};
|
||||
await starsCollection.insertOne(starsAccount);
|
||||
console.log(`Created new StarsReadModel with PeerId=${userId} (as int64)`);
|
||||
} else {
|
||||
// Обновляем существующий счёт
|
||||
const currentBalance = starsAccount.Balance instanceof Long
|
||||
? starsAccount.Balance.toNumber()
|
||||
: parseInt(starsAccount.Balance);
|
||||
|
||||
await starsCollection.updateOne(
|
||||
{ _id: documentId },
|
||||
{
|
||||
$set: {
|
||||
Balance: currentBalance + parseInt(amount), // храним как обычный int64
|
||||
PeerId: parseInt(userId) // следим, чтобы PeerId был обычным int64
|
||||
},
|
||||
$push: { Transactions: transaction }
|
||||
}
|
||||
);
|
||||
console.log(`Updated StarsReadModel with PeerId=${userId}, newBalance=${currentBalance + parseInt(amount)}`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: parseInt(userId),
|
||||
userName: user.UserName || null,
|
||||
firstName: user.FirstName || null,
|
||||
newBalance: newBalance.toString(),
|
||||
transaction: {
|
||||
amount: parseInt(amount),
|
||||
reason: reason || 'Admin issued stars',
|
||||
date: transaction.Date
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error issuing stars:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/stars/user/:userId - баланс звёзд и транзакции пользователя
|
||||
router.get('/user/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const db = req.db;
|
||||
|
||||
// Получаем сведения о пользователе
|
||||
const userCollection = db.collection('eventflow-userreadmodel');
|
||||
const user = await userCollection.findOne({ UserId: parseInt(userId) });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Пытаемся найти события StarsAggregate для этого пользователя
|
||||
const eventsCollection = db.collection('eventflow.events');
|
||||
const existingEvent = await eventsCollection.findOne({
|
||||
AggregateName: 'StarsAggregate',
|
||||
'Data': { $regex: `"PeerId":${userId}` }
|
||||
});
|
||||
|
||||
let starsAccount = null;
|
||||
if (existingEvent) {
|
||||
const aggregateId = existingEvent.AggregateId;
|
||||
const starsCollection = db.collection('eventflow-starsreadmodel');
|
||||
starsAccount = await starsCollection.findOne({ _id: aggregateId });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
userId: user.UserId,
|
||||
userName: user.UserName || null,
|
||||
firstName: user.FirstName || null,
|
||||
lastName: user.LastName || null,
|
||||
phoneNumber: user.PhoneNumber || null
|
||||
},
|
||||
balance: starsAccount?.Balance?.toString() || "0",
|
||||
transactions: starsAccount?.Transactions || []
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting user stars:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/stars/recent - последние транзакции звёзд по всем пользователям
|
||||
router.get('/recent', async (req, res) => {
|
||||
try {
|
||||
const { limit = 20 } = req.query;
|
||||
const db = req.db;
|
||||
|
||||
const starsCollection = db.collection('eventflow-starsreadmodel');
|
||||
|
||||
// Собираем последние транзакции
|
||||
const accounts = await starsCollection
|
||||
.find({ Transactions: { $exists: true, $ne: [] } })
|
||||
.limit(parseInt(limit))
|
||||
.sort({ 'Transactions.Date': -1 })
|
||||
.toArray();
|
||||
|
||||
// Разворачиваем транзакции в плоский список
|
||||
const recentTransactions = [];
|
||||
for (const account of accounts) {
|
||||
if (account.Transactions) {
|
||||
for (const tx of account.Transactions.slice(-5)) { // последние 5 на пользователя
|
||||
recentTransactions.push({
|
||||
userId: account.PeerId.toString(),
|
||||
...tx
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по дате
|
||||
recentTransactions.sort((a, b) => new Date(b.Date) - new Date(a.Date));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: recentTransactions.slice(0, parseInt(limit))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting recent transactions:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/stars/notify-gift-received - уведомить пользователя о полученном подарке
|
||||
router.post('/notify-gift-received', async (req, res) => {
|
||||
try {
|
||||
const { recipientUserId, senderUserId, giftId, stars } = req.body;
|
||||
|
||||
if (!recipientUserId || !senderUserId || !giftId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'recipientUserId, senderUserId, and giftId are required'
|
||||
});
|
||||
}
|
||||
|
||||
const db = req.db;
|
||||
|
||||
// Получаем сведения о получателе
|
||||
const userCollection = db.collection('eventflow-userreadmodel');
|
||||
const recipient = await userCollection.findOne({ UserId: parseInt(recipientUserId) });
|
||||
const sender = await userCollection.findOne({ UserId: parseInt(senderUserId) });
|
||||
|
||||
if (!recipient) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Recipient not found'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Notification requested for user ${recipientUserId}: Gift ${giftId} from user ${senderUserId}`);
|
||||
|
||||
// Само уведомление отправляет MessageDomainEventHandler при обработке
|
||||
// InboxMessageCreatedEvent. Этот эндпоинт нужен для тестов и отладки.
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Notification logged for user ${recipientUserId}`,
|
||||
data: {
|
||||
recipient: {
|
||||
userId: recipient.UserId,
|
||||
userName: recipient.UserName || null,
|
||||
firstName: recipient.FirstName || null
|
||||
},
|
||||
sender: sender ? {
|
||||
userId: sender.UserId,
|
||||
userName: sender.UserName || null,
|
||||
firstName: sender.FirstName || null
|
||||
} : null,
|
||||
giftId: parseInt(giftId),
|
||||
stars: parseInt(stars || 0)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing gift notification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,102 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET statistics
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const giftsCollection = req.db.collection('AvailableStarGiftReadModel');
|
||||
const sentGiftsCollection = req.db.collection('StarGiftReadModel');
|
||||
|
||||
const [
|
||||
totalGifts,
|
||||
limitedGifts,
|
||||
soldOutGifts,
|
||||
totalSentGifts,
|
||||
totalStarsEarned
|
||||
] = await Promise.all([
|
||||
giftsCollection.countDocuments(),
|
||||
giftsCollection.countDocuments({ Limited: true }),
|
||||
giftsCollection.countDocuments({ SoldOut: true }),
|
||||
sentGiftsCollection.countDocuments(),
|
||||
sentGiftsCollection.aggregate([
|
||||
{ $group: { _id: null, total: { $sum: '$Stars' } } }
|
||||
]).toArray()
|
||||
]);
|
||||
|
||||
const mostPopular = await sentGiftsCollection.aggregate([
|
||||
{ $group: { _id: '$GiftId', count: { $sum: 1 } } },
|
||||
{ $sort: { count: -1 } },
|
||||
{ $limit: 5 }
|
||||
]).toArray();
|
||||
|
||||
res.json({
|
||||
totalGifts,
|
||||
limitedGifts,
|
||||
soldOutGifts,
|
||||
availableGifts: totalGifts - soldOutGifts,
|
||||
totalSentGifts,
|
||||
totalStarsEarned: totalStarsEarned[0]?.total || 0,
|
||||
mostPopular
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET sent gifts statistics
|
||||
router.get('/sent', async (req, res) => {
|
||||
try {
|
||||
const sentGiftsCollection = req.db.collection('StarGiftReadModel');
|
||||
|
||||
const stats = await sentGiftsCollection.aggregate([
|
||||
{
|
||||
$facet: {
|
||||
byDay: [
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
$dateToString: { format: '%Y-%m-%d', date: '$CreatedAt' }
|
||||
},
|
||||
count: { $sum: 1 },
|
||||
stars: { $sum: '$Stars' }
|
||||
}
|
||||
},
|
||||
{ $sort: { _id: -1 } },
|
||||
{ $limit: 30 }
|
||||
],
|
||||
byGift: [
|
||||
{
|
||||
$group: {
|
||||
_id: '$GiftId',
|
||||
count: { $sum: 1 },
|
||||
stars: { $sum: '$Stars' }
|
||||
}
|
||||
},
|
||||
{ $sort: { count: -1 } }
|
||||
],
|
||||
conversion: [
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
total: { $sum: 1 },
|
||||
converted: {
|
||||
$sum: { $cond: ['$Converted', 1, 0] }
|
||||
},
|
||||
saved: {
|
||||
$sum: { $cond: ['$Saved', 1, 0] }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]).toArray();
|
||||
|
||||
res.json(stats[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,118 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Настраиваем multer для загрузки файлов
|
||||
const upload = multer({
|
||||
dest: 'uploads/',
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // ограничение 10 МБ
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Разрешаем JSON-файлы для анимаций
|
||||
if (file.mimetype === 'application/json' || file.originalname.endsWith('.json')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JSON files are allowed for animations'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузить документ в Telegram
|
||||
router.post('/document', upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const { type, title } = req.body;
|
||||
const filePath = req.file.path;
|
||||
const originalName = req.file.originalname;
|
||||
|
||||
console.log(`Uploading document: ${originalName}, type: ${type}, title: ${title}`);
|
||||
|
||||
try {
|
||||
// Читаем содержимое файла
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
|
||||
// Для JSON-анимаций проверяем содержимое
|
||||
if (type === 'animation' && originalName.endsWith('.json')) {
|
||||
try {
|
||||
const jsonContent = JSON.parse(fileBuffer.toString());
|
||||
if (!jsonContent.v || !jsonContent.layers) {
|
||||
throw new Error('Invalid Lottie JSON: missing required fields (v, layers)');
|
||||
}
|
||||
console.log(`Valid Lottie JSON: version=${jsonContent.v}, layers=${jsonContent.layers?.length}`);
|
||||
} catch (jsonError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid JSON animation: ' + jsonError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Генерируем ID документа (по метке времени, как у остальных документов)
|
||||
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Создаём документ в MongoDB
|
||||
const document = {
|
||||
_id: `document-${documentId}`,
|
||||
Id: `document-${documentId}`,
|
||||
DocumentId: documentId,
|
||||
AccessHash: Math.floor(Math.random() * 9223372036854775807), // случайное 64-битное значение
|
||||
FileReference: Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08]), // 8 байт
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: type === 'animation' ? 'application/json' : 'application/octet-stream',
|
||||
Size: fileBuffer.length,
|
||||
DcId: 2, // дата-центр для медиа
|
||||
Attributes2: [
|
||||
{
|
||||
ConstructorId: 0x6319d612, // TDocumentAttributeSticker
|
||||
Alt: type === 'animation' ? '❄️' : '📄',
|
||||
Stickerset: {
|
||||
ConstructorId: 0x83cf7966, // TInputStickerSetEmpty
|
||||
}
|
||||
}
|
||||
],
|
||||
Thumbs: [],
|
||||
VideoThumbs: []
|
||||
};
|
||||
|
||||
// Сохраняем документ в MongoDB
|
||||
await req.db
|
||||
.collection('eventflow-documentreadmodel')
|
||||
.insertOne(document);
|
||||
|
||||
// TODO: загрузить файл в хранилище MinIO.
|
||||
// Пока создаём только запись документа.
|
||||
// В продакшене сюда нужно заливать fileBuffer в MinIO по пути `${documentId}`.
|
||||
|
||||
console.log(`Document created: ID=${documentId}, Size=${fileBuffer.length} bytes`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
documentId: documentId,
|
||||
message: `Document uploaded successfully`,
|
||||
size: fileBuffer.length
|
||||
});
|
||||
|
||||
} finally {
|
||||
// Удаляем загруженный файл
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upload document error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,413 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
|
||||
// Список всех пользователей с постраничной разбивкой
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, search = '' } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
const filter = {};
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ FirstName: { $regex: search, $options: 'i' } },
|
||||
{ LastName: { $regex: search, $options: 'i' } },
|
||||
{ UserName: { $regex: search, $options: 'i' } },
|
||||
{ Phone: { $regex: search, $options: 'i' } }
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
req.db.collection('eventflow-userreadmodel')
|
||||
.find(filter)
|
||||
.sort({ UserId: -1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.toArray(),
|
||||
req.db.collection('eventflow-userreadmodel').countDocuments(filter)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Поиск пользователей по телефону или имени пользователя
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { phone, username } = req.query;
|
||||
|
||||
if (!phone && !username) {
|
||||
return res.status(400).json({ error: 'phone or username parameter required' });
|
||||
}
|
||||
|
||||
let query = {};
|
||||
|
||||
if (phone) {
|
||||
// Поиск по номеру телефона
|
||||
query.PhoneNumber = phone;
|
||||
} else if (username) {
|
||||
// Поиск по имени пользователя (без учёта регистра)
|
||||
query.UserName = new RegExp(`^${username}$`, 'i');
|
||||
}
|
||||
|
||||
const users = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.find(query)
|
||||
.limit(10)
|
||||
.toArray();
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
console.error('User search error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить пользователя по ID
|
||||
router.get('/:userId', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Заморозить (ограничить) аккаунт пользователя
|
||||
router.post('/:userId/freeze', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const { reason = 'Account restricted for violating Terms of Service' } = req.body;
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Переводим пользователя в замороженное (ограниченное) состояние
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Restricted: true,
|
||||
RestrictionReason: reason
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${userId} has been frozen`,
|
||||
userId,
|
||||
reason
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Freeze user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Разморозить (снять ограничение) аккаунт пользователя
|
||||
router.post('/:userId/unfreeze', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Снимаем ограничение
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Restricted: false,
|
||||
RestrictionReason: null
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${userId} has been unfrozen`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unfreeze user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить аккаунт пользователя (пометить как удалённый)
|
||||
router.delete('/:userId', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Помечаем пользователя как удалённого
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
IsDeleted: true,
|
||||
Restricted: true,
|
||||
RestrictionReason: 'Account deleted by administrator'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${userId} has been deleted`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Задать анимацию заморозки для пользователя
|
||||
router.post('/:userId/frozen-animation', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const { documentId } = req.body;
|
||||
|
||||
if (!documentId) {
|
||||
return res.status(400).json({ error: 'Document ID is required' });
|
||||
}
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Сохраняем ID документа анимации заморозки
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
FrozenAnimationDocumentId: parseInt(documentId)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Frozen animation set for user ${userId}`,
|
||||
userId,
|
||||
documentId: parseInt(documentId)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Set frozen animation error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Убрать анимацию заморозки у пользователя
|
||||
router.delete('/:userId/frozen-animation', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Убираем анимацию заморозки
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$unset: {
|
||||
FrozenAnimationDocumentId: ""
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Frozen animation removed from user ${userId}`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Remove frozen animation error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Выдать пользователю premium
|
||||
router.post('/:userId/premium', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Выдаём статус premium
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Premium: true,
|
||||
PremiumGifts: true
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Premium given to user ${userId}`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Give premium error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Снять premium с пользователя
|
||||
router.delete('/:userId/premium', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
const user = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: userId });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Снимаем статус premium
|
||||
await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.updateOne(
|
||||
{ UserId: userId },
|
||||
{
|
||||
$set: {
|
||||
Premium: false,
|
||||
PremiumGifts: false
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Premium removed from user ${userId}`,
|
||||
userId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Remove premium error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить настройки иконки заморозки
|
||||
router.get('/settings/frozen-icon', async (req, res) => {
|
||||
try {
|
||||
const settings = await req.db
|
||||
.collection('admin-settings')
|
||||
.findOne({ key: 'frozen_icon' });
|
||||
|
||||
res.json({
|
||||
type: settings?.type || 'emoji', // значение 'emoji' или 'animation'
|
||||
emoji: settings?.emoji || '❄️',
|
||||
animationUrl: settings?.animationUrl || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get frozen icon error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить настройки иконки заморозки
|
||||
router.post('/settings/frozen-icon', async (req, res) => {
|
||||
try {
|
||||
const { type, emoji, animationUrl } = req.body;
|
||||
|
||||
await req.db
|
||||
.collection('admin-settings')
|
||||
.updateOne(
|
||||
{ key: 'frozen_icon' },
|
||||
{
|
||||
$set: {
|
||||
key: 'frozen_icon',
|
||||
type: type || 'emoji',
|
||||
emoji: emoji || '❄️',
|
||||
animationUrl: animationUrl || null,
|
||||
updatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Frozen icon settings updated'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update frozen icon error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,679 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import Joi from 'joi';
|
||||
import * as minioHelper from '../utils/minioHelper.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Настройка загрузки файлов для иконок TGS
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = 'uploads/verification-icons';
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, `verification-icon-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['application/json', 'application/octet-stream', 'application/x-tgsticker'];
|
||||
const allowedExts = ['.json', '.tgs'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (allowedExts.includes(ext)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only .json and .tgs files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Схемы валидации
|
||||
const botVerifierSchema = Joi.object({
|
||||
botUserId: Joi.number().integer().positive().required(),
|
||||
iconEmojiId: Joi.number().integer().positive().required(),
|
||||
companyName: Joi.string().min(1).max(200).required(),
|
||||
canModifyCustomDescription: Joi.boolean().default(false)
|
||||
});
|
||||
|
||||
const verificationSchema = Joi.object({
|
||||
targetUserId: Joi.number().integer().positive().required(),
|
||||
botUserId: Joi.number().integer().positive().optional(),
|
||||
customDescription: Joi.string().max(500).optional().allow(null, '')
|
||||
});
|
||||
|
||||
// GET /api/verification/bots - Get all bot verifiers
|
||||
router.get('/bots', async (req, res) => {
|
||||
try {
|
||||
const verifiers = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.find({ IsActive: true })
|
||||
.toArray();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: verifiers
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching bot verifiers:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch bot verifiers'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/bots/:botUserId - Get specific bot verifier
|
||||
router.get('/bots/:botUserId', async (req, res) => {
|
||||
try {
|
||||
const { botUserId } = req.params;
|
||||
|
||||
const verifier = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOne({ BotUserId: parseInt(botUserId), IsActive: true });
|
||||
|
||||
if (!verifier) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot verifier not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: verifier
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching bot verifier:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch bot verifier'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/verification/bots - Create new bot verifier
|
||||
router.post('/bots', async (req, res) => {
|
||||
try {
|
||||
const { error, value } = botVerifierSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
// Check if bot user exists
|
||||
const botUser = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: value.botUserId, Bot: true });
|
||||
|
||||
if (!botUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot user not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if verifier already exists
|
||||
const existing = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOne({ BotUserId: value.botUserId });
|
||||
|
||||
const verifier = {
|
||||
Id: `bot_${value.botUserId}`,
|
||||
BotUserId: value.botUserId,
|
||||
IconEmojiId: value.iconEmojiId,
|
||||
CompanyName: value.companyName,
|
||||
CanModifyCustomDescription: value.canModifyCustomDescription,
|
||||
IsActive: true,
|
||||
CreatedAt: existing?.CreatedAt || new Date(),
|
||||
UpdatedAt: new Date()
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
// Update existing verifier
|
||||
await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.updateOne(
|
||||
{ BotUserId: value.botUserId },
|
||||
{ $set: verifier }
|
||||
);
|
||||
} else {
|
||||
// Create new verifier
|
||||
await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.insertOne(verifier);
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: verifier
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating bot verifier:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create bot verifier'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/verification/bots/:botUserId - Update bot verifier
|
||||
router.put('/bots/:botUserId', async (req, res) => {
|
||||
try {
|
||||
const { botUserId } = req.params;
|
||||
const { error, value } = botVerifierSchema.validate(req.body);
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
const result = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ BotUserId: parseInt(botUserId) },
|
||||
{
|
||||
$set: {
|
||||
IconEmojiId: value.iconEmojiId,
|
||||
CompanyName: value.companyName,
|
||||
CanModifyCustomDescription: value.canModifyCustomDescription,
|
||||
UpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot verifier not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating bot verifier:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update bot verifier'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/verification/bots/:botUserId - Deactivate bot verifier
|
||||
router.delete('/bots/:botUserId', async (req, res) => {
|
||||
try {
|
||||
const { botUserId } = req.params;
|
||||
|
||||
const result = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOneAndUpdate(
|
||||
{ BotUserId: parseInt(botUserId) },
|
||||
{
|
||||
$set: {
|
||||
IsActive: false,
|
||||
UpdatedAt: new Date()
|
||||
}
|
||||
},
|
||||
{ returnDocument: 'after' }
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot verifier not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deactivating bot verifier:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to deactivate bot verifier'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/users/:userId - Get user verification status
|
||||
router.get('/users/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
const verification = await req.db
|
||||
.collection('ReadModel-BotVerificationReadModel')
|
||||
.findOne({ TargetType: 1, TargetId: parseInt(userId) });
|
||||
|
||||
if (!verification) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: null
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: verification
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching user verification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch verification status'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/verification/users/:userId - Set user verification
|
||||
router.post('/users/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const { error, value } = verificationSchema.validate({
|
||||
...req.body,
|
||||
targetUserId: parseInt(userId)
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: error.details[0].message
|
||||
});
|
||||
}
|
||||
|
||||
// Check if target user exists
|
||||
const targetUser = await req.db
|
||||
.collection('eventflow-userreadmodel')
|
||||
.findOne({ UserId: value.targetUserId });
|
||||
|
||||
if (!targetUser) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Get bot verifier
|
||||
const botUserId = value.botUserId || req.body.botUserId;
|
||||
const verifier = await req.db
|
||||
.collection('ReadModel-BotVerifierReadModel')
|
||||
.findOne({ BotUserId: botUserId, IsActive: true });
|
||||
|
||||
if (!verifier) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Bot verifier not found or inactive'
|
||||
});
|
||||
}
|
||||
|
||||
// Проверяем право задавать собственное описание
|
||||
if (value.customDescription && !verifier.CanModifyCustomDescription) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Bot verifier cannot modify custom description'
|
||||
});
|
||||
}
|
||||
|
||||
// По умолчанию описание берётся из CompanyName.
|
||||
// Если задано непустое CustomDescription, оно заменяет описание по умолчанию.
|
||||
const defaultDescription = verifier.CompanyName;
|
||||
const customDescription = value.customDescription && value.customDescription.trim() !== ''
|
||||
? value.customDescription.trim()
|
||||
: null;
|
||||
|
||||
// Update UserReadModel with verification (both collections for compatibility)
|
||||
const updateData = {
|
||||
BotVerifierId: parseInt(verifier.BotUserId),
|
||||
BotVerificationIcon: parseInt(verifier.IconEmojiId)
|
||||
};
|
||||
|
||||
// Check if UserReadModel exists
|
||||
const existingUser = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: value.targetUserId });
|
||||
|
||||
if (!existingUser) {
|
||||
console.log(`UserReadModel does not exist for user ${value.targetUserId}`);
|
||||
console.log(`UserReadModel создаётся Command Server при регистрации или обновлении пользователя`);
|
||||
console.log(`Пока создаём минимальный UserReadModel только с полями верификации`);
|
||||
|
||||
// Создаём минимальный UserReadModel с полями верификации
|
||||
await req.db.collection('ReadModel-UserReadModel').insertOne({
|
||||
_id: `user-${value.targetUserId}`,
|
||||
Id: `user-${value.targetUserId}`,
|
||||
UserId: value.targetUserId,
|
||||
BotVerifierId: parseInt(verifier.BotUserId),
|
||||
BotVerificationIcon: parseInt(verifier.IconEmojiId),
|
||||
Version: 1
|
||||
});
|
||||
console.log(`Created minimal UserReadModel for user ${value.targetUserId}`);
|
||||
} else {
|
||||
// Обновляем существующий UserReadModel и увеличиваем Version, чтобы сбросить кэш
|
||||
const currentVersion = existingUser.Version || 1;
|
||||
await req.db.collection('ReadModel-UserReadModel').updateOne(
|
||||
{ UserId: value.targetUserId },
|
||||
{
|
||||
$set: updateData,
|
||||
$inc: { Version: 1 } // увеличиваем версию, чтобы сбросить кэш Query Server
|
||||
}
|
||||
);
|
||||
console.log(`Updated existing UserReadModel for user ${value.targetUserId} (Version: ${currentVersion} -> ${currentVersion + 1})`);
|
||||
}
|
||||
|
||||
// Также обновляем коллекцию eventflow
|
||||
await req.db.collection('eventflow-userreadmodel').updateOne(
|
||||
{ UserId: value.targetUserId },
|
||||
{ $set: updateData },
|
||||
{ upsert: true }
|
||||
);
|
||||
|
||||
// Создаём или обновляем BotVerificationReadModel
|
||||
const verificationId = `verification_1_${value.targetUserId}`; // формат: verification_{TargetType}_{TargetId}
|
||||
const verification = {
|
||||
_id: verificationId, // явно задаём _id строкой
|
||||
Id: verificationId,
|
||||
TargetType: 1, // пользователь
|
||||
TargetId: value.targetUserId,
|
||||
BotVerifierId: verifier.BotUserId, // используем BotVerifierId (имя поля в C#-ReadModel)
|
||||
IconEmojiId: verifier.IconEmojiId,
|
||||
Description: defaultDescription, // всегда задаём описание по умолчанию из CompanyName
|
||||
CustomDescription: customDescription, // null или собственный текст
|
||||
VerifiedAt: new Date(),
|
||||
UpdatedAt: new Date()
|
||||
};
|
||||
|
||||
console.log(`Creating verification: Description="${defaultDescription}", CustomDescription=${customDescription ? `"${customDescription}"` : 'null'}`);
|
||||
|
||||
// Удаляем старую запись, если она есть (чтобы избежать конфликта _id)
|
||||
await req.db.collection('ReadModel-BotVerificationReadModel').deleteOne({
|
||||
TargetType: 1,
|
||||
TargetId: value.targetUserId
|
||||
});
|
||||
|
||||
// Вставляем новую запись
|
||||
await req.db.collection('ReadModel-BotVerificationReadModel').insertOne(verification);
|
||||
|
||||
// Сбрасываем кэш UserReadModel, обновляя служебное поле:
|
||||
// это заставляет Query Server перечитать пользователя из MongoDB
|
||||
await req.db.collection('ReadModel-UserReadModel').updateOne(
|
||||
{ UserId: value.targetUserId },
|
||||
{ $set: { _cacheInvalidated: new Date() } }
|
||||
);
|
||||
|
||||
console.log(`Verification created successfully for user ${value.targetUserId}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: verification,
|
||||
warning: 'Please restart messenger-query-server to see changes: docker-compose restart messenger-query-server'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error setting user verification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to set user verification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/users/:userId/debug - отладочные данные о верификации пользователя
|
||||
router.get('/users/:userId/debug', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
// Проверяем UserReadModel
|
||||
const userReadModel = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: userId });
|
||||
|
||||
// Проверяем BotVerificationReadModel
|
||||
const verification = await req.db.collection('ReadModel-BotVerificationReadModel').findOne({
|
||||
TargetType: 1,
|
||||
TargetId: userId
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userReadModel: {
|
||||
BotVerifierId: userReadModel?.BotVerifierId || null,
|
||||
BotVerificationIcon: userReadModel?.BotVerificationIcon || null
|
||||
},
|
||||
verification: verification || null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error debugging verification:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/verification/users/:userId - Remove user verification
|
||||
router.delete('/users/:userId', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// Убираем верификацию из UserReadModel (в обеих коллекциях)
|
||||
const unsetData = {
|
||||
BotVerifierId: "",
|
||||
BotVerificationIcon: ""
|
||||
};
|
||||
|
||||
// Запоминаем текущую версию до обновления
|
||||
const existingUser = await req.db.collection('ReadModel-UserReadModel').findOne({ UserId: parseInt(userId) });
|
||||
const currentVersion = existingUser?.Version || 1;
|
||||
|
||||
await Promise.all([
|
||||
req.db.collection('eventflow-userreadmodel').updateOne(
|
||||
{ UserId: parseInt(userId) },
|
||||
{ $unset: unsetData }
|
||||
),
|
||||
req.db.collection('ReadModel-UserReadModel').updateOne(
|
||||
{ UserId: parseInt(userId) },
|
||||
{
|
||||
$unset: unsetData,
|
||||
$inc: { Version: 1 } // увеличиваем версию, чтобы сбросить кэш Query Server
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
console.log(`Removed verification for user ${userId} (Version: ${currentVersion} -> ${currentVersion + 1})`);
|
||||
|
||||
// Удаляем из BotVerificationReadModel
|
||||
await req.db.collection('ReadModel-BotVerificationReadModel').deleteOne({
|
||||
TargetType: 1,
|
||||
TargetId: parseInt(userId)
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Verification removed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error removing user verification:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to remove verification'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/verification/upload-icon - загрузка файла иконки TGS
|
||||
router.post('/upload-icon', upload.single('icon'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'No file uploaded'
|
||||
});
|
||||
}
|
||||
|
||||
const filePath = req.file.path;
|
||||
const ext = path.extname(req.file.originalname).toLowerCase();
|
||||
|
||||
if (ext !== '.tgs') {
|
||||
await fs.unlink(filePath);
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Only .tgs files are allowed'
|
||||
});
|
||||
}
|
||||
|
||||
// Генерируем уникальный ID документа для этого эмодзи
|
||||
const documentId = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
||||
|
||||
// Загружаем в MinIO (как для эмодзи-паков)
|
||||
await minioHelper.uploadFile(filePath, documentId, 'application/x-tgsticker');
|
||||
|
||||
// Создаём DocumentReadModel (та же структура, что и у эмодзи-паков)
|
||||
const fileReference = Buffer.alloc(8);
|
||||
fileReference.writeBigInt64LE(BigInt(documentId));
|
||||
|
||||
const documentReadModel = {
|
||||
_id: `document-${documentId}`,
|
||||
Id: `document-${documentId}`,
|
||||
Version: 1,
|
||||
DocumentId: documentId,
|
||||
AccessHash: documentId + 100,
|
||||
FileReference: fileReference,
|
||||
DcId: 2,
|
||||
Date: Math.floor(Date.now() / 1000),
|
||||
MimeType: 'application/x-tgsticker',
|
||||
Size: req.file.size,
|
||||
Name: String(documentId),
|
||||
Attributes2: [
|
||||
{
|
||||
_t: 'TDocumentAttributeCustomEmoji',
|
||||
Free: true,
|
||||
TextColor: false,
|
||||
Alt: '🏆',
|
||||
Stickerset: {
|
||||
_t: 'TInputStickerSetEmpty'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Используем правильное имя коллекции (соглашение об именовании EventFlow)
|
||||
const collectionName = 'ReadModel-DocumentReadModel';
|
||||
|
||||
const insertResult = await req.db
|
||||
.collection(collectionName)
|
||||
.insertOne(documentReadModel);
|
||||
|
||||
console.log(`DocumentReadModel created in ${collectionName}: documentId=${documentId}, insertedId=${insertResult.insertedId}`);
|
||||
|
||||
// Убеждаемся, что документ создан
|
||||
const verifyDoc = await req.db
|
||||
.collection(collectionName)
|
||||
.findOne({ DocumentId: documentId });
|
||||
|
||||
if (!verifyDoc) {
|
||||
console.error(`Document not found after insert: documentId=${documentId}`);
|
||||
throw new Error('Document verification failed');
|
||||
}
|
||||
|
||||
console.log(`Document verified in MongoDB: documentId=${documentId}`);
|
||||
|
||||
// Удаляем загруженный файл
|
||||
await fs.unlink(filePath);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
documentId,
|
||||
size: req.file.size
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error uploading icon:', error);
|
||||
if (req.file) {
|
||||
await fs.unlink(req.file.path).catch(() => {});
|
||||
}
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to upload icon'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/stats - статистика верификации
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const [totalVerifiers, activeVerifiers, totalVerifications, userVerifications] = await Promise.all([
|
||||
req.db.collection('ReadModel-BotVerifierReadModel').countDocuments({}),
|
||||
req.db.collection('ReadModel-BotVerifierReadModel').countDocuments({ IsActive: true }),
|
||||
req.db.collection('ReadModel-BotVerificationReadModel').countDocuments({}),
|
||||
req.db.collection('ReadModel-BotVerificationReadModel').countDocuments({ TargetType: 1 })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalVerifiers,
|
||||
activeVerifiers,
|
||||
totalVerifications,
|
||||
userVerifications,
|
||||
channelVerifications: totalVerifications - userVerifications
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching verification stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch statistics'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/verification/icons/:documentId/check - проверить наличие документа иконки
|
||||
router.get('/icons/:documentId/check', async (req, res) => {
|
||||
try {
|
||||
const documentId = parseInt(req.params.documentId);
|
||||
|
||||
// Проверяем в MongoDB
|
||||
const document = await req.db
|
||||
.collection('ReadModel-DocumentReadModel')
|
||||
.findOne({ DocumentId: documentId });
|
||||
|
||||
// Проверяем в MinIO
|
||||
const fileExistsInMinio = await minioHelper.fileExists(documentId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
documentId,
|
||||
existsInMongoDB: !!document,
|
||||
existsInMinIO: fileExistsInMinio,
|
||||
document: document ? {
|
||||
DocumentId: document.DocumentId,
|
||||
Size: document.Size,
|
||||
MimeType: document.MimeType,
|
||||
DcId: document.DcId
|
||||
} : null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking icon:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to check icon'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,340 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
// Определяем текущий каталог (в ES-модулях нет __dirname по умолчанию)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Загружаем переменные окружения в первую очередь, до остальных импортов
|
||||
const envPath = join(__dirname, '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
// Выводим в лог, откуда загружен .env, и проверяем ключи MinIO
|
||||
console.log('Loading .env from:', envPath);
|
||||
console.log('MINIO_ACCESS_KEY:', process.env.MINIO_ACCESS_KEY || 'NOT SET');
|
||||
console.log('MINIO_SECRET_KEY:', process.env.MINIO_SECRET_KEY ? '***' + process.env.MINIO_SECRET_KEY.slice(-4) : 'NOT SET');
|
||||
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { MongoClient } from 'mongodb';
|
||||
import giftsRouter from './routes/gifts.js';
|
||||
import attributesRouter from './routes/attributes.js';
|
||||
import statsRouter from './routes/stats.js';
|
||||
import emojiPacksRouter from './routes/emojipacks.js';
|
||||
import stickerPacksRouter from './routes/stickerpacks.js';
|
||||
import verificationRouter from './routes/verification.js';
|
||||
import reactionsRouter from './routes/reactions.js';
|
||||
import usersRouter from './routes/users.js';
|
||||
import sponsoredMessagesRouter from './routes/sponsoredMessages.js';
|
||||
import serviceNotificationsRouter from './routes/serviceNotifications.js';
|
||||
import settingsRouter from './routes/settings.js';
|
||||
import uploadRouter from './routes/upload.js';
|
||||
import starsRouter from './routes/stars.js';
|
||||
import frozenAccountsRouter from './routes/frozenAccounts.js';
|
||||
import frozenSettingsRouter from './routes/frozenSettings.js';
|
||||
import minioHelper from './utils/minioHelper.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173'
|
||||
}));
|
||||
app.use(express.json({ limit: '500mb' }));
|
||||
app.use(express.urlencoded({ limit: '500mb', extended: true }));
|
||||
app.use('/uploads', express.static('uploads'));
|
||||
|
||||
// MongoDB Connection
|
||||
let db;
|
||||
const mongoClient = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017');
|
||||
|
||||
async function connectDB() {
|
||||
try {
|
||||
await mongoClient.connect();
|
||||
db = mongoClient.db(process.env.DB_NAME || 'tg');
|
||||
console.log('Connected to MongoDB');
|
||||
|
||||
// Создаём индексы
|
||||
await db.collection('AvailableStarGiftReadModel').createIndex({ GiftId: 1 }, { unique: true });
|
||||
await db.collection('AvailableStarGiftReadModel').createIndex({ SoldOut: 1, Stars: 1 });
|
||||
await db.collection('ReadModel-UserReadModel').createIndex({ UserId: 1 });
|
||||
await db.collection('ReadModel-UserReadModel').createIndex({ UserName: 1 });
|
||||
await db.collection('ReadModel-UserReadModel').createIndex({ PhoneNumber: 1 });
|
||||
|
||||
// Индексы для кастомных эмодзи
|
||||
await db.collection('custom_emoji_documents').createIndex({ document_id: 1 }, { unique: true });
|
||||
await db.collection('custom_emoji_documents').createIndex({ 'attributes.stickerset_id': 1 });
|
||||
await db.collection('custom_emoji_documents').createIndex({ 'attributes.alt': 1 });
|
||||
// Коллекции read-моделей EventFlow
|
||||
await db.collection('ReadModel-StickerSetReadModel').createIndex({ StickerSetId: 1 }, { unique: true });
|
||||
await db.collection('ReadModel-StickerSetReadModel').createIndex({ ShortName: 1 }, { unique: true });
|
||||
await db.collection('ReadModel-DocumentReadModel').createIndex({ DocumentId: 1 }, { unique: true });
|
||||
await db.collection('ReadModel-DocumentReadModel').createIndex({ _id: 1 });
|
||||
await db.collection('emoji_packs').createIndex({ stickerset_id: 1, emoticon: 1 }, { unique: true });
|
||||
|
||||
// Индексы для рекламных сообщений
|
||||
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ Id: 1 }, { unique: true });
|
||||
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ ChannelId: 1, IsActive: 1 });
|
||||
await db.collection('ReadModel-SponsoredMessageReadModel').createIndex({ CreatedDate: -1 });
|
||||
|
||||
console.log('Database indexes created');
|
||||
|
||||
// Инициализируем bucket в MinIO
|
||||
try {
|
||||
await minioHelper.ensureBucket();
|
||||
console.log('MinIO storage initialized');
|
||||
} catch (error) {
|
||||
console.warn('MinIO initialization failed (will retry on upload):', error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MongoDB connection error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Прокидываем подключение к БД во все маршруты
|
||||
app.use((req, res, next) => {
|
||||
req.db = db;
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/gifts', giftsRouter);
|
||||
app.use('/api/attributes', attributesRouter);
|
||||
app.use('/api/stats', statsRouter);
|
||||
app.use('/api/emojipacks', emojiPacksRouter);
|
||||
app.use('/api/stickerpacks', stickerPacksRouter);
|
||||
app.use('/api/verification', verificationRouter);
|
||||
app.use('/api/reactions', reactionsRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api/sponsored-messages', sponsoredMessagesRouter);
|
||||
app.use('/api/service-notifications', serviceNotificationsRouter);
|
||||
app.use('/api/users/settings', settingsRouter);
|
||||
app.use('/api/upload', uploadRouter);
|
||||
app.use('/api/stars', starsRouter);
|
||||
app.use('/api/frozen-accounts', frozenAccountsRouter);
|
||||
app.use('/api/frozen-settings', frozenSettingsRouter);
|
||||
|
||||
// Быстрый поиск пользователя (устаревший вариант, перенесён в роутер users)
|
||||
app.get('/api/users-legacy/search', async (req, res) => {
|
||||
try {
|
||||
const { phone, username, limit = 10 } = req.query;
|
||||
|
||||
const filter = {};
|
||||
if (phone) filter.PhoneNumber = phone;
|
||||
if (username) filter.UserName = { $regex: username, $options: 'i' };
|
||||
|
||||
const users = await db
|
||||
.collection('ReadModel-UserReadModel')
|
||||
.find(filter)
|
||||
.limit(parseInt(limit))
|
||||
.project({ UserId: 1, PhoneNumber: 1, UserName: 1, FirstName: 1, LastName: 1 })
|
||||
.toArray();
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Список всех пользователей с постраничной разбивкой
|
||||
app.get('/api/users', async (req, res) => {
|
||||
try {
|
||||
const { page = 1, limit = 50, search = '' } = req.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
|
||||
// Формируем фильтр поиска
|
||||
const filter = {};
|
||||
if (search) {
|
||||
filter.$or = [
|
||||
{ FirstName: { $regex: search, $options: 'i' } },
|
||||
{ LastName: { $regex: search, $options: 'i' } },
|
||||
{ UserName: { $regex: search, $options: 'i' } },
|
||||
{ PhoneNumber: { $regex: search, $options: 'i' } }
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
db.collection('ReadModel-UserReadModel')
|
||||
.find(filter)
|
||||
.sort({ UserId: -1 }) // Сначала новые
|
||||
.skip(skip)
|
||||
.limit(parseInt(limit))
|
||||
.project({
|
||||
UserId: 1,
|
||||
PhoneNumber: 1,
|
||||
UserName: 1,
|
||||
FirstName: 1,
|
||||
LastName: 1,
|
||||
AccessHash: 1,
|
||||
IsDeleted: 1,
|
||||
Bot: 1
|
||||
})
|
||||
.toArray(),
|
||||
db.collection('ReadModel-UserReadModel').countDocuments(filter)
|
||||
]);
|
||||
|
||||
res.json({
|
||||
users,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
totalPages: Math.ceil(total / parseInt(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Список всех каналов
|
||||
app.get('/api/channels', async (req, res) => {
|
||||
try {
|
||||
const channels = await db.collection('eventflow-channelreadmodel')
|
||||
.find({ IsDeleted: { $ne: true } })
|
||||
.sort({ ChannelId: -1 })
|
||||
.project({
|
||||
ChannelId: 1,
|
||||
Title: 1,
|
||||
UserName: 1,
|
||||
ParticipantsCount: 1,
|
||||
AccessHash: 1,
|
||||
Broadcast: 1,
|
||||
MegaGroup: 1
|
||||
})
|
||||
.toArray();
|
||||
|
||||
// Переименовываем ParticipantsCount в MembersCount для совместимости с фронтендом
|
||||
const formattedChannels = channels.map(ch => ({
|
||||
...ch,
|
||||
MembersCount: ch.ParticipantsCount || 0
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
channels: formattedChannels
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching channels:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Проверка работоспособности сервиса
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
mongodb: db ? 'connected' : 'disconnected',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Эндпоинт для проверки соединения с MinIO
|
||||
app.get('/api/test/minio', async (req, res) => {
|
||||
try {
|
||||
const { Client } = await import('minio');
|
||||
|
||||
// Берём конфигурацию из переменных окружения
|
||||
const config = {
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: false,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
|
||||
};
|
||||
|
||||
const testClient = new Client(config);
|
||||
const bucketName = 'tg-files';
|
||||
|
||||
// Проверяем соединение, запросив список bucket'ов
|
||||
const startTime = Date.now();
|
||||
let buckets;
|
||||
try {
|
||||
buckets = await testClient.listBuckets();
|
||||
} catch (listError) {
|
||||
throw new Error(`Failed to list buckets: ${listError.message}`);
|
||||
}
|
||||
const listTime = Date.now() - startTime;
|
||||
|
||||
// Проверяем, существует ли нужный нам bucket
|
||||
const bucketExists = buckets.some(b => b.name === bucketName);
|
||||
|
||||
// Если bucket'а нет, пробуем его создать
|
||||
let bucketCreated = false;
|
||||
if (!bucketExists) {
|
||||
try {
|
||||
await testClient.makeBucket(bucketName, 'us-east-1');
|
||||
bucketCreated = true;
|
||||
} catch (createError) {
|
||||
// Не критично, просто отражаем это в ответе
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
endPoint: config.endPoint,
|
||||
port: config.port,
|
||||
useSSL: config.useSSL,
|
||||
accessKey: config.accessKey,
|
||||
secretKey: '***' + config.secretKey.slice(-4)
|
||||
},
|
||||
connection: {
|
||||
status: 'connected',
|
||||
responseTime: `${listTime}ms`
|
||||
},
|
||||
buckets: buckets.map(b => ({
|
||||
name: b.name,
|
||||
creationDate: b.creationDate
|
||||
})),
|
||||
targetBucket: {
|
||||
name: bucketName,
|
||||
exists: bucketExists,
|
||||
created: bucketCreated
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
config: {
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: process.env.MINIO_PORT || '9000',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
|
||||
secretKey: '***' + (process.env.MINIO_SECRET_KEY || 'minioadmin').slice(-4)
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Обработка ошибок
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: err.message
|
||||
});
|
||||
});
|
||||
|
||||
// Запуск сервера
|
||||
connectDB().then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Star Gift Admin API running on http://localhost:${PORT}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Корректное завершение работы
|
||||
process.on('SIGINT', async () => {
|
||||
await mongoClient.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Client } from 'minio';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const BUCKET_NAME = 'tg-files';
|
||||
|
||||
// Ленивая инициализация: создаём клиента только при первом обращении
|
||||
let minioClient = null;
|
||||
let minioConfig = null;
|
||||
|
||||
function getMinioClient() {
|
||||
if (!minioClient) {
|
||||
// Конфигурация MinIO (должна совпадать с docker-compose.yml)
|
||||
minioConfig = {
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: false,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'test', // совпадает с .env Docker
|
||||
secretKey: process.env.MINIO_SECRET_KEY || '12345678' // совпадает с .env Docker
|
||||
};
|
||||
|
||||
// При первом использовании выводим конфигурацию (секретный ключ скрываем)
|
||||
console.log('MinIO Configuration:', {
|
||||
endPoint: minioConfig.endPoint,
|
||||
port: minioConfig.port,
|
||||
useSSL: minioConfig.useSSL,
|
||||
accessKey: minioConfig.accessKey,
|
||||
secretKey: '***' + minioConfig.secretKey.slice(-4) // показываем только последние 4 символа
|
||||
});
|
||||
|
||||
minioClient = new Client(minioConfig);
|
||||
}
|
||||
return minioClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет наличие bucket'а и создаёт его при отсутствии
|
||||
*/
|
||||
export async function ensureBucket() {
|
||||
const client = getMinioClient();
|
||||
try {
|
||||
console.log(`Checking if bucket "${BUCKET_NAME}" exists...`);
|
||||
const exists = await client.bucketExists(BUCKET_NAME);
|
||||
|
||||
if (!exists) {
|
||||
console.log(`Bucket "${BUCKET_NAME}" does not exist, creating...`);
|
||||
await client.makeBucket(BUCKET_NAME, 'us-east-1');
|
||||
console.log(`Created MinIO bucket: ${BUCKET_NAME}`);
|
||||
} else {
|
||||
console.log(`Bucket "${BUCKET_NAME}" already exists`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure bucket:', {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
statusCode: error.statusCode,
|
||||
endpoint: minioConfig.endPoint + ':' + minioConfig.port,
|
||||
bucket: BUCKET_NAME
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает файл в MinIO
|
||||
* @param {string} localFilePath - путь к локальному файлу
|
||||
* @param {number} documentId - ID документа, используется как имя объекта
|
||||
* @param {string} mimeType - MIME-тип файла
|
||||
* @returns {Promise<string>} имя объекта в MinIO
|
||||
*/
|
||||
export async function uploadFile(localFilePath, documentId, mimeType) {
|
||||
const client = getMinioClient();
|
||||
try {
|
||||
// Убеждаемся, что bucket существует
|
||||
await ensureBucket();
|
||||
|
||||
// file-server ожидает файлы в корне без расширения.
|
||||
// Сохраняем под именем {documentId} (без папки и расширения) —
|
||||
// именно так их ищет file-server из MyTelegram
|
||||
const objectName = `${documentId}`;
|
||||
|
||||
// Получаем сведения о файле
|
||||
const stats = await fs.stat(localFilePath);
|
||||
|
||||
// Загружаем в MinIO
|
||||
const metaData = {
|
||||
'Content-Type': mimeType,
|
||||
'document-id': documentId.toString()
|
||||
};
|
||||
|
||||
await client.fPutObject(
|
||||
BUCKET_NAME,
|
||||
objectName,
|
||||
localFilePath,
|
||||
metaData
|
||||
);
|
||||
|
||||
console.log(`Uploaded to MinIO: ${objectName} (${stats.size} bytes)`);
|
||||
|
||||
return objectName;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload file to MinIO:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет файл из MinIO
|
||||
* @param {number} documentId - ID документа
|
||||
*/
|
||||
export async function deleteFile(documentId) {
|
||||
const client = getMinioClient();
|
||||
try {
|
||||
// Удаляем из корня без расширения
|
||||
const objectName = `${documentId}`;
|
||||
|
||||
try {
|
||||
await client.removeObject(BUCKET_NAME, objectName);
|
||||
console.log(`Deleted from MinIO: ${objectName}`);
|
||||
} catch (err) {
|
||||
console.warn(`File not found in MinIO: ${objectName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete file from MinIO:', error);
|
||||
// Не пробрасываем ошибку — файла может не быть
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, существует ли файл в MinIO
|
||||
* @param {number} documentId - ID документа
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function fileExists(documentId) {
|
||||
const client = getMinioClient();
|
||||
try {
|
||||
// Проверяем в корне без расширения
|
||||
const objectName = `${documentId}`;
|
||||
|
||||
try {
|
||||
await client.statObject(BUCKET_NAME, objectName);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
fileExists,
|
||||
ensureBucket
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate FileReference (24 bytes)
|
||||
* Format: [4 bytes timestamp][20 bytes random]
|
||||
*/
|
||||
export function generateFileReference() {
|
||||
const buffer = Buffer.allocUnsafe(24);
|
||||
|
||||
// First 4 bytes: current UNIX timestamp
|
||||
buffer.writeUInt32BE(Math.floor(Date.now() / 1000), 0);
|
||||
|
||||
// Remaining 20 bytes: cryptographically secure random
|
||||
const randomBytes = crypto.randomBytes(20);
|
||||
randomBytes.copy(buffer, 4);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AccessHash (int64)
|
||||
* Must be cryptographically random
|
||||
*/
|
||||
export function generateAccessHash() {
|
||||
// Generate 8 random bytes
|
||||
const buffer = crypto.randomBytes(8);
|
||||
|
||||
// Convert to signed int64 string for MongoDB
|
||||
return buffer.readBigInt64BE(0).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate DocumentId using timestamp + random
|
||||
* Ensures uniqueness without DB roundtrip
|
||||
*/
|
||||
export function generateDocumentId() {
|
||||
// Timestamp in milliseconds * 10000 + random component
|
||||
return Date.now() * 10000 + Math.floor(Math.random() * 10000);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { MongoClient } from 'mongodb';
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
// Определяем текущий каталог (в ES-модулях нет __dirname по умолчанию)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Загружаем переменные окружения
|
||||
const envPath = join(__dirname, '..', '..', '.env');
|
||||
dotenv.config({ path: envPath });
|
||||
|
||||
const mongoUrl = process.env.MONGODB_URI || 'mongodb://localhost:27017';
|
||||
const dbName = process.env.DB_NAME || 'tg';
|
||||
const adminApiUrl = process.env.ADMIN_API_URL || 'http://localhost:5555';
|
||||
|
||||
let db;
|
||||
let isProcessing = false;
|
||||
|
||||
// Подключаемся к MongoDB
|
||||
async function connectDB() {
|
||||
try {
|
||||
const client = await MongoClient.connect(mongoUrl);
|
||||
db = client.db(dbName);
|
||||
console.log('Service Notification Worker connected to MongoDB');
|
||||
return client;
|
||||
} catch (error) {
|
||||
console.error('MongoDB connection error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Отправляем уведомление через HTTP API
|
||||
async function sendNotificationViaApi(event) {
|
||||
const payload = {
|
||||
userId: event.userId,
|
||||
type: event.type,
|
||||
message: event.message,
|
||||
popup: event.popup,
|
||||
mediaUrl: event.mediaUrl,
|
||||
mediaType: event.mediaType
|
||||
};
|
||||
|
||||
console.log(` Calling Admin API: ${adminApiUrl}/api/service-notifications/send`);
|
||||
console.log(` Payload:`, JSON.stringify(payload, null, 2));
|
||||
|
||||
const response = await fetch(`${adminApiUrl}/api/service-notifications/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
console.log(` Response status: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(` API Error: ${errorText}`);
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(` API Response:`, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Обрабатываем необработанные события уведомлений
|
||||
async function processPendingNotifications() {
|
||||
if (isProcessing) {
|
||||
return; // обработка уже идёт
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessing = true;
|
||||
|
||||
const eventsCollection = db.collection('ServiceNotificationEvents');
|
||||
|
||||
// Берём необработанные события (не более 10 за раз)
|
||||
const pendingEvents = await eventsCollection
|
||||
.find({ processed: false })
|
||||
.sort({ createdAt: 1 })
|
||||
.limit(10)
|
||||
.toArray();
|
||||
|
||||
if (pendingEvents.length === 0) {
|
||||
return; // необработанных событий нет
|
||||
}
|
||||
|
||||
console.log(`\nProcessing ${pendingEvents.length} pending notifications...`);
|
||||
|
||||
for (const event of pendingEvents) {
|
||||
try {
|
||||
console.log(` Sending notification to user ${event.userId} (type: ${event.type})`);
|
||||
|
||||
// Отправляем через HTTP API
|
||||
await sendNotificationViaApi(event);
|
||||
|
||||
// Помечаем как обработанное
|
||||
await eventsCollection.updateOne(
|
||||
{ _id: event._id },
|
||||
{
|
||||
$set: {
|
||||
processed: true,
|
||||
processedAt: new Date(),
|
||||
status: 'sent'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(` Notification sent to user ${event.userId}`);
|
||||
} catch (error) {
|
||||
console.error(` Failed to send notification to user ${event.userId}:`, error.message);
|
||||
|
||||
// Помечаем как неудачное
|
||||
await eventsCollection.updateOne(
|
||||
{ _id: event._id },
|
||||
{
|
||||
$set: {
|
||||
processed: true,
|
||||
processedAt: new Date(),
|
||||
status: 'failed',
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Processed ${pendingEvents.length} notifications\n`);
|
||||
} catch (error) {
|
||||
console.error('Error processing notifications:', error);
|
||||
} finally {
|
||||
isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Главный цикл воркера
|
||||
async function startWorker() {
|
||||
console.log('Service Notification Worker started');
|
||||
console.log(`Using Admin API: ${adminApiUrl}`);
|
||||
console.log('Checking for pending notifications every 5 seconds...\n');
|
||||
|
||||
await connectDB();
|
||||
|
||||
// Сразу обрабатываем события при запуске
|
||||
await processPendingNotifications();
|
||||
|
||||
// Затем проверяем каждые 5 секунд
|
||||
setInterval(async () => {
|
||||
await processPendingNotifications();
|
||||
}, 5000); // проверка каждые 5 секунд
|
||||
}
|
||||
|
||||
// Корректное завершение работы
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down worker...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\nShutting down worker...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Запускаем воркер
|
||||
startWorker().catch(console.error);
|
||||
@@ -0,0 +1,38 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
stargift-admin-backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
# ✅ Updated with correct credentials from docker/compose/.env
|
||||
- MONGODB_URI=mongodb://user:CHANGE_ME@mongodb:27017/tg?authSource=admin
|
||||
- DB_NAME=tg
|
||||
- PORT=3001
|
||||
- CORS_ORIGIN=http://localhost:5173
|
||||
- ADMIN_API_URL=http://localhost:5555
|
||||
- ADMIN_API_KEY=CHANGE_ME
|
||||
# ✅ MinIO credentials
|
||||
- MINIO_ENDPOINT=minio
|
||||
- MINIO_PORT=9000
|
||||
- MINIO_ACCESS_KEY=minioadmin
|
||||
- MINIO_SECRET_KEY=CHANGE_ME
|
||||
# ✅ RabbitMQ credentials
|
||||
- RABBITMQ_URL=amqp://user:CHANGE_ME@rabbitmq:5672
|
||||
networks:
|
||||
- default
|
||||
- compose_default
|
||||
|
||||
stargift-admin-frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
- stargift-admin-backend
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
compose_default:
|
||||
external: true
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/star.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Star Gifts Admin Panel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Outfit:wght@500;700;900&display=swap"
|
||||
rel="stylesheet">
|
||||
<script>
|
||||
// Set theme BEFORE first paint to avoid flash/jump
|
||||
(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('void-theme');
|
||||
if (t) document.documentElement.setAttribute('data-theme', t);
|
||||
} catch (e) { }
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="custom-cursor"></div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "stargift-admin-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.5",
|
||||
"@mui/material": "^7.3.5",
|
||||
"axios": "^1.6.2",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lucide-react": "^0.303.0",
|
||||
"pako": "^2.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"recharts": "^2.10.3",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,114 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import Layout from './components/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import GiftsList from './pages/GiftsList'
|
||||
import CreateGift from './pages/CreateGift'
|
||||
import EditGift from './pages/EditGift'
|
||||
import SendGift from './pages/SendGift'
|
||||
import Attributes from './pages/Attributes'
|
||||
import Users from './pages/Users'
|
||||
import UserManagement from './pages/UserManagement'
|
||||
import FrozenIconSettings from './pages/FrozenIconSettings'
|
||||
import Statistics from './pages/Statistics'
|
||||
import EmojiPacks from './pages/EmojiPacks'
|
||||
import CreateEmojiPack from './pages/CreateEmojiPack'
|
||||
import ManageEmojiPack from './pages/ManageEmojiPack'
|
||||
import BulkUploadEmojis from './pages/BulkUploadEmojis'
|
||||
import StickerPacks from './pages/StickerPacks'
|
||||
import CreateStickerPack from './pages/CreateStickerPack'
|
||||
import ManageStickerPack from './pages/ManageStickerPack'
|
||||
import BulkUploadStickers from './pages/BulkUploadStickers'
|
||||
import BulkUploadPacks from './pages/BulkUploadPacks'
|
||||
import BulkUploadStickerPacks from './pages/BulkUploadStickerPacks'
|
||||
import FeaturedStickerPacks from './pages/FeaturedStickerPacks'
|
||||
import Verification from './pages/Verification'
|
||||
import Reactions from './pages/Reactions'
|
||||
import CreateReaction from './pages/CreateReaction'
|
||||
import BulkUploadReactions from './pages/BulkUploadReactions'
|
||||
import FeaturedEmojiPacks from './FeaturedEmojiPacks'
|
||||
import EmojiPacksBackup from './pages/EmojiPacksBackup'
|
||||
import SponsoredMessages from './pages/SponsoredMessages'
|
||||
import ServiceNotifications from './pages/ServiceNotifications'
|
||||
import CreateServiceNotification from './pages/CreateServiceNotification'
|
||||
import IssueStars from './pages/IssueStars'
|
||||
import FrozenAccounts from './pages/FrozenAccounts'
|
||||
import FrozenSettings from './pages/FrozenSettings'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#0f0f0f',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
iconTheme: {
|
||||
primary: '#00ff88',
|
||||
secondary: '#000',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 4000,
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="gifts" element={<GiftsList />} />
|
||||
<Route path="gifts/create" element={<CreateGift />} />
|
||||
<Route path="gifts/edit/:giftId" element={<EditGift />} />
|
||||
<Route path="gifts/send" element={<SendGift />} />
|
||||
<Route path="gifts/attributes" element={<Attributes />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="user-management" element={<UserManagement />} />
|
||||
<Route path="frozen-icon-settings" element={<FrozenIconSettings />} />
|
||||
<Route path="statistics" element={<Statistics />} />
|
||||
<Route path="emojipacks" element={<EmojiPacks />} />
|
||||
<Route path="emojipacks/create" element={<CreateEmojiPack />} />
|
||||
<Route path="emojipacks/bulk-upload" element={<BulkUploadEmojis />} />
|
||||
<Route path="emojipacks/featured" element={<FeaturedEmojiPacks />} />
|
||||
<Route path="emojipacks/backup" element={<EmojiPacksBackup />} />
|
||||
<Route path="emojipacks/:id" element={<ManageEmojiPack />} />
|
||||
<Route path="emojipacks/:id/edit" element={<ManageEmojiPack />} />
|
||||
<Route path="emojipacks/:packId/bulk-upload" element={<BulkUploadEmojis />} />
|
||||
<Route path="stickerpacks" element={<StickerPacks />} />
|
||||
<Route path="stickerpacks/create" element={<CreateStickerPack />} />
|
||||
<Route path="stickerpacks/bulk-upload" element={<BulkUploadStickers />} />
|
||||
<Route path="stickerpacks/bulk-upload-nested" element={<BulkUploadPacks />} />
|
||||
<Route path="stickerpacks/bulk-upload-stickers" element={<BulkUploadStickerPacks />} />
|
||||
<Route path="stickerpacks/featured" element={<FeaturedStickerPacks />} />
|
||||
<Route path="stickerpacks/:id" element={<ManageStickerPack />} />
|
||||
<Route path="stickerpacks/:id/edit" element={<ManageStickerPack />} />
|
||||
<Route path="stickerpacks/:packId/bulk-upload" element={<BulkUploadStickers />} />
|
||||
<Route path="verification" element={<Verification />} />
|
||||
<Route path="reactions" element={<Reactions />} />
|
||||
<Route path="reactions/create" element={<CreateReaction />} />
|
||||
<Route path="reactions/bulk-upload" element={<BulkUploadReactions />} />
|
||||
<Route path="sponsored-messages" element={<SponsoredMessages />} />
|
||||
<Route path="service-notifications" element={<ServiceNotifications />} />
|
||||
<Route path="service-notifications/create" element={<CreateServiceNotification />} />
|
||||
<Route path="service-notifications/edit/:id" element={<CreateServiceNotification />} />
|
||||
<Route path="stars/issue" element={<IssueStars />} />
|
||||
<Route path="frozen-accounts" element={<FrozenAccounts />} />
|
||||
<Route path="frozen-settings" element={<FrozenSettings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,239 @@
|
||||
/* Telegram Dark Theme */
|
||||
.featured-packs-container {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
padding: 12px 24px;
|
||||
background: #5288c1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: #3d5a7a;
|
||||
}
|
||||
|
||||
.featured-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featured-pack-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #17212b;
|
||||
border: 1px solid #2b5278;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.featured-pack-item:hover {
|
||||
border-color: #5288c1;
|
||||
background: #1f2c38;
|
||||
}
|
||||
|
||||
.pack-order {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #5288c1;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pack-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pack-info h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pack-meta {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #8b98a5;
|
||||
}
|
||||
|
||||
.pack-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid #2b5278;
|
||||
background: #0e1621;
|
||||
color: #ffffff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover:not(:disabled) {
|
||||
background: #2b5278;
|
||||
border-color: #5288c1;
|
||||
}
|
||||
|
||||
.btn-icon:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
color: #ff6b6b;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #8b98a5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #17212b;
|
||||
border: 1px solid #2b5278;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 24px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pack-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pack-list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid #2b5278;
|
||||
background: #0e1621;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pack-list-item:hover {
|
||||
background: #1f2c38;
|
||||
border-color: #5288c1;
|
||||
}
|
||||
|
||||
.pack-list-item h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-add-small {
|
||||
padding: 8px 16px;
|
||||
background: #5288c1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-add-small:hover {
|
||||
background: #3d5a7a;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #2b5278;
|
||||
border: 1px solid #2b5278;
|
||||
color: #ffffff;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: #3d5a7a;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-size: 18px;
|
||||
color: #8b98a5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import './FeaturedEmojiPacks.css';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
function FeaturedEmojiPacks() {
|
||||
const [featuredPacks, setFeaturedPacks] = useState([]);
|
||||
const [allPacks, setAllPacks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [selectedPack, setSelectedPack] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeaturedPacks();
|
||||
loadAllPacks();
|
||||
}, []);
|
||||
|
||||
const loadFeaturedPacks = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/emojipacks/featured`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setFeaturedPacks(data.packs);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load featured packs');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAllPacks = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/emojipacks?limit=1000&emojis=true`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Filter only emoji packs (Emojis === true)
|
||||
setAllPacks(data.packs.filter(p => p.Emojis === true));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load all packs:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const addToFeatured = async (stickerSetId) => {
|
||||
try {
|
||||
const maxOrder = Math.max(...featuredPacks.map(p => p.FeaturedOrder || 0), 0);
|
||||
const response = await fetch(`${API_URL}/api/emojipacks/${stickerSetId}/featured`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isFeatured: true, featuredOrder: maxOrder + 1 })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await loadFeaturedPacks();
|
||||
setShowAddModal(false);
|
||||
} else {
|
||||
alert('Failed to add to featured: ' + data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFromFeatured = async (stickerSetId) => {
|
||||
if (!confirm('Remove this pack from featured?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/emojipacks/${stickerSetId}/featured`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isFeatured: false })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await loadFeaturedPacks();
|
||||
} else {
|
||||
alert('Failed to remove: ' + data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const moveUp = async (pack, index) => {
|
||||
if (index === 0) return;
|
||||
|
||||
const newPacks = [...featuredPacks];
|
||||
[newPacks[index], newPacks[index - 1]] = [newPacks[index - 1], newPacks[index]];
|
||||
|
||||
await reorderPacks(newPacks);
|
||||
};
|
||||
|
||||
const moveDown = async (pack, index) => {
|
||||
if (index === featuredPacks.length - 1) return;
|
||||
|
||||
const newPacks = [...featuredPacks];
|
||||
[newPacks[index], newPacks[index + 1]] = [newPacks[index + 1], newPacks[index]];
|
||||
|
||||
await reorderPacks(newPacks);
|
||||
};
|
||||
|
||||
const reorderPacks = async (newPacks) => {
|
||||
const reorderedPacks = newPacks.map((pack, index) => ({
|
||||
stickerSetId: pack.StickerSetId,
|
||||
featuredOrder: index + 1
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/emojipacks/featured/reorder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ packs: reorderedPacks })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await loadFeaturedPacks();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Error reordering: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading">Loading...</div>;
|
||||
if (error) return <div className="error">{error}</div>;
|
||||
|
||||
const availablePacks = allPacks.filter(
|
||||
p => !featuredPacks.some(fp => fp.StickerSetId === p.StickerSetId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="featured-packs-container">
|
||||
<div className="header">
|
||||
<h1>🌟 Featured Emoji Packs</h1>
|
||||
<button className="btn-add" onClick={() => setShowAddModal(true)}>
|
||||
+ Add Featured Pack
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="featured-list">
|
||||
{featuredPacks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No featured packs yet. Add some to get started!</p>
|
||||
</div>
|
||||
) : (
|
||||
featuredPacks.map((pack, index) => (
|
||||
<div key={pack.StickerSetId} className="featured-pack-item">
|
||||
<div className="pack-order">#{index + 1}</div>
|
||||
<div className="pack-info">
|
||||
<h3>{pack.Title}</h3>
|
||||
<p className="pack-meta">
|
||||
{pack.ShortName} • {pack.Count} emojis • ID: {pack.StickerSetId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pack-actions">
|
||||
<button
|
||||
onClick={() => moveUp(pack, index)}
|
||||
disabled={index === 0}
|
||||
className="btn-icon"
|
||||
title="Move up"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveDown(pack, index)}
|
||||
disabled={index === featuredPacks.length - 1}
|
||||
className="btn-icon"
|
||||
title="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeFromFeatured(pack.StickerSetId)}
|
||||
className="btn-remove"
|
||||
title="Remove from featured"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowAddModal(false)}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<h2>Add Pack to Featured</h2>
|
||||
<div className="pack-list">
|
||||
{availablePacks.length === 0 ? (
|
||||
<p>All emoji packs are already featured!</p>
|
||||
) : (
|
||||
availablePacks.map(pack => (
|
||||
<div key={pack.StickerSetId} className="pack-list-item">
|
||||
<div>
|
||||
<h4>{pack.Title}</h4>
|
||||
<p className="pack-meta">
|
||||
{pack.ShortName} • {pack.Count} emojis
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => addToFeatured(pack.StickerSetId)}
|
||||
className="btn-add-small"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => setShowAddModal(false)} className="btn-close">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeaturedEmojiPacks;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function CustomCursor() {
|
||||
useEffect(() => {
|
||||
const cursor = document.getElementById('custom-cursor')
|
||||
|
||||
const moveCursor = (e) => {
|
||||
if (cursor) {
|
||||
cursor.style.left = `${e.clientX}px`
|
||||
cursor.style.top = `${e.clientY}px`
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseDown = () => {
|
||||
if (cursor) cursor.classList.add('scale-75')
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (cursor) cursor.classList.remove('scale-75')
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', moveCursor)
|
||||
window.addEventListener('mousedown', handleMouseDown)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', moveCursor)
|
||||
window.removeEventListener('mousedown', handleMouseDown)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Outlet, NavLink, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, Gift, Plus, Send, Users, BarChart3, Sparkles, Package, Sticker, Shield, Smile, Star, UserCog, Snowflake, Megaphone, ChevronRight, Menu, X, Bell, Coins, AlertCircle, Search, LogOut } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(window.innerWidth >= 1024)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const menuSections = [
|
||||
{
|
||||
title: 'Overview',
|
||||
items: [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard', badge: null }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Star Gifts',
|
||||
items: [
|
||||
{ to: '/gifts', icon: Gift, label: 'All Gifts', badge: null },
|
||||
{ to: '/gifts/create', icon: Plus, label: 'Create Gift', badge: null },
|
||||
{ to: '/gifts/send', icon: Send, label: 'Send to User', badge: null },
|
||||
{ to: '/gifts/attributes', icon: Sparkles, label: 'Attributes', badge: 'New' },
|
||||
{ to: '/stars/issue', icon: Coins, label: 'Issue Stars', badge: 'New' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Content',
|
||||
items: [
|
||||
{ to: '/emojipacks', icon: Package, label: 'Emoji Packs', badge: null },
|
||||
{ to: '/emojipacks/featured', icon: Star, label: 'Featured Packs', badge: 'New' },
|
||||
{ to: '/stickerpacks', icon: Sticker, label: 'Sticker Packs', badge: null },
|
||||
{ to: '/reactions', icon: Smile, label: 'Reactions', badge: null },
|
||||
{ to: '/reactions/bulk-upload', icon: Package, label: 'Bulk Upload Reactions', badge: null }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Advertising',
|
||||
items: [
|
||||
{ to: '/sponsored-messages', icon: Megaphone, label: 'Sponsored Ads', badge: 'Hot' },
|
||||
{ to: '/service-notifications', icon: Bell, label: 'Notifications', badge: 'New' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Users',
|
||||
items: [
|
||||
{ to: '/users', icon: Users, label: 'All Users', badge: null },
|
||||
{ to: '/user-management', icon: UserCog, label: 'Management', badge: null },
|
||||
{ to: '/verification', icon: Shield, label: 'Verification', badge: null },
|
||||
{ to: '/frozen-accounts', icon: AlertCircle, label: 'Frozen Accounts', badge: 'New' },
|
||||
{ to: '/frozen-icon-settings', icon: Snowflake, label: 'Frozen Icon', badge: null },
|
||||
{ to: '/frozen-settings', icon: Snowflake, label: 'Frozen Settings', badge: 'New' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
items: [
|
||||
{ to: '/statistics', icon: BarChart3, label: 'Statistics', badge: null }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
// Breadcrumb logic
|
||||
const currentSection = menuSections.find(s => s.items.some(i => i.to === location.pathname))?.title || 'Dashboard'
|
||||
const currentPage = menuSections.flatMap(s => s.items).find(i => i.to === location.pathname)?.label || 'Overview'
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-bg-app text-fg font-sans selection:bg-accent selection:text-black">
|
||||
{/* Mobile Menu Overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className={`
|
||||
fixed lg:static inset-y-0 left-0 z-50
|
||||
w-[280px] flex-shrink-0 bg-bg-side border-r border-border
|
||||
transform transition-transform duration-300 ease-in-out
|
||||
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
flex flex-col
|
||||
`}>
|
||||
{/* Logo */}
|
||||
<div className="h-[70px] flex items-center px-6 border-b border-border">
|
||||
<NavLink to="/" className="flex items-center gap-3 font-heading font-black text-xl tracking-tight hover:opacity-80 transition-opacity">
|
||||
<div className="bg-accent/10 p-2 rounded-lg border border-accent/20 shadow-[0_0_10px_rgba(0,242,255,0.1)]">
|
||||
<Sparkles className="w-5 h-5 text-accent" />
|
||||
</div>
|
||||
<span>
|
||||
MY<span className="text-accent">TELEGRAM</span>
|
||||
</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
{/* Search (Visual Placeholder) */}
|
||||
<div className="p-5 pb-0">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-fg-muted group-focus-within:text-accent transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
className="w-full bg-bg-panel border border-border rounded-lg pl-9 pr-4 py-2.5 text-sm text-fg placeholder-fg-muted focus:border-accent focus:shadow-[0_0_15px_var(--accent-glow)] outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-5 px-3 space-y-6 scrollbar-thin scrollbar-thumb-bg-panel">
|
||||
{menuSections.map((section, idx) => (
|
||||
<div key={idx}>
|
||||
<h3 className="px-4 mb-2 text-xs font-bold text-fg-muted uppercase tracking-wider">
|
||||
{section.title}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{section.items.map(({ to, icon: Icon, label, badge }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 border border-transparent
|
||||
${isActive
|
||||
? 'bg-accent/10 text-accent border-accent/20 shadow-[0_0_10px_rgba(0,242,255,0.1)]'
|
||||
: 'text-fg-muted hover:text-fg hover:bg-bg-panel hover:border-border'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{badge && (
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide border
|
||||
${badge === 'Hot'
|
||||
? 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
: 'bg-accent/10 text-accent border-accent/20'
|
||||
}`}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Wrapper */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-bg-app relative">
|
||||
{/* Background Ambient Glow */}
|
||||
<div className="absolute top-0 left-0 w-full h-[500px] bg-accent/5 rounded-full blur-[120px] pointer-events-none -translate-y-1/2 opacity-50" />
|
||||
|
||||
{/* Top Header */}
|
||||
<header className="h-[70px] flex items-center justify-between px-6 lg:px-8 border-b border-border bg-bg-app/80 backdrop-blur-md sticky top-0 z-30">
|
||||
{/* Left: Breadcrumbs / Mobile Menu Toggle */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="lg:hidden p-2 text-fg hover:bg-bg-panel rounded-lg transition-colors"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<nav className="hidden sm:flex items-center text-sm font-medium text-fg-muted">
|
||||
<span className="hover:text-fg transition-colors cursor-pointer">Admin</span>
|
||||
<ChevronRight className="w-4 h-4 mx-2 opacity-50" />
|
||||
<span className="hover:text-fg transition-colors cursor-pointer">{currentSection}</span>
|
||||
<ChevronRight className="w-4 h-4 mx-2 opacity-50" />
|
||||
<span className="text-accent font-semibold">{currentPage}</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<button className="relative p-2 text-fg-muted hover:text-accent hover:bg-bg-panel rounded-lg transition-all group">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-accent rounded-full border-2 border-bg-app shadow-[0_0_10px_var(--accent)]" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 overflow-y-auto p-4 lg:p-8 relative z-0 scrollbar-thin scrollbar-thumb-bg-panel">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function SendNotificationModal({ template, onClose }) {
|
||||
const [sendToAll, setSendToAll] = useState(false);
|
||||
const [userIds, setUserIds] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const handleSend = async () => {
|
||||
// Validate input
|
||||
if (!sendToAll && !userIds.trim()) {
|
||||
toast.error('Please enter user IDs or select "Send to All Users"');
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse user IDs
|
||||
let userIdArray = [];
|
||||
if (!sendToAll) {
|
||||
userIdArray = userIds
|
||||
.split(/[,\s\n]+/)
|
||||
.map(id => id.trim())
|
||||
.filter(id => id && !isNaN(id))
|
||||
.map(id => parseInt(id));
|
||||
|
||||
if (userIdArray.length === 0) {
|
||||
toast.error('No valid user IDs found');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm action
|
||||
const confirmMessage = sendToAll
|
||||
? 'Are you sure you want to send this notification to ALL users?'
|
||||
: `Are you sure you want to send this notification to ${userIdArray.length} user(s)?`;
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSending(true);
|
||||
const response = await fetch(`${API_URL}/api/service-notifications/${template.Id}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sendToAll,
|
||||
userIds: sendToAll ? [] : userIdArray
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success(`Notification queued for ${data.targetUserCount} user(s)`);
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to send notification');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending notification:', error);
|
||||
toast.error('Error sending notification');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Send Notification</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Send "{template.Title}" to users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Template Preview */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Template Preview</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Type:</span>{' '}
|
||||
<span className="px-2 py-0.5 bg-blue-100 text-blue-800 rounded text-xs">
|
||||
{template.Type}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Title:</span>{' '}
|
||||
<span className="text-gray-900">{template.Title}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Message:</span>{' '}
|
||||
<div className="mt-1 text-gray-600 whitespace-pre-wrap">
|
||||
{template.Message}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">Display:</span>{' '}
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
template.IsPopup
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{template.IsPopup ? 'Popup' : 'Message'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Send To
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{/* Send to All */}
|
||||
<label className="flex items-start p-3 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="radio"
|
||||
checked={sendToAll}
|
||||
onChange={() => setSendToAll(true)}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">All Users</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
Send to every registered user in the system
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Send to Specific Users */}
|
||||
<label className="flex items-start p-3 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!sendToAll}
|
||||
onChange={() => setSendToAll(false)}
|
||||
className="mt-1 mr-3"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-900">Specific Users</div>
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
Enter user IDs (comma or newline separated)
|
||||
</div>
|
||||
{!sendToAll && (
|
||||
<textarea
|
||||
value={userIds}
|
||||
onChange={(e) => setUserIds(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||
placeholder="2010001, 2010002, 2010003 or one per line"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800">
|
||||
Important
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700">
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>This action cannot be undone</li>
|
||||
<li>Users will receive the notification immediately</li>
|
||||
<li>Popup notifications require user interaction to dismiss</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-gray-200 flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={sending}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={sending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{sending ? 'Sending...' : '📤 Send Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Lottie from 'lottie-react'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import pako from 'pako'
|
||||
|
||||
export default function StickerPreview({ stickerBase64, documentId, size = 120 }) {
|
||||
const [animationData, setAnimationData] = useState(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// If documentId is provided (new format), load from MinIO
|
||||
if (documentId && typeof documentId === 'number') {
|
||||
loadStickerFromMinIO(documentId)
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy: If stickerBase64 is provided (old format)
|
||||
if (!stickerBase64) {
|
||||
setAnimationData(null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let jsonString
|
||||
|
||||
// Check if it's a Buffer object (from MongoDB Binary)
|
||||
if (typeof stickerBase64 === 'object' && stickerBase64.type === 'Buffer' && Array.isArray(stickerBase64.data)) {
|
||||
// Convert Buffer array to string
|
||||
jsonString = String.fromCharCode.apply(null, stickerBase64.data)
|
||||
} else if (typeof stickerBase64 === 'string') {
|
||||
// Try to decode as base64
|
||||
try {
|
||||
jsonString = atob(stickerBase64)
|
||||
} catch {
|
||||
// If it fails, assume it's already a JSON string
|
||||
jsonString = stickerBase64
|
||||
}
|
||||
} else {
|
||||
throw new Error('Unknown sticker format')
|
||||
}
|
||||
|
||||
const data = JSON.parse(jsonString)
|
||||
setAnimationData(data)
|
||||
setError(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to parse sticker data:', err)
|
||||
setError(true)
|
||||
}
|
||||
}, [stickerBase64, documentId])
|
||||
|
||||
const loadStickerFromMinIO = async (docId) => {
|
||||
setLoading(true)
|
||||
setError(false)
|
||||
|
||||
try {
|
||||
// For now, show placeholder since we can't directly access MinIO from frontend
|
||||
// In production, you'd need a backend endpoint to proxy MinIO files
|
||||
console.log(`Sticker DocumentId: ${docId}`)
|
||||
|
||||
// Show placeholder emoji for now
|
||||
setAnimationData(null)
|
||||
setError(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to load sticker from MinIO:', err)
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!stickerBase64 && !documentId) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center bg-gray-100 rounded-lg"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<span className="text-gray-400 text-2xl">🎁</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center bg-red-50 rounded-lg border border-red-200"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<AlertCircle className="w-6 h-6 text-red-500 mb-1" />
|
||||
<span className="text-xs text-red-600">Invalid sticker</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!animationData) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center bg-gray-100 rounded-lg"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center bg-gradient-to-br from-primary-50 to-purple-50 rounded-lg"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Lottie
|
||||
animationData={animationData}
|
||||
loop={true}
|
||||
style={{ width: size * 0.9, height: size * 0.9 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--bg-app: #050505;
|
||||
--bg-side: #0a0a0a;
|
||||
--bg-panel: #0f0f0f;
|
||||
--fg: #ffffff;
|
||||
--fg-muted: #555555;
|
||||
--accent: #00f2ff;
|
||||
--accent-glow: rgba(0, 242, 255, 0.2);
|
||||
--border: rgba(255, 255, 255, 0.05);
|
||||
--glass: rgba(255, 255, 255, 0.02);
|
||||
--success: #00ff88;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-app: #f3f4f6;
|
||||
--bg-side: #ffffff;
|
||||
--bg-panel: #ffffff;
|
||||
--fg: #111827;
|
||||
--fg-muted: #4b5563;
|
||||
--accent: #0891b2;
|
||||
--accent-glow: rgba(8, 145, 178, 0.1);
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--glass: rgba(0, 0, 0, 0.05);
|
||||
--success: #059669;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-bg-app text-fg font-sans;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.01em;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-panel);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
}
|
||||
}
|
||||
|
||||
/* Re-enable default cursor on touch devices */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
* {
|
||||
cursor: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-heading text-fg;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Cursor */
|
||||
#custom-cursor {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: url('/Pointer.cur') center/contain no-repeat;
|
||||
transform: translate(-50%, -50%);
|
||||
/* Center cursor */
|
||||
}
|
||||
|
||||
[data-theme="light"] #custom-cursor {
|
||||
background: url('/Pointer1.cur') center/contain no-repeat;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
#custom-cursor {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
/* Modern Cards with VOIDPAY style */
|
||||
.card {
|
||||
@apply bg-bg-panel border border-border rounded-2xl p-6 transition-all duration-300;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
@apply border-accent shadow-[0_0_15px_var(--accent-glow)];
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply px-6 py-3 rounded-lg font-bold font-heading transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-fg text-bg-app hover:bg-accent hover:text-black hover:shadow-[0_0_15px_var(--accent-glow)];
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-glass text-fg border border-border hover:bg-fg hover:text-bg-app;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.input {
|
||||
@apply w-full px-4 py-3 bg-bg-panel border border-border rounded-lg text-fg placeholder-fg-muted outline-none transition-all;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
@apply border-accent shadow-[0_0_15px_var(--accent-glow)];
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.void-sidebar {
|
||||
@apply w-[280px] bg-bg-side border-r border-border flex flex-col flex-shrink-0;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
@apply block px-6 py-3 text-fg-muted font-medium text-sm transition-all border-l-2 border-transparent;
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
@apply bg-[rgba(0, 242, 255, 0.05)] text-fg border-accent;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Gifts API
|
||||
export const giftsApi = {
|
||||
getAll: (params) => api.get('/gifts', { params }),
|
||||
getById: (giftId) => api.get(`/gifts/${giftId}`),
|
||||
create: (formData) => api.post('/gifts', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}),
|
||||
update: (giftId, formData) => api.put(`/gifts/${giftId}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}),
|
||||
delete: (giftId) => api.delete(`/gifts/${giftId}`),
|
||||
markSoldOut: (giftId, soldOut) => api.patch(`/gifts/${giftId}/soldout`, { soldOut }),
|
||||
purchase: (giftId) => api.patch(`/gifts/${giftId}/purchase`),
|
||||
}
|
||||
|
||||
// Stats API
|
||||
export const statsApi = {
|
||||
getOverview: () => api.get('/stats'),
|
||||
getSentStats: () => api.get('/stats/sent'),
|
||||
}
|
||||
|
||||
// Health check
|
||||
export const healthCheck = () => api.get('/health')
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,553 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Trash2, Upload, Sparkles, Image as ImageIcon, FileArchive, ChevronDown, Package, Gift, Settings } from 'lucide-react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
const Attributes = () => {
|
||||
const [gifts, setGifts] = useState([]);
|
||||
const [selectedGiftId, setSelectedGiftId] = useState('');
|
||||
const [attributeSets, setAttributeSets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [modelFile, setModelFile] = useState(null);
|
||||
const [patternFile, setPatternFile] = useState(null);
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [patternName, setPatternName] = useState('');
|
||||
const [backdropName, setBackdropName] = useState(''); // Kept for logic, even if auto-generated
|
||||
const [modelRarity, setModelRarity] = useState(1000);
|
||||
const [patternRarity, setPatternRarity] = useState(1000);
|
||||
const [backdropRarity, setBackdropRarity] = useState(1000); // Kept for logic
|
||||
|
||||
// ZIP upload state
|
||||
const [zipFile, setZipFile] = useState(null);
|
||||
const [zipGiftId, setZipGiftId] = useState('');
|
||||
const [uploadResult, setUploadResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGifts();
|
||||
fetchAttributeSets();
|
||||
}, []);
|
||||
|
||||
const fetchGifts = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/gifts`);
|
||||
const data = await response.json();
|
||||
setGifts(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching gifts:', error);
|
||||
toast.error('Failed to load gifts');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAttributeSets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_URL}/api/attributes`);
|
||||
const data = await response.json();
|
||||
setAttributeSets(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching attribute sets:', error);
|
||||
toast.error('Failed to fetch attribute sets');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZipUpload = async () => {
|
||||
if (!zipFile || !zipGiftId) {
|
||||
toast.error('Please select a ZIP file and enter Gift ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setUploadResult(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', zipFile);
|
||||
formData.append('giftId', zipGiftId);
|
||||
|
||||
const response = await fetch(`${API_URL}/api/attributes/upload-zip`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setUploadResult(data);
|
||||
if (data.success) {
|
||||
toast.success(`Successfully processed ${data.upgradesProcessed} upgrades!`);
|
||||
setZipFile(null);
|
||||
setZipGiftId('');
|
||||
await fetchAttributeSets();
|
||||
} else {
|
||||
toast.error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('ZIP upload error:', error);
|
||||
setUploadResult({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
toast.error('ZIP upload failed: ' + error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedGiftId) {
|
||||
toast.error('Please select a Gift first!');
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
toast.error('Name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('giftId', selectedGiftId);
|
||||
formData.append('name', name);
|
||||
formData.append('modelName', modelName || `${name} Model`);
|
||||
formData.append('patternName', patternName || `${name} Pattern`);
|
||||
formData.append('backdropName', backdropName || `${name} Backdrop`);
|
||||
formData.append('modelRarity', modelRarity);
|
||||
formData.append('patternRarity', patternRarity);
|
||||
formData.append('backdropRarity', backdropRarity);
|
||||
|
||||
if (modelFile) formData.append('model', modelFile);
|
||||
if (patternFile) formData.append('pattern', patternFile);
|
||||
|
||||
const response = await fetch(`${API_URL}/api/attributes`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to upload attribute set');
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setName('');
|
||||
setModelFile(null);
|
||||
setPatternFile(null);
|
||||
setModelName('');
|
||||
setPatternName('');
|
||||
setBackdropName('');
|
||||
setModelRarity(1000);
|
||||
setPatternRarity(1000);
|
||||
setBackdropRarity(1000);
|
||||
|
||||
// Refresh list
|
||||
fetchAttributeSets();
|
||||
toast.success('Attribute set uploaded successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error uploading attribute set:', error);
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this attribute set?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/attributes/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setAttributeSets(attributeSets.filter(s => s._id !== id));
|
||||
toast.success('Attribute set deleted');
|
||||
} else {
|
||||
throw new Error('Failed to delete');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting attribute set:', error);
|
||||
toast.error('Failed to delete attribute set');
|
||||
}
|
||||
};
|
||||
|
||||
const selectedGift = gifts.find(g => g.GiftId?.toString() === selectedGiftId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<div className="p-2.5 bg-yellow/10 rounded-xl rounded-tr-none">
|
||||
<Sparkles className="w-8 h-8 text-yellow" />
|
||||
</div>
|
||||
Gift Upgrade Attributes
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-2">Manage models, patterns, and backdrops for gift upgrades</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZIP Upload Section */}
|
||||
<div className="card bg-gradient-to-br from-bg-panel to-blue/5 border-blue/20">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-heading font-bold mb-2 flex items-center gap-2 text-fg">
|
||||
<Package className="text-blue" />
|
||||
Bulk Upload from ZIP
|
||||
</h2>
|
||||
<p className="text-fg-muted text-sm max-w-2xl">
|
||||
Upload a ZIP file containing <code className="bg-bg-app px-1.5 py-0.5 rounded text-xs">info.json</code> and <code className="bg-bg-app px-1.5 py-0.5 rounded text-xs">.tgs</code> files to automatically import multiple upgrades at once.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-fg-muted uppercase tracking-wider mb-2">Gift ID</label>
|
||||
<input
|
||||
type="number"
|
||||
value={zipGiftId}
|
||||
onChange={(e) => setZipGiftId(e.target.value)}
|
||||
placeholder="e.g., 23"
|
||||
className="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-fg-muted uppercase tracking-wider mb-2">ZIP File</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={(e) => setZipFile(e.target.files[0])}
|
||||
className="w-full text-sm text-fg-muted
|
||||
file:mr-4 file:py-2.5 file:px-4
|
||||
file:rounded-lg file:border-0
|
||||
file:text-sm file:font-semibold
|
||||
file:bg-blue/10 file:text-blue
|
||||
hover:file:bg-blue/20
|
||||
cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleZipUpload}
|
||||
disabled={!zipFile || !zipGiftId || uploading}
|
||||
className="btn btn-primary h-[46px]"
|
||||
>
|
||||
{uploading ? 'Processing...' : 'Upload ZIP Archive'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{zipFile && (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-fg-muted bg-bg-app/50 p-2 rounded-lg w-fit">
|
||||
<FileArchive className="w-4 h-4 text-accent" />
|
||||
<span className="font-medium text-fg">{zipFile.name}</span>
|
||||
<span>({(zipFile.size / 1024 / 1024).toFixed(2)} MB)</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadResult && (
|
||||
<div className={`mt-4 p-4 rounded-xl border ${uploadResult.success ? 'bg-success/10 border-success/20' : 'bg-red-500/10 border-red-500/20'} animate-fade-in`}>
|
||||
{uploadResult.success ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-success/20 rounded-full text-success">
|
||||
<Package className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-success">Import Successful</p>
|
||||
<p className="text-sm text-fg-muted">Processed <strong>{uploadResult.upgradesProcessed}</strong> upgrades from {uploadResult.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-500/20 rounded-full text-red-500">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-red-500">Import Failed</p>
|
||||
<p className="text-sm text-fg-muted opacity-80">{uploadResult.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Upload Form */}
|
||||
<div className="card h-fit lg:sticky lg:top-6">
|
||||
<h2 className="text-xl font-heading font-bold mb-6 flex items-center gap-2 text-fg">
|
||||
<Settings className="w-5 h-5 text-accent" />
|
||||
Manual Creation
|
||||
</h2>
|
||||
<form onSubmit={handleUpload} className="space-y-6">
|
||||
{/* Gift Selection */}
|
||||
<div className="pb-6 border-b border-border">
|
||||
<label className="block text-sm font-bold text-accent mb-2">1. Select Gift for Upgrade</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedGiftId}
|
||||
onChange={(e) => setSelectedGiftId(e.target.value)}
|
||||
className="input appearance-none cursor-pointer"
|
||||
required
|
||||
>
|
||||
<option value="">-- Choose a Gift --</option>
|
||||
{gifts.map((gift) => (
|
||||
<option key={gift._id} value={gift.GiftId}>
|
||||
{gift.Title || `Gift #${gift.GiftId}`} ({gift.Stars} ⭐)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 transform -translate-y-1/2 text-fg-muted pointer-events-none" size={18} />
|
||||
</div>
|
||||
{selectedGift && (
|
||||
<div className="mt-3 p-3 bg-accent/5 border border-accent/10 rounded-xl">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Gift className="w-4 h-4 text-accent" />
|
||||
<span className="text-fg font-bold text-sm">{selectedGift.Title || `Gift #${selectedGift.GiftId}`}</span>
|
||||
</div>
|
||||
<div className="text-xs text-fg-muted pl-6">{selectedGift.Stars} Stars • Upgrade Cost: {selectedGift.UpgradeStars} Stars</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-accent mb-4">2. Configure Upgrade Attributes</label>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-fg-muted mb-1.5">Set Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input"
|
||||
placeholder="e.g., Golden Rare Set"
|
||||
required
|
||||
disabled={!selectedGiftId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model (TGS) */}
|
||||
<div className="bg-bg-app/50 border border-border rounded-xl p-4">
|
||||
<h3 className="font-bold text-sm text-fg mb-3 flex items-center gap-2">
|
||||
<FileArchive size={16} className="text-blue" />
|
||||
Model (Upgraded Gift TGS)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setModelFile(e.target.files[0])}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10"
|
||||
accept=".tgs"
|
||||
disabled={!selectedGiftId}
|
||||
/>
|
||||
<div className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${modelFile ? 'border-success bg-success/5' : 'border-border group-hover:border-blue group-hover:bg-blue/5'
|
||||
}`}>
|
||||
{modelFile ? (
|
||||
<span className="text-success text-xs font-bold truncate block">{modelFile.name}</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-center text-fg-muted">
|
||||
<Upload size={16} className="mb-1" />
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Upload TGS</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-fg-muted uppercase mb-1">Model Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
className="input py-2 text-sm"
|
||||
placeholder={`${name || 'Gift'} Model`}
|
||||
disabled={!selectedGiftId}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-fg-muted uppercase mb-1">Rarity (‰)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={modelRarity}
|
||||
onChange={(e) => setModelRarity(e.target.value)}
|
||||
className="input py-2 text-sm"
|
||||
placeholder="1000"
|
||||
disabled={!selectedGiftId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pattern (PNG or TGS) */}
|
||||
<div className="bg-bg-app/50 border border-border rounded-xl p-4">
|
||||
<h3 className="font-bold text-sm text-fg mb-3 flex items-center gap-2">
|
||||
<ImageIcon size={16} className="text-purple" />
|
||||
Pattern (PNG or TGS)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setPatternFile(e.target.files[0])}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10"
|
||||
accept=".png,.tgs"
|
||||
disabled={!selectedGiftId}
|
||||
/>
|
||||
<div className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${patternFile ? 'border-success bg-success/5' : 'border-border group-hover:border-purple group-hover:bg-purple/5'
|
||||
}`}>
|
||||
{patternFile ? (
|
||||
<span className="text-success text-xs font-bold truncate block">{patternFile.name}</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-center text-fg-muted">
|
||||
<Upload size={16} className="mb-1" />
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider">Upload PNG/TGS</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-fg-muted uppercase mb-1">Pattern Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={patternName}
|
||||
onChange={(e) => setPatternName(e.target.value)}
|
||||
className="input py-2 text-sm"
|
||||
placeholder={`${name || 'Gift'} Pattern`}
|
||||
disabled={!selectedGiftId}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-fg-muted uppercase mb-1">Rarity (‰)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={patternRarity}
|
||||
onChange={(e) => setPatternRarity(e.target.value)}
|
||||
className="input py-2 text-sm"
|
||||
placeholder="1000"
|
||||
disabled={!selectedGiftId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backdrop Info */}
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-xl p-4">
|
||||
<h3 className="font-bold text-sm mb-2 flex items-center gap-2 text-blue">
|
||||
<Sparkles size={16} />
|
||||
Backdrop (Auto-generated)
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted leading-relaxed">
|
||||
✨ Backdrop colors are <strong>automatically generated</strong> using a deterministic algorithm based on the upgrade instance ID.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading || !selectedGiftId}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Create Attribute Set'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="card overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-bg-app border-b border-border">
|
||||
<tr>
|
||||
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider">Gift</th>
|
||||
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider">Name</th>
|
||||
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider">Model</th>
|
||||
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider">Pattern</th>
|
||||
<th className="p-4 text-xs font-bold text-fg-muted uppercase tracking-wider text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan="5" className="p-12 text-center text-fg-muted">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
|
||||
Loading attributes...
|
||||
</td>
|
||||
</tr>
|
||||
) : attributeSets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="5" className="p-12 text-center text-fg-muted">
|
||||
No attribute sets found. Create one to get started.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
attributeSets.map((set) => (
|
||||
<tr key={set._id} className="hover:bg-bg-app/50 transition-colors">
|
||||
<td className="p-4">
|
||||
<div className="inline-flex items-center px-2 py-1 rounded bg-accent/10 border border-accent/20 text-accent font-mono text-xs">
|
||||
#{set.GiftId}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 font-medium text-fg">{set.Name}</td>
|
||||
<td className="p-4">
|
||||
{set.Model ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-blue font-medium">
|
||||
<FileArchive size={12} />
|
||||
TGS
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-bg-app rounded text-fg-muted w-fit">
|
||||
{set.Model.RarityPermille}‰
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-fg-muted opacity-50">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{set.Pattern ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-purple font-medium">
|
||||
<ImageIcon size={12} />
|
||||
TGS/PNG
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-bg-app rounded text-fg-muted w-fit">
|
||||
{set.Pattern.RarityPermille}‰
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-fg-muted opacity-50">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-right">
|
||||
<button
|
||||
onClick={() => handleDelete(set._id)}
|
||||
className="p-2 text-fg-muted hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors"
|
||||
title="Delete Attribute Set"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Attributes;
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Upload, FileArchive, ArrowLeft, CheckCircle, XCircle, Loader } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function BulkUploadEmojis() {
|
||||
const navigate = useNavigate();
|
||||
const { packId } = useParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [zipFile, setZipFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState([]);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [selectedPackId, setSelectedPackId] = useState(packId || '');
|
||||
const [loadingPacks, setLoadingPacks] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks();
|
||||
}, []);
|
||||
|
||||
const fetchPacks = async () => {
|
||||
try {
|
||||
setLoadingPacks(true);
|
||||
const response = await fetch('/api/emojipacks?limit=100');
|
||||
const data = await response.json();
|
||||
setPacks(data.packs || []);
|
||||
if (packId) {
|
||||
setSelectedPackId(packId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching packs:', error);
|
||||
toast.error('Failed to load emoji packs');
|
||||
} finally {
|
||||
setLoadingPacks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.name.endsWith('.zip')) {
|
||||
setZipFile(file);
|
||||
setUploadProgress([]);
|
||||
setCompleted(false);
|
||||
} else {
|
||||
toast.error('Please select a ZIP file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!zipFile) {
|
||||
toast.error('Please select a ZIP file first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPackId) {
|
||||
toast.error('Please select an emoji pack');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setUploadProgress([]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('zipFile', zipFile);
|
||||
formData.append('packId', selectedPackId);
|
||||
|
||||
const response = await fetch('/api/emojipacks/bulk-upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUploadProgress(data.results || []);
|
||||
setCompleted(true);
|
||||
toast.success('Upload completed successfully');
|
||||
|
||||
if (data.packId) {
|
||||
setTimeout(() => {
|
||||
navigate(`/emojipacks/${data.packId}`);
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(`Error: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading:', error);
|
||||
toast.error('Failed to upload ZIP file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
to={packId ? `/emojipacks/${packId}` : '/emojipacks'}
|
||||
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back
|
||||
</Link>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<FileArchive className="w-8 h-8 text-purple" />
|
||||
Bulk Upload Emojis from ZIP
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1">
|
||||
Upload a ZIP file containing TGS, WebP, or PNG emoji files. Random emojis will be assigned automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Form */}
|
||||
<div className="card p-6 space-y-6">
|
||||
{/* Pack Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Select Emoji Pack <span className="text-red">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedPackId}
|
||||
onChange={(e) => setSelectedPackId(e.target.value)}
|
||||
disabled={loading || loadingPacks || !!packId}
|
||||
className="input w-full focus:ring-2 focus:ring-purple/50 disabled:opacity-50"
|
||||
>
|
||||
<option value="">-- Select a pack --</option>
|
||||
{packs.map((pack) => (
|
||||
<option key={pack.StickerSetId || pack.stickerset_id} value={pack.StickerSetId || pack.stickerset_id}>
|
||||
{pack.Title || pack.title} ({pack.ShortName || pack.short_name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-2 text-sm text-fg-muted">
|
||||
{packId ? 'Pack is pre-selected from URL' : 'Choose which pack to add emojis to'}
|
||||
</p>
|
||||
{!packId && (
|
||||
<Link
|
||||
to="/emojipacks/create"
|
||||
className="mt-2 inline-flex items-center text-sm text-purple hover:text-purple/80"
|
||||
>
|
||||
+ Create new emoji pack first
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Select ZIP File <span className="text-red">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
disabled={loading}
|
||||
className="input flex-1 p-2 focus:ring-2 focus:ring-purple/50"
|
||||
/>
|
||||
{zipFile && (
|
||||
<span className="text-sm text-fg-muted">
|
||||
{zipFile.name} ({(zipFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-fg-muted">
|
||||
ZIP file should contain .tgs, .webp, or .png files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue mb-2">ℹ️ How it works</h4>
|
||||
<ul className="list-disc list-inside text-sm text-blue/80 space-y-1">
|
||||
<li>Upload a ZIP file containing TGS, WebP, or PNG emoji files</li>
|
||||
<li>Each file will be extracted and processed</li>
|
||||
<li>Random emoji will be assigned as fallback (alt) for each file</li>
|
||||
<li>All emojis will be added to the selected pack</li>
|
||||
<li>You can edit emoji assignments later</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!zipFile || !selectedPackId || loading}
|
||||
className="btn btn-primary w-full py-3 text-lg font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload and Process
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Progress */}
|
||||
{uploadProgress.length > 0 && (
|
||||
<div className="mt-6 space-y-2 animate-fade-in">
|
||||
<h3 className="font-semibold text-fg">Upload Results:</h3>
|
||||
<div className="max-h-96 overflow-y-auto space-y-2 pr-2 custom-scrollbar">
|
||||
{uploadProgress.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border ${item.success
|
||||
? 'bg-success/5 border-success/20'
|
||||
: 'bg-red/5 border-red/20'
|
||||
}`}
|
||||
>
|
||||
{item.success ? (
|
||||
<CheckCircle className="w-5 h-5 text-success flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-fg truncate">{item.filename}</div>
|
||||
{item.emoji && (
|
||||
<div className="text-sm text-fg-muted truncate">
|
||||
Emoji: {item.emoji} | Document ID: {item.documentId}
|
||||
</div>
|
||||
)}
|
||||
{item.error && (
|
||||
<div className="text-sm text-red">{item.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{completed && (
|
||||
<div className="bg-success/5 border border-success/20 rounded-lg p-4 animate-scale-in">
|
||||
<h4 className="font-semibold text-success mb-2 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Upload Complete!
|
||||
</h4>
|
||||
<p className="text-sm text-success/80">
|
||||
{uploadProgress.filter(p => p.success).length} emojis uploaded successfully.
|
||||
{packId && ' Redirecting to pack...'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Upload, FileArchive, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function BulkUploadPacks() {
|
||||
const [file, setFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, pack: '' });
|
||||
const [result, setResult] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile && selectedFile.name.endsWith('.zip')) {
|
||||
setFile(selectedFile);
|
||||
} else {
|
||||
toast.error('Please select a ZIP file');
|
||||
setFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
toast.error('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setResult(null);
|
||||
setProgress({ current: 0, total: 0, pack: 'Starting upload...' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('zipFile', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stickerpacks/bulk-upload-nested', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setResult(data);
|
||||
setProgress({ current: data.createdPacks, total: data.totalPacks, pack: 'Complete!' });
|
||||
toast.success('Bulk upload completed!');
|
||||
} else {
|
||||
toast.error(data.error || 'Upload failed');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Network error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/stickerpacks')}
|
||||
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back to Sticker Packs
|
||||
</button>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<FileArchive className="w-8 h-8 text-purple" />
|
||||
Bulk Upload Sticker Packs
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1">
|
||||
Upload a ZIP file containing multiple pack ZIPs with .tgs files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Card */}
|
||||
<div className="card p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-fg mb-4">
|
||||
📦 Select Main ZIP File
|
||||
</h2>
|
||||
<p className="text-sm text-fg-muted mb-4">
|
||||
Structure: <code className="bg-muted px-2 py-1 rounded">featured_packs.zip</code> →
|
||||
<code className="bg-muted px-2 py-1 rounded ml-2">DuckEmoji.zip</code> →
|
||||
<code className="bg-muted px-2 py-1 rounded ml-2">🦆_12345.tgs</code>
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
className="input w-full p-2 cursor-pointer"
|
||||
/>
|
||||
|
||||
{file && (
|
||||
<div className="mt-3 p-3 bg-purple/10 border border-purple/20 rounded-lg">
|
||||
<p className="text-sm text-purple">
|
||||
✓ Selected: <strong>{file.name}</strong> ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
className="btn btn-primary w-full py-3 text-lg font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Upload className="w-5 h-5 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload and Process
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{uploading && (
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-fg mb-4">Processing...</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm text-fg-muted">
|
||||
<span>{progress.pack}</span>
|
||||
<span>{progress.current}/{progress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-3">
|
||||
<div
|
||||
className="bg-purple h-3 rounded-full transition-all duration-300"
|
||||
style={{ width: progress.total > 0 ? `${(progress.current / progress.total) * 100}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="card p-6 animate-fade-in">
|
||||
<h3 className="text-2xl font-bold text-fg mb-6 flex items-center gap-2">
|
||||
<CheckCircle className="w-8 h-8 text-success" />
|
||||
Upload Complete!
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-success/5 border border-success/20 rounded-xl p-4 text-center">
|
||||
<div className="text-3xl font-bold text-success">{result.createdPacks}</div>
|
||||
<div className="text-sm text-fg-muted mt-1">Packs Created</div>
|
||||
</div>
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-xl p-4 text-center">
|
||||
<div className="text-3xl font-bold text-blue">{result.totalStickers}</div>
|
||||
<div className="text-sm text-fg-muted mt-1">Stickers Uploaded</div>
|
||||
</div>
|
||||
<div className="bg-purple/5 border border-purple/20 rounded-xl p-4 text-center">
|
||||
<div className="text-3xl font-bold text-purple">{result.totalPacks}</div>
|
||||
<div className="text-sm text-fg-muted mt-1">Total Packs</div>
|
||||
</div>
|
||||
<div className="bg-red/5 border border-red/20 rounded-xl p-4 text-center">
|
||||
<div className="text-3xl font-bold text-red">{result.failedPacks}</div>
|
||||
<div className="text-sm text-fg-muted mt-1">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Created Packs List */}
|
||||
{result.packs && result.packs.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="font-semibold text-fg mb-3">Created Packs:</h4>
|
||||
<div className="max-h-96 overflow-y-auto space-y-2 custom-scrollbar pr-2">
|
||||
{result.packs.map((pack, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center p-3 border border-border rounded-lg bg-card hover:border-purple transition-colors">
|
||||
<div>
|
||||
<div className="font-medium text-fg">{pack.title}</div>
|
||||
<div className="text-sm text-fg-muted">
|
||||
{pack.shortName} • ID: {pack.stickersetId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-purple">
|
||||
{pack.stickersCount} stickers
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed Packs */}
|
||||
{result.failures && result.failures.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-red mb-3">Failed Packs:</h4>
|
||||
<div className="max-h-48 overflow-y-auto space-y-2 custom-scrollbar pr-2">
|
||||
{result.failures.map((fail, idx) => (
|
||||
<div key={idx} className="p-3 bg-red/5 border border-red/20 rounded-lg">
|
||||
<div className="font-medium text-fg">{fail.pack}</div>
|
||||
<div className="text-sm text-red">{fail.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/stickerpacks')}
|
||||
className="mt-6 w-full btn btn-secondary"
|
||||
>
|
||||
View All Sticker Packs →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Divider
|
||||
} from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
|
||||
const BulkUploadReactions = () => {
|
||||
const [file, setFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [results, setResults] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [premium, setPremium] = useState(false);
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
setFile(event.target.files[0]);
|
||||
setResults(null);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
setResults(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('zipFile', file);
|
||||
formData.append('premium', premium);
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3001/api/reactions/bulk-upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
|
||||
setResults(data.results);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Bulk Upload Reactions
|
||||
</Typography>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="body1" paragraph>
|
||||
Upload a ZIP file containing folders named after emojis (e.g., "🙏", "❤️").
|
||||
Each folder should contain the reaction assets (static_icon.png, appear_animation.tgs, etc.).
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={premium}
|
||||
onChange={(e) => setPremium(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Mark as Premium Reactions"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
component="label"
|
||||
startIcon={<CloudUploadIcon />}
|
||||
>
|
||||
Select ZIP File
|
||||
<input
|
||||
type="file"
|
||||
hidden
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{file && (
|
||||
<Typography variant="body2">
|
||||
Selected: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
fullWidth
|
||||
>
|
||||
{uploading ? <CircularProgress size={24} /> : 'Upload Reactions'}
|
||||
</Button>
|
||||
</Paper>
|
||||
|
||||
{results && (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Upload Results
|
||||
</Typography>
|
||||
<List>
|
||||
{results.map((result, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{result.success ? (
|
||||
<CheckCircleIcon color="success" />
|
||||
) : (
|
||||
<ErrorIcon color="error" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{result.success ? (
|
||||
<>
|
||||
<Typography variant="h6">{result.emoji}</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
({result.assets?.length || 0} assets)
|
||||
</Typography>
|
||||
</>
|
||||
) : (
|
||||
<Typography color="error">
|
||||
{result.emoji || 'Unknown'} - {result.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
result.success && (
|
||||
<Typography variant="caption" display="block">
|
||||
Assets: {result.assets?.join(', ')}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < results.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkUploadReactions;
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Upload, FileArchive, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function BulkUploadStickerPacks() {
|
||||
const [file, setFile] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0, pack: '' });
|
||||
const [result, setResult] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile && selectedFile.name.endsWith('.zip')) {
|
||||
setFile(selectedFile);
|
||||
} else {
|
||||
toast.error('Please select a ZIP file');
|
||||
setFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
toast.error('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setResult(null);
|
||||
setProgress({ current: 0, total: 0, pack: 'Starting upload...' });
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('zipFile', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stickerpacks/bulk-upload-stickers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setResult(data);
|
||||
setProgress({ current: data.createdPacks, total: data.totalPacks, pack: 'Complete!' });
|
||||
toast.success('Bulk upload completed!');
|
||||
} else {
|
||||
toast.error(data.error || 'Upload failed');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Network error');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => navigate('/stickerpacks')}
|
||||
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back to Sticker Packs
|
||||
</button>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<FileArchive className="w-8 h-8 text-purple" />
|
||||
Bulk Upload Regular Sticker Packs
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1">
|
||||
Upload a ZIP file containing multiple pack ZIPs with .tgs sticker files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Card */}
|
||||
<div className="card p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-fg mb-4">
|
||||
📦 Select Main ZIP File
|
||||
</h2>
|
||||
<p className="text-sm text-fg-muted mb-4">
|
||||
Structure: <code className="bg-muted px-2 py-1 rounded">featured_stickers.zip</code> →
|
||||
<code className="bg-muted px-2 py-1 rounded ml-2">Funny_Cats.zip</code> →
|
||||
<code className="bg-muted px-2 py-1 rounded ml-2">😀_12345.tgs</code>
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading}
|
||||
className="input w-full p-2 cursor-pointer"
|
||||
/>
|
||||
|
||||
{file && (
|
||||
<div className="mt-3 p-3 bg-purple/10 border border-purple/20 rounded-lg">
|
||||
<p className="text-sm text-purple">
|
||||
✓ Selected: <strong>{file.name}</strong> ({(file.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
className="btn btn-primary w-full py-3 text-lg font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Upload className="w-5 h-5 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload and Process
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{uploading && (
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-semibold text-fg mb-4">Processing...</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm text-fg-muted">
|
||||
<span>{progress.pack}</span>
|
||||
<span>{progress.current}/{progress.total}</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-3">
|
||||
<div
|
||||
className="bg-purple h-3 rounded-full transition-all duration-300"
|
||||
style={{ width: progress.total > 0 ? `${(progress.current / progress.total) * 100}%` : '0%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="card p-6 animate-fade-in">
|
||||
<h3 className="text-2xl font-bold text-fg mb-6 flex items-center gap-2">
|
||||
<CheckCircle className="w-8 h-8 text-success" />
|
||||
Upload Complete!
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-success/5 border border-success/20 rounded-xl p-4 text-center">
|
||||
<div className="text-3xl font-bold text-success">{result.createdPacks}</div>
|
||||
<div className="text-sm text-fg-muted mt-1">Packs Created</div>
|
||||
</div>
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-xl p-4 text-center">
|
||||
<div className="text-3xl font-bold text-blue">{result.totalStickers}</div>
|
||||
<div className="text-sm text-fg-muted mt-1">Stickers Uploaded</div>
|
||||
</div>
|
||||
<div className="bg-purple/5 border border-purple/20 rounded-xl p-4 text-center">
|
||||
<div className="text-3xl font-bold text-purple">{result.totalPacks}</div>
|
||||
<div className="text-sm text-fg-muted mt-1">Total Packs</div>
|
||||
</div>
|
||||
<div className="bg-red/5 border border-red/20 rounded-xl p-4 text-center">
|
||||
<div className="text-3xl font-bold text-red">{result.failedPacks}</div>
|
||||
<div className="text-sm text-fg-muted mt-1">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Created Packs List */}
|
||||
{result.packs && result.packs.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h4 className="font-semibold text-fg mb-3">Created Packs:</h4>
|
||||
<div className="max-h-96 overflow-y-auto space-y-2 custom-scrollbar pr-2">
|
||||
{result.packs.map((pack, idx) => (
|
||||
<div key={idx} className="flex justify-between items-center p-3 border border-border rounded-lg bg-card hover:border-purple transition-colors">
|
||||
<div>
|
||||
<div className="font-medium text-fg">{pack.title}</div>
|
||||
<div className="text-sm text-fg-muted">
|
||||
{pack.shortName} • ID: {pack.stickersetId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-purple">
|
||||
{pack.stickersCount} stickers
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed Packs */}
|
||||
{result.failures && result.failures.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-red mb-3">Failed Packs:</h4>
|
||||
<div className="max-h-48 overflow-y-auto space-y-2 custom-scrollbar pr-2">
|
||||
{result.failures.map((fail, idx) => (
|
||||
<div key={idx} className="p-3 bg-red/5 border border-red/20 rounded-lg">
|
||||
<div className="font-medium text-fg">{fail.pack}</div>
|
||||
<div className="text-sm text-red">{fail.reason}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/stickerpacks')}
|
||||
className="mt-6 w-full btn btn-secondary"
|
||||
>
|
||||
View All Sticker Packs →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Upload, FileArchive, ArrowLeft, CheckCircle, XCircle, Loader } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function BulkUploadStickers() {
|
||||
const navigate = useNavigate();
|
||||
const { packId } = useParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [zipFile, setZipFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState([]);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [uploadStats, setUploadStats] = useState(null);
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [selectedPackId, setSelectedPackId] = useState(packId || '');
|
||||
const [loadingPacks, setLoadingPacks] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks();
|
||||
}, []);
|
||||
|
||||
const fetchPacks = async () => {
|
||||
try {
|
||||
setLoadingPacks(true);
|
||||
const response = await fetch('/api/stickerpacks?limit=100');
|
||||
const data = await response.json();
|
||||
setPacks(data.packs || []);
|
||||
if (packId) {
|
||||
setSelectedPackId(packId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching packs:', error);
|
||||
toast.error('Failed to load sticker packs');
|
||||
} finally {
|
||||
setLoadingPacks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.name.endsWith('.zip')) {
|
||||
setZipFile(file);
|
||||
setUploadProgress([]);
|
||||
setCompleted(false);
|
||||
} else {
|
||||
toast.error('Please select a ZIP file');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!zipFile) {
|
||||
toast.error('Please select a ZIP file first');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPackId) {
|
||||
toast.error('Please select a sticker pack');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setUploadProgress([]);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('zipFile', zipFile);
|
||||
formData.append('packId', selectedPackId);
|
||||
|
||||
const response = await fetch('/api/stickerpacks/bulk-upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUploadProgress(data.results || []);
|
||||
setUploadStats({
|
||||
total: data.successCount || 0,
|
||||
premium: data.premiumCount || 0,
|
||||
regular: data.regularCount || 0
|
||||
});
|
||||
setCompleted(true);
|
||||
toast.success('Upload completed successfully');
|
||||
|
||||
if (data.packId) {
|
||||
setTimeout(() => {
|
||||
navigate(`/stickerpacks/${data.packId}`);
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(`Error: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading:', error);
|
||||
toast.error('Failed to upload ZIP file');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
to={packId ? `/stickerpacks/${packId}` : '/stickerpacks'}
|
||||
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back
|
||||
</Link>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<FileArchive className="w-8 h-8 text-blue" />
|
||||
Bulk Upload Stickers from ZIP
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1">
|
||||
Upload a ZIP file containing TGS/WEBP/PNG/WEBM sticker files. Random emojis will be assigned automatically.
|
||||
</p>
|
||||
<div className="mt-4 p-4 bg-blue/5 border border-blue/20 rounded-lg">
|
||||
<p className="text-sm text-blue font-medium">✨ Premium Stickers Support:</p>
|
||||
<p className="text-xs text-blue/80 mt-1">
|
||||
Name files as <code className="bg-black/30 px-1 rounded">000_main.tgs</code> + <code className="bg-black/30 px-1 rounded">000_effect.tgs</code> for premium stickers with effects
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Form */}
|
||||
<div className="card p-6 space-y-6">
|
||||
{/* Pack Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Select Sticker Pack <span className="text-red">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedPackId}
|
||||
onChange={(e) => setSelectedPackId(e.target.value)}
|
||||
disabled={loading || loadingPacks || !!packId}
|
||||
className="input w-full focus:ring-2 focus:ring-purple/50 disabled:opacity-50"
|
||||
>
|
||||
<option value="">-- Select a pack --</option>
|
||||
{packs.map((pack) => (
|
||||
<option key={pack.StickerSetId || pack.stickerset_id} value={pack.StickerSetId || pack.stickerset_id}>
|
||||
{pack.Title || pack.title} ({pack.ShortName || pack.short_name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-2 text-sm text-fg-muted">
|
||||
{packId ? 'Pack is pre-selected from URL' : 'Choose which pack to add stickers to'}
|
||||
</p>
|
||||
{!packId && (
|
||||
<Link
|
||||
to="/stickerpacks/create"
|
||||
className="mt-2 inline-flex items-center text-sm text-purple hover:text-purple/80"
|
||||
>
|
||||
+ Create new sticker pack first
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Select ZIP File <span className="text-red">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileChange}
|
||||
disabled={loading}
|
||||
className="input flex-1 p-2 focus:ring-2 focus:ring-purple/50"
|
||||
/>
|
||||
{zipFile && (
|
||||
<span className="text-sm text-fg-muted">
|
||||
{zipFile.name} ({(zipFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-fg-muted">
|
||||
ZIP file should contain .tgs, .webp, .png or .webm files
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-purple/5 border border-purple/20 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-purple mb-2">ℹ️ How it works</h4>
|
||||
<ul className="list-disc list-inside text-sm text-purple/80 space-y-1">
|
||||
<li>Upload a ZIP file containing TGS, WEBP, PNG or WEBM sticker files</li>
|
||||
<li>Each file will be extracted and processed</li>
|
||||
<li>Random emoji will be assigned as fallback (alt) for each sticker</li>
|
||||
<li>All stickers will be added to the selected pack</li>
|
||||
<li>You can edit emoji assignments later</li>
|
||||
<li>Supported formats: TGS (animated), WEBP (recommended), PNG</li>
|
||||
<li className="text-purple font-medium">✨ Premium: Use <code className="bg-black/30 px-1 rounded">XXX_main.tgs</code> + <code className="bg-black/30 px-1 rounded">XXX_effect.tgs</code> naming</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!zipFile || !selectedPackId || loading}
|
||||
className="btn btn-primary w-full py-3 text-lg font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader className="w-5 h-5 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-5 h-5" />
|
||||
Upload and Process
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Progress */}
|
||||
{uploadProgress.length > 0 && (
|
||||
<div className="mt-6 space-y-2 animate-fade-in">
|
||||
<h3 className="font-semibold text-fg">Upload Results:</h3>
|
||||
<div className="max-h-96 overflow-y-auto space-y-2 pr-2 custom-scrollbar">
|
||||
{uploadProgress.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border ${item.success
|
||||
? 'bg-success/5 border-success/20'
|
||||
: 'bg-red/5 border-red/20'
|
||||
}`}
|
||||
>
|
||||
{item.success ? (
|
||||
<CheckCircle className="w-5 h-5 text-success flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-5 h-5 text-red flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-fg truncate">{item.filename}</div>
|
||||
{item.emoji && (
|
||||
<div className="text-sm text-fg-muted truncate">
|
||||
Emoji: {item.emoji} | Document ID: {item.documentId}
|
||||
{item.premium && <span className="ml-2 px-2 py-0.5 bg-blue/10 text-blue text-[10px] rounded border border-blue/20">✨ PREMIUM</span>}
|
||||
</div>
|
||||
)}
|
||||
{item.error && (
|
||||
<div className="text-sm text-red">{item.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{completed && uploadStats && (
|
||||
<div className="bg-success/5 border border-success/20 rounded-lg p-4 animate-scale-in">
|
||||
<h4 className="font-semibold text-success mb-2 flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Upload Complete!
|
||||
</h4>
|
||||
<div className="text-sm text-success/80 space-y-1">
|
||||
<p>✅ Total: {uploadStats.total} stickers uploaded successfully</p>
|
||||
{uploadStats.premium > 0 && (
|
||||
<p>✨ Premium: {uploadStats.premium} stickers with effects</p>
|
||||
)}
|
||||
{uploadStats.regular > 0 && (
|
||||
<p>📦 Regular: {uploadStats.regular} stickers</p>
|
||||
)}
|
||||
{packId && <p className="mt-2 font-medium">Redirecting to pack...</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Package, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function CreateEmojiPack() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
short_name: '',
|
||||
text_color: false,
|
||||
channel_emoji_status: false,
|
||||
creator_id: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/emojipacks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
toast.success('Emoji pack created successfully!');
|
||||
navigate(`/emojipacks/${data.pack.StickerSetId}`);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(`Error: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating pack:', error);
|
||||
toast.error('Failed to create pack');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
to="/emojipacks"
|
||||
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back to Packs
|
||||
</Link>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<Package className="w-8 h-8 text-purple" />
|
||||
Create Emoji Pack
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1">Create a new custom emoji sticker set</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="card p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Title <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
required
|
||||
placeholder="My Awesome Emojis"
|
||||
className="input w-full focus:ring-2 focus:ring-purple/50"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
Display name for the emoji pack
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Short Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Short Name <span className="text-red">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-fg-muted">t.me/addemoji/</span>
|
||||
<input
|
||||
type="text"
|
||||
name="short_name"
|
||||
value={formData.short_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
placeholder="my_emojis"
|
||||
className="input flex-1 focus:ring-2 focus:ring-purple/50"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
Unique identifier (letters, numbers, and underscores only)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Creator ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Creator User ID
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="creator_id"
|
||||
value={formData.creator_id}
|
||||
onChange={handleChange}
|
||||
placeholder="2000001"
|
||||
className="input w-full focus:ring-2 focus:ring-purple/50"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-fg-muted">
|
||||
Optional: MyTelegram user ID of the pack creator
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<h3 className="text-lg font-semibold text-fg">Options</h3>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="text_color"
|
||||
checked={formData.text_color}
|
||||
onChange={handleChange}
|
||||
className="mt-1 w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-fg group-hover:text-purple transition-colors">Text Color Support</div>
|
||||
<div className="text-sm text-fg-muted">
|
||||
Emojis will change color based on context (text color, status, etc.)
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="channel_emoji_status"
|
||||
checked={formData.channel_emoji_status}
|
||||
onChange={handleChange}
|
||||
className="mt-1 w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-fg group-hover:text-purple transition-colors">Channel Emoji Status</div>
|
||||
<div className="text-sm text-fg-muted">
|
||||
Allow using emojis as channel status icons
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-purple/5 border border-purple/20 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-purple mb-2">📝 Next Steps</h4>
|
||||
<ol className="list-decimal list-inside text-sm text-purple/80 space-y-1">
|
||||
<li>Create the pack with a unique short name</li>
|
||||
<li>Upload TGS/WEBM files for each emoji</li>
|
||||
<li>Assign fallback emoji (alt) for each uploaded file</li>
|
||||
<li>Share the pack link with users</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 btn btn-primary font-semibold text-lg"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Pack'}
|
||||
</button>
|
||||
<Link
|
||||
to="/emojipacks"
|
||||
className="btn btn-secondary font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Upload, ArrowLeft, Sparkles } from 'lucide-react'
|
||||
import useGiftsStore from '../store/useGiftsStore'
|
||||
import Lottie from 'lottie-react'
|
||||
import pako from 'pako'
|
||||
|
||||
export default function CreateGift() {
|
||||
const navigate = useNavigate()
|
||||
const { createGift } = useGiftsStore()
|
||||
const { register, handleSubmit, watch, formState: { errors } } = useForm()
|
||||
const [stickerFile, setStickerFile] = useState(null)
|
||||
const [stickerPreview, setStickerPreview] = useState(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const limited = watch('limited')
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
setStickerFile(file)
|
||||
|
||||
// Read file for preview (TGS files are gzipped JSON)
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const arrayBuffer = event.target.result
|
||||
const uint8Array = new Uint8Array(arrayBuffer)
|
||||
|
||||
// Try to decompress as gzip (TGS format)
|
||||
try {
|
||||
const decompressed = pako.inflate(uint8Array, { to: 'string' })
|
||||
const jsonData = JSON.parse(decompressed)
|
||||
setStickerPreview(jsonData)
|
||||
} catch (gzipErr) {
|
||||
// If not gzipped, try to parse as plain JSON
|
||||
const text = new TextDecoder().decode(uint8Array)
|
||||
const jsonData = JSON.parse(text)
|
||||
setStickerPreview(jsonData)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse JSON:', err)
|
||||
setStickerPreview(null)
|
||||
// toast.error('Invalid TGS file format')
|
||||
}
|
||||
}
|
||||
reader.readAsArrayBuffer(file)
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (rawData) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// Process data to ensure correct types
|
||||
const stars = Number(rawData.stars);
|
||||
const data = {
|
||||
...rawData,
|
||||
giftId: Number(rawData.giftId),
|
||||
stars: stars,
|
||||
convertStars: rawData.limited
|
||||
? Number(rawData.convertStars)
|
||||
: Math.floor(stars * 0.7) || 1,
|
||||
|
||||
upgradeStars: rawData.limited
|
||||
? Number(rawData.upgradeStars)
|
||||
: null, // Explicitly null for non-limited
|
||||
|
||||
stickerId: rawData.stickerId ? rawData.stickerId : undefined
|
||||
}
|
||||
|
||||
if (rawData.limited) {
|
||||
data.availabilityTotal = Number(rawData.availabilityTotal)
|
||||
data.availabilityRemains = rawData.availabilityRemains
|
||||
? Number(rawData.availabilityRemains)
|
||||
: Number(rawData.availabilityTotal)
|
||||
} else {
|
||||
delete data.availabilityTotal
|
||||
delete data.availabilityRemains
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('data', JSON.stringify(data))
|
||||
if (stickerFile) {
|
||||
formData.append('sticker', stickerFile)
|
||||
console.log('Uploading sticker:', stickerFile.name, stickerFile.size, 'bytes')
|
||||
} else {
|
||||
console.warn('No sticker file selected')
|
||||
}
|
||||
|
||||
await createGift(formData)
|
||||
navigate('/gifts')
|
||||
} catch (error) {
|
||||
console.error('Create gift error:', error)
|
||||
console.error('Error response:', error.response?.data)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<button onClick={() => navigate('/gifts')} className="btn btn-secondary mb-6 flex items-center gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Gifts
|
||||
</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="bg-gradient-to-br from-primary-500 to-primary-600 p-3 rounded-lg">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create New Gift</h1>
|
||||
<p className="text-[#8b98a5]">Add a new gift to the catalog</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Gift ID */}
|
||||
<div>
|
||||
<label className="label">Gift ID *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
{...register('giftId', { required: 'Gift ID is required', min: 1 })}
|
||||
/>
|
||||
{errors.giftId && <p className="text-red-500 text-sm mt-1">{errors.giftId.message}</p>}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="label">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g., Golden Star, Blue Diamond..."
|
||||
{...register('title')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sticker Upload */}
|
||||
<div>
|
||||
<label className="label">Sticker Animation (JSON/TGS)</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${stickerFile ? 'border-green-500 bg-green-500/20' : 'border-[#2b5278] hover:border-primary-500'
|
||||
}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.tgs"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
id="sticker-upload"
|
||||
/>
|
||||
<label htmlFor="sticker-upload" className="cursor-pointer">
|
||||
<Upload className={`w-12 h-12 mx-auto mb-2 ${stickerFile ? 'text-green-600' : 'text-[#8b98a5]'}`} />
|
||||
<p className={`text-[#8b98a5] font-medium ${stickerFile ? 'text-green-400' : ''}`}>
|
||||
{stickerFile ? `✓ ${stickerFile.name}` : 'Click to upload sticker'}
|
||||
</p>
|
||||
{stickerFile && (
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
{(stickerFile.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-[#8b98a5] mt-1">JSON or TGS (Max: 10MB)</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{stickerPreview && (
|
||||
<div className="border border-border rounded-lg p-6 flex items-center justify-center bg-bg-app shadow-inner">
|
||||
<Lottie
|
||||
animationData={stickerPreview}
|
||||
loop={true}
|
||||
style={{ width: 150, height: 150 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Sticker ID */}
|
||||
<div className="mt-4">
|
||||
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Or Enter Existing Sticker ID (Debug)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input font-mono text-sm"
|
||||
placeholder="e.g. 17673446594580884"
|
||||
{...register('stickerId')}
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">Overrides file upload if set. Use to reuse existing stickers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stars Pricing */}
|
||||
{/* Stars Pricing */}
|
||||
<div className={`grid grid-cols-1 ${limited ? 'md:grid-cols-3' : 'md:grid-cols-1'} gap-6`}>
|
||||
<div>
|
||||
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Price (Stars) *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input text-yellow-500 font-bold"
|
||||
{...register('stars', { required: true, min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{limited && (
|
||||
<>
|
||||
<div className="animate-fade-in">
|
||||
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Convert Stars *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
{...register('convertStars', { required: 'Convert price is required for limited gifts', min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="animate-fade-in">
|
||||
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Upgrade Stars</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input text-purple-400"
|
||||
{...register('upgradeStars', { min: 0 })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gift Type */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="limited"
|
||||
className="w-4 h-4 text-primary-600 rounded"
|
||||
{...register('limited')}
|
||||
/>
|
||||
<label htmlFor="limited" className="text-sm font-medium">Limited Edition</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="birthday"
|
||||
className="w-4 h-4 text-primary-600 rounded"
|
||||
{...register('birthday')}
|
||||
/>
|
||||
<label htmlFor="birthday" className="text-sm font-medium">Birthday Gift</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="soldOut"
|
||||
className="w-4 h-4 text-primary-600 rounded"
|
||||
{...register('soldOut')}
|
||||
/>
|
||||
<label htmlFor="soldOut" className="text-sm font-medium">Mark as Sold Out</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limited Edition Supply */}
|
||||
{limited && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-purple-900 mb-3">Limited Edition Settings</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Total Supply *</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
{...register('availabilityTotal', { min: 1 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Remaining Supply</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input"
|
||||
placeholder="Leave empty to use total"
|
||||
{...register('availabilityRemains', { min: 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Gift'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/gifts')}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Upload, ArrowLeft, Smile } from 'lucide-react';
|
||||
import Lottie from 'lottie-react';
|
||||
import pako from 'pako';
|
||||
|
||||
export default function CreateReaction() {
|
||||
const navigate = useNavigate();
|
||||
const { register, handleSubmit, watch, formState: { errors } } = useForm();
|
||||
const [files, setFiles] = useState({
|
||||
staticIcon: null,
|
||||
appearAnimation: null,
|
||||
selectAnimation: null,
|
||||
activateAnimation: null,
|
||||
effectAnimation: null,
|
||||
aroundAnimation: null,
|
||||
centerIcon: null
|
||||
});
|
||||
const [previews, setPreviews] = useState({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const premium = watch('premium');
|
||||
|
||||
const handleFileChange = (field, e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setFiles(prev => ({ ...prev, [field]: file }));
|
||||
|
||||
// Read file for preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const arrayBuffer = event.target.result;
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
try {
|
||||
const decompressed = pako.inflate(uint8Array, { to: 'string' });
|
||||
const jsonData = JSON.parse(decompressed);
|
||||
setPreviews(prev => ({ ...prev, [field]: jsonData }));
|
||||
} catch (gzipErr) {
|
||||
const text = new TextDecoder().decode(uint8Array);
|
||||
const jsonData = JSON.parse(text);
|
||||
setPreviews(prev => ({ ...prev, [field]: jsonData }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to parse JSON:', err);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('data', JSON.stringify(data));
|
||||
|
||||
// Append all animation files
|
||||
Object.entries(files).forEach(([key, file]) => {
|
||||
if (file) {
|
||||
formData.append(key, file);
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch('/api/reactions', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
navigate('/reactions');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Create reaction error:', error);
|
||||
alert('Failed to create reaction');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const AnimationUpload = ({ field, label, required = false }) => (
|
||||
<div>
|
||||
<label className="label">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className={`border-2 border-dashed rounded-lg p-4 text-center transition-colors ${
|
||||
files[field] ? 'border-green-500 bg-green-500/20' : 'border-[#2b5278] hover:border-purple-500'
|
||||
}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.tgs"
|
||||
onChange={(e) => handleFileChange(field, e)}
|
||||
className="hidden"
|
||||
id={`${field}-upload`}
|
||||
/>
|
||||
<label htmlFor={`${field}-upload`} className="cursor-pointer">
|
||||
<Upload className={`w-10 h-10 mx-auto mb-2 ${files[field] ? 'text-green-600' : 'text-[#8b98a5]'}`} />
|
||||
<p className={`text-sm font-medium ${files[field] ? 'text-green-400' : 'text-[#8b98a5]'}`}>
|
||||
{files[field] ? `✓ ${files[field].name}` : 'Click to upload'}
|
||||
</p>
|
||||
{files[field] && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
{(files[field].size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-[#8b98a5] mt-1">TGS or JSON</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{previews[field] && (
|
||||
<div className="border-2 border-[#2b5278] rounded-lg p-4 flex items-center justify-center bg-gradient-to-br from-purple-50 to-pink-50">
|
||||
<Lottie
|
||||
animationData={previews[field]}
|
||||
loop={true}
|
||||
style={{ width: 100, height: 100 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<button onClick={() => navigate('/reactions')} className="btn btn-secondary mb-6 flex items-center gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Reactions
|
||||
</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="bg-gradient-to-br from-purple-500 to-pink-600 p-3 rounded-lg">
|
||||
<Smile className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create New Reaction</h1>
|
||||
<p className="text-[#8b98a5]">Add a new reaction with animations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Emoji *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="👍"
|
||||
{...register('emoji', { required: 'Emoji is required' })}
|
||||
/>
|
||||
{errors.emoji && <p className="text-red-500 text-sm mt-1">{errors.emoji.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Title *</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Like, Heart, Fire..."
|
||||
{...register('title', { required: 'Title is required' })}
|
||||
/>
|
||||
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flags */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="premium"
|
||||
className="w-4 h-4 text-purple-600 rounded"
|
||||
{...register('premium')}
|
||||
/>
|
||||
<label htmlFor="premium" className="text-sm font-medium">Premium Only</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="inactive"
|
||||
className="w-4 h-4 text-[#8b98a5] rounded"
|
||||
{...register('inactive')}
|
||||
/>
|
||||
<label htmlFor="inactive" className="text-sm font-medium">Mark as Inactive</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Animations */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">Animations</h3>
|
||||
|
||||
<AnimationUpload field="staticIcon" label="Static Icon" required />
|
||||
<AnimationUpload field="appearAnimation" label="Appear Animation" required />
|
||||
<AnimationUpload field="selectAnimation" label="Select Animation (Hover)" required />
|
||||
<AnimationUpload field="activateAnimation" label="Activate Animation" required />
|
||||
<AnimationUpload field="effectAnimation" label="Effect Animation (Background)" required />
|
||||
<AnimationUpload field="aroundAnimation" label="Around Animation" />
|
||||
<AnimationUpload field="centerIcon" label="Center Icon" />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-blue-50 border border-blue-500/30 rounded-lg p-4">
|
||||
<h4 className="font-semibold text-blue-900 mb-2">ℹ️ Animation Guide</h4>
|
||||
<ul className="list-disc list-inside text-sm text-blue-300 space-y-1">
|
||||
<li><strong>Static Icon:</strong> Small icon shown in reaction picker</li>
|
||||
<li><strong>Appear Animation:</strong> Plays when reaction picker opens</li>
|
||||
<li><strong>Select Animation:</strong> Plays on hover over reaction</li>
|
||||
<li><strong>Activate Animation:</strong> Plays when reaction is clicked</li>
|
||||
<li><strong>Effect Animation:</strong> Background effect during activation</li>
|
||||
<li><strong>Around/Center:</strong> Optional animations for existing reactions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
{submitting ? 'Creating...' : 'Create Reaction'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/reactions')}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Bell, ArrowLeft, Save, AlertCircle, FileText, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function CreateServiceNotification() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
type: '',
|
||||
title: '',
|
||||
message: '',
|
||||
mediaUrl: '',
|
||||
mediaType: '',
|
||||
isPopup: true,
|
||||
isActive: true
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTemplate, setLoadingTemplate] = useState(isEdit);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
loadTemplate();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadTemplate = async () => {
|
||||
try {
|
||||
setLoadingTemplate(true);
|
||||
const response = await fetch(`${API_URL}/api/service-notifications/${id}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setFormData({
|
||||
type: data.template.Type || '',
|
||||
title: data.template.Title || '',
|
||||
message: data.template.Message || '',
|
||||
mediaUrl: data.template.MediaUrl || '',
|
||||
mediaType: data.template.MediaType || '',
|
||||
isPopup: data.template.IsPopup !== undefined ? data.template.IsPopup : true,
|
||||
isActive: data.template.IsActive !== undefined ? data.template.IsActive : true
|
||||
});
|
||||
} else {
|
||||
toast.error('Failed to load template');
|
||||
navigate('/service-notifications');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading template:', error);
|
||||
toast.error('Error loading template');
|
||||
navigate('/service-notifications');
|
||||
} finally {
|
||||
setLoadingTemplate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.type || !formData.title || !formData.message) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const url = isEdit
|
||||
? `${API_URL}/api/service-notifications/${id}`
|
||||
: `${API_URL}/api/service-notifications`;
|
||||
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success(isEdit ? 'Template updated successfully' : 'Template created successfully');
|
||||
navigate('/service-notifications');
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to save template');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving template:', error);
|
||||
toast.error('Error saving template');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadingTemplate) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/service-notifications')}
|
||||
className="p-2 -ml-2 text-fg-muted hover:text-fg rounded-full transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-6 h-6" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<Bell className="w-8 h-8 text-purple" />
|
||||
{isEdit ? 'Edit' : 'Create'} Notification Template
|
||||
</h1>
|
||||
<p className="mt-1 text-fg-muted">
|
||||
{isEdit ? 'Update' : 'Create a new'} system notification template
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Form */}
|
||||
<div className="lg:col-span-2">
|
||||
<form onSubmit={handleSubmit} className="bg-card border border-border rounded-xl shadow-lg p-6 space-y-6">
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
Type <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., ads, premium_promo, system_update"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Used for deduplication. Same type won't show twice within 15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
Title <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="e.g., Premium Discount!"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
Message <span className="text-red">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
rows={4}
|
||||
className="input w-full resize-none"
|
||||
placeholder="e.g., 🎉 Get Premium with 50% discount! Limited time offer."
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
Supports emoji and text formatting
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Media URL (Optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
Media URL (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.mediaUrl}
|
||||
onChange={(e) => setFormData({ ...formData, mediaUrl: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media Type */}
|
||||
{formData.mediaUrl && (
|
||||
<div className="animate-fade-in">
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
Media Type
|
||||
</label>
|
||||
<select
|
||||
value={formData.mediaType}
|
||||
onChange={(e) => setFormData({ ...formData, mediaType: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Select type</option>
|
||||
<option value="photo">Photo</option>
|
||||
<option value="video">Video</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Type */}
|
||||
<div className="bg-muted/50 p-4 rounded-lg border border-border">
|
||||
<label className="block text-sm font-medium text-fg mb-3">
|
||||
Display Type
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className={`flex items-start gap-3 cursor-pointer p-3 rounded-lg border transition-all ${formData.isPopup ? 'bg-purple/10 border-purple text-fg' : 'border-border text-fg-muted hover:bg-muted'}`}>
|
||||
<div className="relative flex items-center mt-0.5">
|
||||
<input
|
||||
type="radio"
|
||||
checked={formData.isPopup}
|
||||
onChange={() => setFormData({ ...formData, isPopup: true })}
|
||||
className="sr-only"
|
||||
/>
|
||||
{formData.isPopup ? <CheckCircle2 className="w-5 h-5 text-purple" /> : <div className="w-5 h-5 rounded-full border border-fg-muted" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="flex items-center gap-2 font-medium mb-0.5">
|
||||
<AlertCircle className="w-4 h-4" /> Popup
|
||||
</span>
|
||||
<span className="text-sm opacity-80 block">
|
||||
Show as alert/popup (user must dismiss)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-start gap-3 cursor-pointer p-3 rounded-lg border transition-all ${!formData.isPopup ? 'bg-purple/10 border-purple text-fg' : 'border-border text-fg-muted hover:bg-muted'}`}>
|
||||
<div className="relative flex items-center mt-0.5">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!formData.isPopup}
|
||||
onChange={() => setFormData({ ...formData, isPopup: false })}
|
||||
className="sr-only"
|
||||
/>
|
||||
{!formData.isPopup ? <CheckCircle2 className="w-5 h-5 text-purple" /> : <div className="w-5 h-5 rounded-full border border-fg-muted" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<span className="flex items-center gap-2 font-medium mb-0.5">
|
||||
<FileText className="w-4 h-4" /> Message
|
||||
</span>
|
||||
<span className="text-sm opacity-80 block">
|
||||
Save as message from "Telegram" (user 777000)
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Status */}
|
||||
<div>
|
||||
<label className="flex items-center gap-3 p-3 bg-muted/30 rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-muted rounded-full peer peer-focus:ring-2 peer-focus:ring-purple/20 dark:peer-focus:ring-purple/40 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple"></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-fg">
|
||||
Active (can be sent to users)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/service-notifications')}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary min-w-[140px]"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Save className="w-4 h-4" />
|
||||
<span>{isEdit ? 'Update Template' : 'Create Template'}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Examples */}
|
||||
<div className="bg-card border border-border rounded-xl p-6 h-fit sticky top-6">
|
||||
<h3 className="text-lg font-bold font-heading text-fg mb-4 flex items-center gap-2">
|
||||
<span className="text-2xl">💡</span> Examples
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-muted/30 rounded-lg border border-border">
|
||||
<strong className="text-fg block mb-2">Premium Ad</strong>
|
||||
<div className="text-sm text-fg-muted space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs uppercase tracking-wide opacity-70">Type</span>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-purple font-mono text-xs w-fit">ads</code>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<span className="text-xs uppercase tracking-wide opacity-70">Message</span>
|
||||
<p className="italic">"🎉 Get Premium with 50% discount! Limited time offer."</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/30 rounded-lg border border-border">
|
||||
<strong className="text-fg block mb-2">System Announcement</strong>
|
||||
<div className="text-sm text-fg-muted space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs uppercase tracking-wide opacity-70">Type</span>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-purple font-mono text-xs w-fit">system_update</code>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<span className="text-xs uppercase tracking-wide opacity-70">Message</span>
|
||||
<p className="italic">"⚠️ Server maintenance in 10 minutes. Service will be unavailable for 5 minutes."</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/30 rounded-lg border border-border">
|
||||
<strong className="text-fg block mb-2">New Feature</strong>
|
||||
<div className="text-sm text-fg-muted space-y-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs uppercase tracking-wide opacity-70">Type</span>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-purple font-mono text-xs w-fit">feature_announcement</code>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 mt-2">
|
||||
<span className="text-xs uppercase tracking-wide opacity-70">Message</span>
|
||||
<p className="italic">"🆕 New stickers and emojis added! Check them out now."</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Package, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function CreateStickerPack() {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
short_name: '',
|
||||
masks: false
|
||||
});
|
||||
|
||||
const validateShortName = (name) => {
|
||||
// Only lowercase letters, numbers, and underscores
|
||||
return /^[a-z0-9_]+$/.test(name);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!form.title.trim()) {
|
||||
toast.error('Title is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.short_name.trim()) {
|
||||
toast.error('Short name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateShortName(form.short_name)) {
|
||||
toast.error('Short name must contain only lowercase letters, numbers, and underscores');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/stickerpacks', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(form)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
toast.success('Sticker pack created successfully!');
|
||||
navigate(`/stickerpacks/${data.pack.StickerSetId}`);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
toast.error(errorData.error || 'Failed to create pack');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating pack:', err);
|
||||
toast.error('Failed to create pack. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
to="/stickerpacks"
|
||||
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back to Sticker Packs
|
||||
</Link>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<Package className="w-8 h-8 text-purple" />
|
||||
Create Sticker Pack
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1">Create a new sticker set for regular stickers (512x512 px)</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="card p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Pack Title <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
||||
className="input w-full focus:ring-2 focus:ring-purple/50"
|
||||
placeholder="My Awesome Stickers"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-fg-muted mt-1">Display name of your sticker pack</p>
|
||||
</div>
|
||||
|
||||
{/* Short Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Short Name <span className="text-red">*</span> (URL identifier)
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-fg-muted">t.me/addstickers/</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.short_name}
|
||||
onChange={(e) => setForm({ ...form, short_name: e.target.value.toLowerCase() })}
|
||||
className="input flex-1 focus:ring-2 focus:ring-purple/50"
|
||||
placeholder="my_stickers_123"
|
||||
pattern="[a-z0-9_]+"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted mt-1">
|
||||
Only lowercase letters (a-z), numbers (0-9), and underscores (_). Must be unique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Masks */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.masks}
|
||||
onChange={(e) => setForm({ ...form, masks: e.target.checked })}
|
||||
className="mt-1 w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
|
||||
/>
|
||||
<span className="text-sm font-medium text-fg group-hover:text-purple transition-colors">Mask Stickers</span>
|
||||
</label>
|
||||
<p className="text-sm text-fg-muted mt-1 ml-6">
|
||||
Check if this pack contains mask stickers (stickers for face masks)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-lg p-4">
|
||||
<h3 className="font-medium text-blue mb-2">📋 Sticker Requirements</h3>
|
||||
<ul className="text-sm text-blue/80 space-y-1">
|
||||
<li>• <strong>Static:</strong> WebP or PNG (512x512 px)</li>
|
||||
<li>• <strong>Animated:</strong> TGS (Lottie, 512x512, max 3 sec, 60 FPS)</li>
|
||||
<li>• <strong>Video:</strong> WebM (VP9, 512x512, max 3 sec, no audio)</li>
|
||||
<li>• Maximum file size: 512 KB</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-4 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 btn btn-primary font-semibold text-lg"
|
||||
>
|
||||
{loading ? 'Creating...' : 'Create Pack'}
|
||||
</button>
|
||||
<Link
|
||||
to="/stickerpacks"
|
||||
className="btn btn-secondary font-semibold"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Gift, TrendingUp, Star, ShoppingBag, Plus, AlertCircle } from 'lucide-react'
|
||||
import { statsApi } from '../lib/api'
|
||||
import useGiftsStore from '../store/useGiftsStore'
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { gifts, fetchGifts } = useGiftsStore()
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [statsRes] = await Promise.all([
|
||||
statsApi.getOverview(),
|
||||
fetchGifts()
|
||||
])
|
||||
setStats(statsRes.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: 'Total Gifts',
|
||||
value: stats?.totalGifts || 0,
|
||||
icon: Gift,
|
||||
color: 'bg-blue-500',
|
||||
trend: null,
|
||||
},
|
||||
{
|
||||
title: 'Available Gifts',
|
||||
value: stats?.availableGifts || 0,
|
||||
icon: ShoppingBag,
|
||||
color: 'bg-green-500/200',
|
||||
trend: '+' + stats?.availableGifts || 0,
|
||||
},
|
||||
{
|
||||
title: 'Sold Out',
|
||||
value: stats?.soldOutGifts || 0,
|
||||
icon: AlertCircle,
|
||||
color: 'bg-red-500/200',
|
||||
trend: null,
|
||||
},
|
||||
{
|
||||
title: 'Total Sent',
|
||||
value: stats?.totalSentGifts || 0,
|
||||
icon: TrendingUp,
|
||||
color: 'bg-purple-500',
|
||||
trend: null,
|
||||
},
|
||||
{
|
||||
title: 'Stars Earned',
|
||||
value: stats?.totalStarsEarned?.toLocaleString() || '0',
|
||||
icon: Star,
|
||||
color: 'bg-yellow-500/200',
|
||||
trend: null,
|
||||
},
|
||||
{
|
||||
title: 'Limited Edition',
|
||||
value: stats?.limitedGifts || 0,
|
||||
icon: Gift,
|
||||
color: 'bg-pink-500',
|
||||
trend: null,
|
||||
},
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#5288c1]"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black font-heading text-fg">Dashboard</h1>
|
||||
<p className="text-fg-muted font-medium mt-1">Overview of your Star Gifts catalog</p>
|
||||
</div>
|
||||
<Link to="/gifts/create" className="btn btn-primary gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
Create New Gift
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{statCards.map((stat, index) => (
|
||||
<div key={index} className="card group">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-fg-muted uppercase tracking-wider">{stat.title}</p>
|
||||
<p className="text-3xl font-black font-heading text-fg mt-2 group-hover:text-accent transition-colors">{stat.value}</p>
|
||||
{stat.trend && (
|
||||
<p className="text-sm text-success mt-1 font-medium">{stat.trend}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg bg-bg-app border border-border group-hover:border-accent group-hover:shadow-[0_0_10px_var(--accent-glow)] transition-all`}>
|
||||
<stat.icon className="w-6 h-6 text-fg group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recent Gifts */}
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold font-heading text-fg">Recent Gifts</h2>
|
||||
<Link to="/gifts" className="text-accent hover:text-white text-sm font-bold tracking-wide uppercase transition-colors">
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{gifts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Gift className="w-12 h-12 text-fg-muted mx-auto mb-3" />
|
||||
<p className="text-fg-muted font-medium">No gifts created yet</p>
|
||||
<Link to="/gifts/create" className="mt-4 inline-flex items-center gap-2 btn btn-primary">
|
||||
<Plus className="w-4 h-4" />
|
||||
Create Your First Gift
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{gifts.slice(0, 8).map((gift) => (
|
||||
<Link
|
||||
key={gift.GiftId}
|
||||
to={`/gifts/edit/${gift.GiftId}`}
|
||||
className="bg-bg-app border border-border rounded-lg p-4 hover:border-accent hover:shadow-[0_0_15px_var(--accent-glow)] transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-lg font-bold text-fg group-hover:text-accent transition-colors">#{gift.GiftId}</span>
|
||||
{gift.Limited && (
|
||||
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-full font-bold uppercase tracking-wider">
|
||||
Limited
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-fg-muted mb-2 truncate font-medium">{gift.Title || 'Untitled'}</p>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-1 text-yellow-400 font-bold">
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
{gift.Stars}
|
||||
</span>
|
||||
{gift.SoldOut && (
|
||||
<span className="text-red-500 font-bold uppercase tracking-wider text-xs">Sold Out</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Most Popular */}
|
||||
{stats?.mostPopular && stats.mostPopular.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="text-xl font-bold font-heading text-fg mb-4">Most Popular Gifts</h2>
|
||||
<div className="space-y-3">
|
||||
{stats.mostPopular.map((item, index) => (
|
||||
<div key={item._id} className="flex items-center justify-between p-3 bg-bg-app border border-border rounded-lg hover:border-accent/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-accent/10 text-accent border border-accent/20 w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm shadow-[0_0_10px_rgba(0,242,255,0.1)]">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span className="font-medium text-fg">Gift #{item._id}</span>
|
||||
</div>
|
||||
<span className="text-fg-muted font-mono">{item.count} sent</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Upload, ArrowLeft, Sparkles } from 'lucide-react'
|
||||
import { giftsApi } from '../lib/api'
|
||||
import useGiftsStore from '../store/useGiftsStore'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function EditGift() {
|
||||
const { giftId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { updateGift } = useGiftsStore()
|
||||
const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm()
|
||||
const [stickerFile, setStickerFile] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const limited = watch('Limited')
|
||||
|
||||
useEffect(() => {
|
||||
loadGift()
|
||||
}, [giftId])
|
||||
|
||||
const loadGift = async () => {
|
||||
try {
|
||||
const response = await giftsApi.getById(giftId)
|
||||
const gift = response.data
|
||||
|
||||
setValue('title', gift.Title)
|
||||
setValue('stars', gift.Stars)
|
||||
setValue('convertStars', gift.ConvertStars)
|
||||
setValue('upgradeStars', gift.UpgradeStars)
|
||||
setValue('Limited', gift.Limited)
|
||||
setValue('birthday', gift.Birthday)
|
||||
setValue('SoldOut', gift.SoldOut)
|
||||
setValue('availabilityTotal', gift.AvailabilityTotal)
|
||||
setValue('AvailabilityRemains', gift.AvailabilityRemains)
|
||||
|
||||
// Force infinite availability if not limited
|
||||
if (!gift.Limited) {
|
||||
setValue('availabilityTotal', 2000000000) // 2 Billion (effectively infinite)
|
||||
setValue('AvailabilityRemains', 2000000000)
|
||||
setValue('convertStars', 0)
|
||||
setValue('upgradeStars', 0)
|
||||
}
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load gift')
|
||||
navigate('/gifts')
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (rawData) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// Process data to ensure correct types
|
||||
const stars = Number(rawData.stars);
|
||||
const data = {
|
||||
...rawData,
|
||||
stars: stars,
|
||||
convertStars: rawData.Limited
|
||||
? Number(rawData.convertStars)
|
||||
: Math.floor(stars * 0.7) || 1,
|
||||
|
||||
upgradeStars: rawData.Limited
|
||||
? Number(rawData.upgradeStars)
|
||||
: (stars * 10) || 100,
|
||||
}
|
||||
|
||||
if (rawData.Limited) {
|
||||
data.availabilityTotal = Number(rawData.availabilityTotal)
|
||||
data.AvailabilityRemains = rawData.AvailabilityRemains
|
||||
? Number(rawData.AvailabilityRemains)
|
||||
: Number(rawData.availabilityTotal)
|
||||
} else {
|
||||
delete data.availabilityTotal
|
||||
delete data.AvailabilityRemains
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('data', JSON.stringify(data))
|
||||
if (stickerFile) {
|
||||
formData.append('sticker', stickerFile)
|
||||
}
|
||||
|
||||
await updateGift(parseInt(giftId), formData)
|
||||
navigate('/gifts')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<button onClick={() => navigate('/gifts')} className="btn btn-secondary mb-6 flex items-center gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Gifts
|
||||
</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="bg-gradient-to-br from-primary-500 to-primary-600 p-3 rounded-lg">
|
||||
<Sparkles className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Gift #{giftId}</h1>
|
||||
<p className="text-[#8b98a5]">Update gift details</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div>
|
||||
<label className="label">Title</label>
|
||||
<input type="text" className="input" {...register('title')} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Update Sticker (JSON/TGS)</label>
|
||||
<div className="border-2 border-dashed border-[#2b5278] rounded-lg p-6 text-center">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.tgs"
|
||||
onChange={(e) => setStickerFile(e.target.files[0])}
|
||||
className="hidden"
|
||||
id="sticker-upload"
|
||||
/>
|
||||
<label htmlFor="sticker-upload" className="cursor-pointer">
|
||||
<Upload className="w-12 h-12 text-[#8b98a5] mx-auto mb-2" />
|
||||
<p className="text-[#8b98a5]">{stickerFile ? stickerFile.name : 'Click to upload new sticker'}</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`grid grid-cols-1 ${limited ? 'md:grid-cols-3' : 'md:grid-cols-1'} gap-6`}>
|
||||
<div>
|
||||
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Price (Stars)</label>
|
||||
<input type="number" className="input text-yellow-500 font-bold" {...register('stars', { min: 1 })} />
|
||||
</div>
|
||||
|
||||
{limited && (
|
||||
<>
|
||||
<div className="animate-fade-in">
|
||||
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Convert Stars</label>
|
||||
<input type="number" className="input" {...register('convertStars', { required: 'Required for limited gifts', min: 1 })} />
|
||||
</div>
|
||||
<div className="animate-fade-in">
|
||||
<label className="label uppercase tracking-wider text-xs font-bold text-fg-muted mb-2 block">Upgrade Stars</label>
|
||||
<input type="number" className="input text-purple-400" {...register('upgradeStars')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="Limited" className="w-4 h-4 text-primary-600 rounded" {...register('Limited')} />
|
||||
<label htmlFor="Limited" className="text-sm font-medium">Limited</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="birthday" className="w-4 h-4 text-primary-600 rounded" {...register('birthday')} />
|
||||
<label htmlFor="birthday" className="text-sm font-medium">Birthday</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="SoldOut" className="w-4 h-4 text-primary-600 rounded" {...register('SoldOut')} />
|
||||
<label htmlFor="SoldOut" className="text-sm font-medium">Sold Out</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{limited && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-purple-900 mb-3">Limited Edition Settings</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="label">Total Supply</label>
|
||||
<input type="number" className="input" {...register('availabilityTotal')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Remaining</label>
|
||||
<input type="number" className="input" {...register('AvailabilityRemains')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<button type="submit" disabled={submitting} className="btn btn-primary flex-1">
|
||||
{submitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button type="button" onClick={() => navigate('/gifts')} className="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Package, Plus, Search, ExternalLink, Trash2, Edit, FileArchive, Database } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function EmojiPacks() {
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks();
|
||||
}, [currentPage, searchTerm]);
|
||||
|
||||
const fetchPacks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/emojipacks?page=${currentPage}&limit=20&search=${searchTerm}&emojis=true`
|
||||
);
|
||||
const data = await response.json();
|
||||
setPacks(data.packs || []);
|
||||
setTotalPages(data.pagination?.totalPages || 1);
|
||||
} catch (error) {
|
||||
console.error('Error fetching emoji packs:', error);
|
||||
toast.error('Failed to load emoji packs');
|
||||
setPacks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePack = async (packId, packName) => {
|
||||
if (!confirm(`Delete emoji pack "${packName}"? This will delete all emojis in the pack.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/emojipacks/${packId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Pack deleted successfully');
|
||||
fetchPacks();
|
||||
} else {
|
||||
toast.error('Failed to delete pack');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting pack:', error);
|
||||
toast.error('Error deleting pack');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<Package className="w-8 h-8 text-purple" />
|
||||
Custom Emoji Packs
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1">Manage custom emoji sticker sets</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
to="/emojipacks/backup"
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Database className="w-5 h-5" />
|
||||
Backup/Restore
|
||||
</Link>
|
||||
<Link
|
||||
to="/emojipacks/bulk-upload"
|
||||
className="btn bg-success/10 text-success hover:bg-success/20 border-success/20 flex items-center gap-2"
|
||||
>
|
||||
<FileArchive className="w-5 h-5" />
|
||||
Bulk Upload ZIP
|
||||
</Link>
|
||||
<Link
|
||||
to="/emojipacks/create"
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Pack
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card p-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-3 w-5 h-5 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title or short name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="input pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packs Grid */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
|
||||
</div>
|
||||
) : packs.length === 0 ? (
|
||||
<div className="text-center py-20 card">
|
||||
<Package className="w-16 h-16 text-fg-muted mx-auto mb-4" />
|
||||
<p className="text-fg-muted text-lg">No emoji packs found</p>
|
||||
<Link
|
||||
to="/emojipacks/create"
|
||||
className="mt-6 btn btn-primary inline-flex"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Create First Pack
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{packs.map((pack) => (
|
||||
<div
|
||||
key={pack.StickerSetId || pack.stickerset_id}
|
||||
className="card p-6 hover:border-purple/50 transition-colors group"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-fg group-hover:text-purple transition-colors">
|
||||
{pack.Title || pack.title}
|
||||
</h3>
|
||||
<p className="text-sm text-fg-muted">@{pack.ShortName || pack.short_name}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/emojipacks/${pack.StickerSetId || pack.stickerset_id}/edit`}
|
||||
className="p-2 text-fg-muted hover:text-blue hover:bg-blue/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => deletePack(pack.StickerSetId || pack.stickerset_id, pack.Title || pack.title)}
|
||||
className="p-2 text-fg-muted hover:text-red hover:bg-red/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6 bg-muted/30 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-fg-muted">Emojis</span>
|
||||
<span className="font-semibold text-fg">{pack.emoji_count || pack.Count || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-fg-muted">Text Color</span>
|
||||
<span className={(pack.TextColor ?? pack.text_color) ? 'text-success' : 'text-fg-muted'}>
|
||||
{(pack.TextColor ?? pack.text_color) ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-fg-muted">Status</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${(pack.Archived ?? pack.archived)
|
||||
? 'bg-red/10 text-red'
|
||||
: 'bg-success/10 text-success'
|
||||
}`}>
|
||||
{(pack.Archived ?? pack.archived) ? 'Archived' : 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
to={`/emojipacks/${pack.StickerSetId || pack.stickerset_id}`}
|
||||
className="flex-1 btn btn-secondary text-center justify-center"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
<a
|
||||
href={`https://t.me/addemoji/${pack.ShortName || pack.short_name}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost px-3 text-fg-muted hover:text-blue"
|
||||
title="Open in Telegram"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 pt-6">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="btn btn-secondary disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="btn btn-ghost cursor-default">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="btn btn-secondary disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState } from 'react'
|
||||
import { Download, Upload, AlertCircle, CheckCircle, Loader } from 'lucide-react'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
export default function EmojiPacksBackup() {
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [result, setResult] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
const response = await fetch(`${API_URL}/api/emojipacks/export`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Export failed')
|
||||
}
|
||||
|
||||
// Get filename from Content-Disposition header
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
const filenameMatch = contentDisposition?.match(/filename="(.+)"/)
|
||||
const filename = filenameMatch ? filenameMatch[1] : `emoji-packs-export-${Date.now()}.json`
|
||||
|
||||
// Download file
|
||||
const blob = await response.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
|
||||
setResult({
|
||||
type: 'export',
|
||||
message: 'Export successful! File downloaded.'
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportMetadata = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
setImporting(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch(`${API_URL}/api/emojipacks/import/metadata`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Import failed')
|
||||
}
|
||||
|
||||
setResult({
|
||||
type: 'metadata',
|
||||
data
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportFiles = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
setImporting(true)
|
||||
setError(null)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch(`${API_URL}/api/emojipacks/import/files`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Import failed')
|
||||
}
|
||||
|
||||
setResult({
|
||||
type: 'files',
|
||||
data
|
||||
})
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setImporting(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-2">Emoji Packs Backup</h1>
|
||||
<p className="text-[#8b98a5]">
|
||||
Export all emoji packs to JSON file or import them back after docker-compose down
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Export Card */}
|
||||
<div className="card rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Download className="w-6 h-6 text-blue-400" />
|
||||
<h2 className="text-xl font-semibold">Export Packs</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-[#8b98a5] mb-4">
|
||||
Download all emoji packs with metadata and TGS files as a ZIP archive.
|
||||
Use this before running docker-compose down.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="w-full bg-[#5288c1] text-white px-4 py-2 rounded hover:bg-[#3d5a7a] disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export All Packs
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import Card */}
|
||||
<div className="card rounded-lg shadow p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Upload className="w-6 h-6 text-green-600" />
|
||||
<h2 className="text-xl font-semibold">Import Packs (2 Steps)</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-[#8b98a5] mb-4">
|
||||
Step 1: Upload metadata.json<br/>
|
||||
Step 2: Upload ZIP with files folder
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="w-full bg-[#5288c1] text-white px-4 py-2 rounded hover:bg-[#3d5a7a] disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center gap-2 cursor-pointer">
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
1. Import Metadata (JSON)
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleImportMetadata}
|
||||
disabled={importing}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="w-full bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center gap-2 cursor-pointer">
|
||||
{importing ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
2. Import Files (ZIP)
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleImportFiles}
|
||||
disabled={importing}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="mt-6 bg-green-500/20 border border-green-500/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-green-900 mb-2">
|
||||
{result.type === 'export' && 'Export Successful'}
|
||||
{result.type === 'metadata' && 'Metadata Imported'}
|
||||
{result.type === 'files' && 'Files Imported'}
|
||||
</h3>
|
||||
|
||||
{result.type === 'export' && (
|
||||
<p className="text-green-400">{result.message}</p>
|
||||
)}
|
||||
|
||||
{result.type === 'metadata' && (
|
||||
<div className="text-green-400 space-y-1">
|
||||
<p>✅ Imported {result.data.imported.packs} packs</p>
|
||||
<p>✅ Imported {result.data.imported.documents} documents</p>
|
||||
{result.data.skipped.packs > 0 && (
|
||||
<p>⏭️ Skipped {result.data.skipped.packs} existing packs</p>
|
||||
)}
|
||||
<p className="mt-2 font-semibold">➡️ Now upload TGS files (Step 2)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.type === 'files' && (
|
||||
<div className="text-green-400 space-y-1">
|
||||
<p>✅ Uploaded {result.data.uploaded} TGS files</p>
|
||||
{result.data.skipped > 0 && (
|
||||
<p>⏭️ Skipped {result.data.skipped} existing files</p>
|
||||
)}
|
||||
<p className="mt-2 font-semibold">🎉 Import complete!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-6 bg-red-500/20 border border-red-500/30 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-400 mt-0.5" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-red-900 mb-1">Error</h3>
|
||||
<p className="text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-500/30 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-blue-900 mb-3">📝 How to use:</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-blue-300">
|
||||
<li>
|
||||
<strong>Before docker-compose down:</strong> Click "Export All Packs" to download a ZIP backup
|
||||
</li>
|
||||
<li>
|
||||
<strong>After restarting:</strong> Click "Import from ZIP" and select your backup file
|
||||
</li>
|
||||
<li>
|
||||
All packs, documents, and TGS files will be restored automatically
|
||||
</li>
|
||||
<li>
|
||||
Existing packs and files will be skipped (no duplicates)
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div className="mt-4 p-3 bg-yellow-500/20 border border-yellow-500/30 rounded">
|
||||
<p className="text-yellow-400 text-sm">
|
||||
<strong>✅ Complete Backup:</strong> The ZIP file contains both MongoDB data (metadata)
|
||||
and MinIO files (TGS animations). You can safely delete Docker volumes after export.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* Featured Sticker Packs Styles */
|
||||
.featured-pack-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.featured-pack-item:hover {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.featured-pack-item .grip-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.featured-pack-item .grip-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Drag and drop styles */
|
||||
.featured-pack-item.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.featured-pack-item.drag-over {
|
||||
border-color: #3b82f6 !important;
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Modal animations */
|
||||
.modal-backdrop {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
.reorder-button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.reorder-button:hover:not(:disabled) {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.reorder-button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box code {
|
||||
background-color: rgba(59, 130, 246, 0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Star, ArrowLeft, Plus, Trash2, ChevronUp, ChevronDown, GripVertical } from 'lucide-react';
|
||||
|
||||
export default function FeaturedStickerPacks() {
|
||||
const [featuredPacks, setFeaturedPacks] = useState([]);
|
||||
const [allPacks, setAllPacks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeaturedPacks();
|
||||
fetchAllPacks();
|
||||
}, []);
|
||||
|
||||
const fetchFeaturedPacks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/stickerpacks/featured');
|
||||
const data = await response.json();
|
||||
setFeaturedPacks(data.packs || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching featured packs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAllPacks = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stickerpacks?limit=100');
|
||||
const data = await response.json();
|
||||
setAllPacks(data.packs || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching all packs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFeatured = async (stickerSetId, isFeatured) => {
|
||||
try {
|
||||
const response = await fetch(`/api/stickerpacks/${stickerSetId}/featured`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
isFeatured: !isFeatured,
|
||||
featuredOrder: isFeatured ? 0 : (featuredPacks.length + 1)
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchFeaturedPacks();
|
||||
fetchAllPacks();
|
||||
} else {
|
||||
alert('Failed to update featured status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling featured:', error);
|
||||
alert('Error updating featured status');
|
||||
}
|
||||
};
|
||||
|
||||
const moveUp = async (pack, index) => {
|
||||
if (index === 0) return;
|
||||
|
||||
const newOrder = [...featuredPacks];
|
||||
[newOrder[index], newOrder[index - 1]] = [newOrder[index - 1], newOrder[index]];
|
||||
|
||||
await reorderPacks(newOrder);
|
||||
};
|
||||
|
||||
const moveDown = async (pack, index) => {
|
||||
if (index === featuredPacks.length - 1) return;
|
||||
|
||||
const newOrder = [...featuredPacks];
|
||||
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
|
||||
|
||||
await reorderPacks(newOrder);
|
||||
};
|
||||
|
||||
const reorderPacks = async (newOrder) => {
|
||||
try {
|
||||
const updates = newOrder.map((pack, idx) => ({
|
||||
stickerSetId: pack.StickerSetId,
|
||||
featuredOrder: idx + 1
|
||||
}));
|
||||
|
||||
const response = await fetch('/api/stickerpacks/featured/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ packs: updates })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchFeaturedPacks();
|
||||
} else {
|
||||
alert('Failed to reorder packs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reordering:', error);
|
||||
alert('Error reordering packs');
|
||||
}
|
||||
};
|
||||
|
||||
const availablePacks = allPacks.filter(
|
||||
pack => !featuredPacks.some(fp => fp.StickerSetId === pack.StickerSetId)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/stickerpacks"
|
||||
className="inline-flex items-center text-blue-400 hover:text-blue-300 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back to Sticker Packs
|
||||
</Link>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white flex items-center gap-2">
|
||||
<Star className="w-8 h-8 text-yellow-500" />
|
||||
Featured Sticker Packs
|
||||
</h1>
|
||||
<p className="text-[#8b98a5] mt-1">
|
||||
Manage featured regular sticker packs shown to users
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Add Featured Pack
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-500/10 border border-blue-500/30 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-semibold text-blue-400 mb-2">ℹ️ About Featured Sticker Packs</h3>
|
||||
<ul className="text-sm text-[#8b98a5] space-y-1">
|
||||
<li>• Featured packs are shown in the sticker panel for all users</li>
|
||||
<li>• Only <strong>regular stickers</strong> (not custom emoji) can be featured</li>
|
||||
<li>• Order matters - use ↑↓ buttons to reorder</li>
|
||||
<li>• Client calls <code>messages.getFeaturedStickers</code> to get this list</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Featured Packs List */}
|
||||
<div className="card rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">
|
||||
Featured Packs ({featuredPacks.length})
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p className="mt-2 text-[#8b98a5]">Loading...</p>
|
||||
</div>
|
||||
) : featuredPacks.length === 0 ? (
|
||||
<div className="text-center py-8 text-[#8b98a5]">
|
||||
<Star className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No featured packs yet</p>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="mt-4 text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
Add your first featured pack
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{featuredPacks.map((pack, index) => (
|
||||
<div
|
||||
key={pack.StickerSetId}
|
||||
className="flex items-center gap-3 p-4 bg-[#0e1621] rounded-lg border border-[#2b5278] hover:border-blue-500/50 transition-colors"
|
||||
>
|
||||
<GripVertical className="w-5 h-5 text-[#8b98a5]" />
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-white">{pack.Title}</h3>
|
||||
<p className="text-sm text-[#8b98a5]">
|
||||
@{pack.ShortName} • {pack.Count} stickers • Order: {pack.FeaturedOrder}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => moveUp(pack, index)}
|
||||
disabled={index === 0}
|
||||
className="p-2 text-[#8b98a5] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => moveDown(pack, index)}
|
||||
disabled={index === featuredPacks.length - 1}
|
||||
className="p-2 text-[#8b98a5] hover:text-white disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleFeatured(pack.StickerSetId, true)}
|
||||
className="p-2 text-red-400 hover:text-red-300"
|
||||
title="Remove from featured"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-[#17212b] rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Add Featured Pack</h2>
|
||||
|
||||
{availablePacks.length === 0 ? (
|
||||
<div className="text-center py-8 text-[#8b98a5]">
|
||||
<p>All packs are already featured</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{availablePacks.map(pack => (
|
||||
<div
|
||||
key={pack.StickerSetId}
|
||||
className="flex items-center justify-between p-4 bg-[#0e1621] rounded-lg border border-[#2b5278] hover:border-blue-500/50"
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">{pack.Title}</h3>
|
||||
<p className="text-sm text-[#8b98a5]">
|
||||
@{pack.ShortName} • {pack.Count} stickers
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleFeatured(pack.StickerSetId, false);
|
||||
setShowAddModal(false);
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 bg-[#2b5278] text-white rounded-lg hover:bg-[#3d5a7a]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Search, Info, Shield, Filter, User, Calendar, CheckCircle2, XCircle, AlertTriangle, Snowflake } from 'lucide-react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
const FrozenAccounts = () => {
|
||||
const [frozenAccounts, setFrozenAccounts] = useState([]);
|
||||
const [appeals, setAppeals] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('accounts'); // 'accounts' or 'appeals'
|
||||
const [showFreezeModal, setShowFreezeModal] = useState(false);
|
||||
const [freezeForm, setFreezeForm] = useState({
|
||||
userId: '',
|
||||
reason: 4,
|
||||
durationDays: 7,
|
||||
note: ''
|
||||
});
|
||||
|
||||
const reasons = {
|
||||
1: 'Spam',
|
||||
2: 'Malicious Links',
|
||||
3: 'Mass Reports',
|
||||
4: 'ToS Violation',
|
||||
99: 'Other'
|
||||
};
|
||||
|
||||
const statuses = {
|
||||
1: { label: 'Active', color: 'red' },
|
||||
2: { label: 'Appeal Pending', color: 'yellow' },
|
||||
3: { label: 'Approved', color: 'green' },
|
||||
4: { label: 'Rejected', color: 'gray' },
|
||||
5: { label: 'Expired', color: 'gray' }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (activeTab === 'accounts') {
|
||||
// In a real scenario, this endpoint might need to be adjusted or proxied
|
||||
const response = await fetch(`${API_URL}/frozen-accounts`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setFrozenAccounts(data.data);
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(`${API_URL}/frozen-accounts/appeals`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAppeals(data.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error loading data');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFreezeAccount = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/frozen-accounts/freeze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(freezeForm)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Account frozen successfully');
|
||||
setShowFreezeModal(false);
|
||||
setFreezeForm({ userId: '', reason: 4, durationDays: 7, note: '' });
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to freeze account');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error freezing account');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnfreeze = async (userId) => {
|
||||
if (!confirm(`Unfreeze account for user ${userId}?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/frozen-accounts/unfreeze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Account unfrozen successfully');
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to unfreeze account');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error unfreezing account');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReviewAppeal = async (appealId, status) => {
|
||||
const statusText = status === 2 ? 'approve' : 'reject';
|
||||
if (!confirm(`${statusText.charAt(0).toUpperCase() + statusText.slice(1)} this appeal?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/frozen-accounts/appeals/${appealId}/review`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, moderatorUserId: 777000 })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success(data.message);
|
||||
fetchData();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to review appeal');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error reviewing appeal');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
if (!timestamp) return 'N/A';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<div className="p-2.5 bg-blue/10 rounded-xl rounded-tr-none">
|
||||
<Snowflake className="w-8 h-8 text-blue" />
|
||||
</div>
|
||||
Frozen Accounts Management
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-2">Manage frozen accounts and review appeals</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-border space-x-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('accounts')}
|
||||
className={`pb-3 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${activeTab === 'accounts'
|
||||
? 'border-blue text-blue'
|
||||
: 'border-transparent text-fg-muted hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Frozen Accounts ({frozenAccounts.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('appeals')}
|
||||
className={`pb-3 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${activeTab === 'appeals'
|
||||
? 'border-blue text-blue'
|
||||
: 'border-transparent text-fg-muted hover:text-fg'
|
||||
}`}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
Appeals ({appeals.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Freeze Account Button */}
|
||||
{activeTab === 'accounts' && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowFreezeModal(true)}
|
||||
className="btn bg-red text-white hover:bg-red/90 flex items-center gap-2"
|
||||
>
|
||||
<Snowflake className="w-4 h-4" />
|
||||
Freeze Account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'accounts' ? (
|
||||
<div className="card border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">User ID</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Reason</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Frozen Since</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Until</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-card">
|
||||
{frozenAccounts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-6 py-12 text-center text-fg-muted">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 bg-muted rounded-full flex items-center justify-center">
|
||||
<Snowflake className="w-6 h-6 text-fg-muted" />
|
||||
</div>
|
||||
<p>No frozen accounts</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
frozenAccounts.map((account) => (
|
||||
<tr key={account._id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-fg">
|
||||
{account.UserId}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-fg-muted">
|
||||
{reasons[account.Reason] || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2.5 py-1 inline-flex text-xs leading-5 font-semibold rounded-full border ${account.Status === 1 ? 'bg-red/10 text-red border-red/20' :
|
||||
account.Status === 2 ? 'bg-yellow/10 text-yellow border-yellow/20' :
|
||||
account.Status === 3 ? 'bg-success/10 text-success border-success/20' :
|
||||
'bg-muted text-fg-muted border-border'
|
||||
}`}>
|
||||
{statuses[account.Status]?.label || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-fg-muted font-mono">
|
||||
{formatDate(account.FreezeSinceDate)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-fg-muted font-mono">
|
||||
{formatDate(account.FreezeUntilDate)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
{account.Status === 1 ? (
|
||||
<button
|
||||
onClick={() => handleUnfreeze(account.UserId)}
|
||||
className="text-success hover:text-success/80 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Unfreeze
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-fg-muted opacity-50">
|
||||
{account.Status === 3 ? 'Already unfrozen' : '-'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6">
|
||||
{appeals.length === 0 ? (
|
||||
<div className="card p-12 text-center text-fg-muted border-dashed">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Info className="w-8 h-8 text-fg-muted" />
|
||||
</div>
|
||||
No appeals yet
|
||||
</div>
|
||||
) : (
|
||||
appeals.map((appeal) => (
|
||||
<div key={appeal._id} className="card p-6 border-border hover:border-purple/50 transition-colors">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-start mb-4 gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-fg flex items-center gap-2">
|
||||
Appeal #{appeal.AppealId}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-1 text-sm text-fg-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3.5 h-3.5" />
|
||||
ID: {appeal.UserId}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1 h-1 rounded-full bg-fg-muted"></span>
|
||||
Name: {appeal.UserName}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
Submitted: {new Date(appeal.SubmittedDate).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded-full border w-fit ${appeal.Status === 1 ? 'bg-yellow/10 text-yellow border-yellow/20' :
|
||||
appeal.Status === 2 ? 'bg-success/10 text-success border-success/20' :
|
||||
'bg-red/10 text-red border-red/20'
|
||||
}`}>
|
||||
{appeal.Status === 1 ? '⏳ Pending' : appeal.Status === 2 ? '✅ Approved' : '❌ Rejected'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 bg-muted/30 p-4 rounded-lg border border-border">
|
||||
<p className="text-xs font-bold text-fg-muted uppercase tracking-wider mb-2">Appeal Text</p>
|
||||
<p className="text-sm text-fg">{appeal.AppealText}</p>
|
||||
</div>
|
||||
|
||||
{appeal.Status === 1 && (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleReviewAppeal(appeal.AppealId, 2)}
|
||||
className="btn bg-success/10 text-success border border-success/20 hover:bg-success/20 flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReviewAppeal(appeal.AppealId, 3)}
|
||||
className="btn bg-red/10 text-red border border-red/20 hover:bg-red/20 flex items-center gap-2"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appeal.ReviewNote && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<p className="text-xs font-bold text-fg-muted uppercase tracking-wider mb-1">Review Note</p>
|
||||
<p className="text-sm text-fg-muted italic">{appeal.ReviewNote}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Freeze Modal */}
|
||||
{showFreezeModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in">
|
||||
<div className="bg-card w-full max-w-md rounded-2xl border border-border shadow-2xl p-6 m-4 animate-scale-in">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-heading font-bold text-fg flex items-center gap-2">
|
||||
<Snowflake className="w-5 h-5 text-red" />
|
||||
Freeze Account
|
||||
</h2>
|
||||
<button onClick={() => setShowFreezeModal(false)} className="text-fg-muted hover:text-fg">
|
||||
<XCircle className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleFreezeAccount} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
User ID <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={freezeForm.userId}
|
||||
onChange={(e) => setFreezeForm({ ...freezeForm, userId: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Enter User ID"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
Reason
|
||||
</label>
|
||||
<select
|
||||
value={freezeForm.reason}
|
||||
onChange={(e) => setFreezeForm({ ...freezeForm, reason: parseInt(e.target.value) })}
|
||||
className="input w-full"
|
||||
>
|
||||
{Object.entries(reasons).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
Duration (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={freezeForm.durationDays}
|
||||
onChange={(e) => setFreezeForm({ ...freezeForm, durationDays: parseInt(e.target.value) })}
|
||||
className="input w-full"
|
||||
min="1"
|
||||
max="365"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">
|
||||
Note (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={freezeForm.note}
|
||||
onChange={(e) => setFreezeForm({ ...freezeForm, note: e.target.value })}
|
||||
className="input w-full resize-none"
|
||||
rows="3"
|
||||
placeholder="Add internal note..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFreezeModal(false)}
|
||||
className="btn btn-secondary flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn bg-red text-white hover:bg-red/90 flex-1"
|
||||
>
|
||||
Confirm Freeze
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FrozenAccounts;
|
||||
@@ -0,0 +1,334 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Snowflake, Upload, FileJson, Save, Info, CheckCircle2, AlertTriangle, File } from 'lucide-react';
|
||||
|
||||
export default function FrozenIconSettings() {
|
||||
const [settings, setSettings] = useState({
|
||||
type: 'emoji',
|
||||
emoji: '❄️',
|
||||
documentId: null
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [file, setFile] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3001/api/users/settings/frozen-icon');
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
toast.error('Failed to load settings');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile && selectedFile.name.endsWith('.json')) {
|
||||
setFile(selectedFile);
|
||||
} else {
|
||||
toast.error('Please select a .json file');
|
||||
setFile(null);
|
||||
e.target.value = null; // Reset input
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
toast.error('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const toastId = toast.loading('Processing animation...');
|
||||
|
||||
try {
|
||||
// Read JSON file content
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const jsonContent = JSON.parse(e.target.result);
|
||||
|
||||
// Validate Lottie JSON structure
|
||||
if (!jsonContent.v || !jsonContent.layers) {
|
||||
toast.error('Invalid Lottie JSON: missing required fields', { id: toastId });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Valid Lottie JSON:', {
|
||||
version: jsonContent.v,
|
||||
width: jsonContent.w,
|
||||
height: jsonContent.h,
|
||||
layers: jsonContent.layers?.length
|
||||
});
|
||||
|
||||
// Step 1: Upload JSON as document to Telegram
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', 'animation');
|
||||
formData.append('title', 'Frozen Account Animation');
|
||||
|
||||
const uploadResponse = await fetch('http://localhost:3001/api/upload/document', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const uploadData = await uploadResponse.json();
|
||||
|
||||
if (!uploadData.success) {
|
||||
toast.error('Failed to upload to Telegram: ' + (uploadData.error || 'Unknown error'), { id: toastId });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Save document ID as global frozen animation setting
|
||||
const response = await fetch('http://localhost:3001/api/users/settings/frozen-icon', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'animation',
|
||||
emoji: settings.emoji,
|
||||
documentId: uploadData.documentId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success(`Animation uploaded successfully! ID: ${uploadData.documentId}`, { id: toastId });
|
||||
setSettings({ ...settings, type: 'animation', documentId: uploadData.documentId });
|
||||
setFile(null);
|
||||
} else {
|
||||
toast.error('Failed to save settings: ' + (data.error || 'Unknown error'), { id: toastId });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('JSON parse error:', error);
|
||||
toast.error('Invalid JSON file: ' + error.message, { id: toastId });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
toast.error('Upload failed: ' + error.message, { id: toastId });
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUseEmoji = async () => {
|
||||
setLoading(true);
|
||||
const toastId = toast.loading('Saving emoji settings...');
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3001/api/users/settings/frozen-icon', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'emoji',
|
||||
emoji: settings.emoji,
|
||||
documentId: null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Switched to emoji mode', { id: toastId });
|
||||
setSettings({ ...settings, type: 'emoji', documentId: null });
|
||||
} else {
|
||||
toast.error('Failed to update settings', { id: toastId });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Update failed: ' + error.message, { id: toastId });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto animate-fade-in">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<div className="p-2.5 bg-blue/10 rounded-xl rounded-tr-none">
|
||||
<Snowflake className="w-8 h-8 text-blue" />
|
||||
</div>
|
||||
Frozen Account Icon
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-2">Customize the icon shown on frozen accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Current Settings Card */}
|
||||
<div className="card lg:col-span-2">
|
||||
<h2 className="text-xl font-heading font-bold text-fg mb-6 flex items-center gap-2">
|
||||
<Info className="w-5 h-5 text-accent" />
|
||||
Current Configuration
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Type</strong>
|
||||
<div className="flex items-center gap-2">
|
||||
{settings.type === 'emoji' ? (
|
||||
<span className="p-1 bg-yellow/10 rounded text-yellow">
|
||||
<Snowflake className="w-4 h-4" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="p-1 bg-blue/10 rounded text-blue">
|
||||
<FileJson className="w-4 h-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className="text-fg font-medium capitalize">{settings.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Emoji</strong>
|
||||
<span className="text-3xl filter drop-shadow-sm">{settings.emoji}</span>
|
||||
</div>
|
||||
{settings.documentId && (
|
||||
<div className="p-4 bg-muted/30 rounded-xl border border-border md:col-span-1">
|
||||
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Animation ID</strong>
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-success/10 text-success border border-success/20 font-mono">
|
||||
{settings.documentId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Animation Card */}
|
||||
<div className="card h-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-purple/10 rounded-lg">
|
||||
<FileJson className="w-5 h-5 text-purple" />
|
||||
</div>
|
||||
<h2 className="text-xl font-heading font-bold text-fg">Upload Animation</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-fg-muted mb-6 text-sm">
|
||||
Upload a Lottie JSON animation file to use instead of a static emoji.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
disabled={loading}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed z-10"
|
||||
/>
|
||||
<div className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-colors ${file ? 'border-success bg-success/5' : 'border-border group-hover:border-accent group-hover:bg-accent/5'
|
||||
}`}>
|
||||
{file ? (
|
||||
<>
|
||||
<FileJson className="w-10 h-10 text-success mb-3" />
|
||||
<p className="text-fg font-medium">{file.name}</p>
|
||||
<p className="text-fg-muted text-xs mt-1">{(file.size / 1024).toFixed(2)} KB</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-10 h-10 text-fg-muted mb-3 group-hover:text-accent transition-colors" />
|
||||
<p className="text-fg font-medium">Click to upload JSON</p>
|
||||
<p className="text-fg-muted text-xs mt-1">or drag and drop</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || loading}
|
||||
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-5 h-5" />
|
||||
)}
|
||||
{loading ? 'Uploading...' : 'Upload Animation'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Use Emoji Card */}
|
||||
<div className="card h-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-yellow/10 rounded-lg">
|
||||
<Snowflake className="w-5 h-5 text-yellow" />
|
||||
</div>
|
||||
<h2 className="text-xl font-heading font-bold text-fg">Use Emoji</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-fg-muted mb-6 text-sm">
|
||||
Use a simple emoji character instead of an animation.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.emoji}
|
||||
onChange={(e) => setSettings({ ...settings, emoji: e.target.value })}
|
||||
maxLength={2}
|
||||
className="input text-center text-4xl w-24 h-24 p-0 flex items-center justify-center"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="text-sm text-fg-muted flex-1">
|
||||
Enter a single emoji character to be displayed on frozen accounts.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUseEmoji}
|
||||
disabled={loading}
|
||||
className="btn btn-secondary w-full flex items-center justify-center gap-2 mt-auto"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<Save className="w-5 h-5" />
|
||||
)}
|
||||
{loading ? 'Saving...' : 'Save Emoji'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-xl p-6 flex gap-4 items-start">
|
||||
<div className="p-2 bg-blue/10 rounded-lg shrink-0">
|
||||
<Info className="w-5 h-5 text-blue" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-fg font-bold">How it works</h3>
|
||||
<ul className="space-y-1.5 text-sm text-fg-muted">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-blue rounded-full"></span>
|
||||
<span><strong>Emoji mode:</strong> Simple emoji (❄️) will be added to frozen account names</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-blue rounded-full"></span>
|
||||
<span><strong>Animation mode:</strong> Lottie JSON animation will be used (requires client support)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-blue rounded-full"></span>
|
||||
<span><strong>Recommended size:</strong> Under 100KB for best performance</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Snowflake, Upload, Save, Info, CheckCircle2, AlertTriangle, Cloud, FileCode, Server } from 'lucide-react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function FrozenSettings() {
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [file, setFile] = useState(null);
|
||||
const [documentId, setDocumentId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_URL}/api/frozen-settings`);
|
||||
const data = await response.json();
|
||||
setSettings(data.settings);
|
||||
if (data.settings.snowflakeEmojiId) {
|
||||
setDocumentId(data.settings.snowflakeEmojiId.toString());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
toast.error('Failed to load settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile) {
|
||||
// Check if it's a TGS file
|
||||
if (!selectedFile.name.endsWith('.tgs')) {
|
||||
toast.error('Please select a .tgs file');
|
||||
return;
|
||||
}
|
||||
setFile(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) {
|
||||
toast.error('Please select a TGS file first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('snowflake', file);
|
||||
|
||||
const response = await fetch(`${API_URL}/api/frozen-settings/upload-snowflake`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Snowflake TGS uploaded successfully! Now upload it to Telegram and set the Document ID.');
|
||||
fetchSettings();
|
||||
setFile(null);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to upload file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading:', error);
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetEmojiId = async () => {
|
||||
if (!documentId || isNaN(documentId)) {
|
||||
toast.error('Please enter a valid Document ID');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/frozen-settings/set-emoji-id`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
documentId: parseInt(documentId)
|
||||
})
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Emoji ID updated! Restart QueryServer to apply changes.');
|
||||
fetchSettings();
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to set emoji ID');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error setting emoji ID:', error);
|
||||
toast.error(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto animate-fade-in">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<div className="p-2.5 bg-blue/10 rounded-xl rounded-tr-none">
|
||||
<Server className="w-8 h-8 text-blue" />
|
||||
</div>
|
||||
Server-Side Frozen Settings
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-2">Configure the snowflake animation for the server</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Current Configuration */}
|
||||
<div className="card lg:col-span-2">
|
||||
<h2 className="text-xl font-heading font-bold text-fg mb-6 flex items-center gap-2">
|
||||
<Info className="w-5 h-5 text-accent" />
|
||||
Current Configuration
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Snowflake File</strong>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4 text-purple" />
|
||||
<span className="text-fg font-medium font-mono text-sm truncate">
|
||||
{settings?.snowflakeFileName || 'Not uploaded'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Uploaded At</strong>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-fg font-medium text-sm">
|
||||
{settings?.snowflakeUploadedAt
|
||||
? new Date(settings.snowflakeUploadedAt).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Document ID</strong>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-fg font-medium font-mono text-sm underline decoration-dotted">
|
||||
{settings?.snowflakeEmojiId || 'Not set'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-muted/30 rounded-xl border border-border">
|
||||
<strong className="text-xs font-bold text-fg-muted uppercase tracking-wider block mb-2">Status</strong>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium border ${settings?.snowflakeEmojiId
|
||||
? 'bg-success/10 text-success border-success/20'
|
||||
: 'bg-yellow/10 text-yellow border-yellow/20'
|
||||
}`}>
|
||||
{settings?.snowflakeEmojiId ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" /> Active
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" /> Not Configured
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Upload TGS */}
|
||||
<div className="card h-full">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue text-white flex items-center justify-center font-bold text-sm">1</div>
|
||||
<h2 className="text-xl font-heading font-bold text-fg">Upload Snowflake TGS</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-fg-muted mb-6 text-sm">
|
||||
Upload an animated snowflake sticker file (.tgs format, max 64KB).
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="file"
|
||||
accept=".tgs"
|
||||
onChange={handleFileChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div className={`border-2 border-dashed rounded-xl p-8 flex flex-col items-center justify-center transition-colors ${file ? 'border-success bg-success/5' : 'border-border group-hover:border-accent group-hover:bg-accent/5'
|
||||
}`}>
|
||||
{file ? (
|
||||
<>
|
||||
<FileCode className="w-10 h-10 text-success mb-3" />
|
||||
<p className="text-fg font-medium">{file.name}</p>
|
||||
<p className="text-fg-muted text-xs mt-1">{(file.size / 1024).toFixed(2)} KB</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-10 h-10 text-fg-muted mb-3 group-hover:text-accent transition-colors" />
|
||||
<p className="text-fg font-medium">Click to upload TGS</p>
|
||||
<p className="text-fg-muted text-xs mt-1">or drag and drop</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-5 h-5" />
|
||||
)}
|
||||
{uploading ? 'Uploading...' : 'Upload TGS'}
|
||||
</button>
|
||||
|
||||
{settings?.snowflakeFilePath && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg border border-border">
|
||||
<div className="text-xs text-fg-muted uppercase font-bold mb-1">Filesystem Path</div>
|
||||
<code className="text-xs font-mono text-success break-all block">
|
||||
{settings.snowflakeFilePath}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Step 2: Upload to Telegram */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue text-white flex items-center justify-center font-bold text-sm">2</div>
|
||||
<h2 className="text-xl font-heading font-bold text-fg">Upload to Telegram</h2>
|
||||
</div>
|
||||
<p className="text-fg-muted mb-4 text-sm">
|
||||
After uploading the TGS above, upload it to Telegram manually:
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/80 p-4 rounded-xl font-mono text-xs space-y-2 border border-border shadow-inner">
|
||||
<div className="text-fg-muted"># Use Bot API to upload as custom emoji</div>
|
||||
<div className="text-success">POST /bot{'<token>'}/uploadStickerFile</div>
|
||||
<div className="text-fg-muted"># Or use @Stickers bot / admin panel</div>
|
||||
<div className="text-fg-muted"># Get the Document ID from response</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3: Set Document ID */}
|
||||
<div className="card flex-1">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue text-white flex items-center justify-center font-bold text-sm">3</div>
|
||||
<h2 className="text-xl font-heading font-bold text-fg">Set Document ID</h2>
|
||||
</div>
|
||||
<p className="text-fg-muted mb-4 text-sm">
|
||||
Enter the Document ID from Telegram to link it.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={documentId}
|
||||
onChange={(e) => setDocumentId(e.target.value)}
|
||||
placeholder="e.g., 1234567890"
|
||||
className="input w-full font-mono"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSetEmojiId}
|
||||
disabled={!documentId}
|
||||
className="btn btn-secondary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save className="w-5 h-5" />
|
||||
Save Document ID
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-yellow/10 border border-yellow/20 text-yellow text-xs rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>Restart QueryServer after saving to apply changes.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps Guide */}
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-xl p-6">
|
||||
<h3 className="text-lg font-bold font-heading text-fg mb-3 flex items-center gap-2">
|
||||
<Cloud className="w-5 h-5 text-blue" />
|
||||
Workflow Guide
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-fg-muted">
|
||||
<li>Upload your animated snowflake <code className="bg-muted px-1.5 py-0.5 rounded text-fg">.tgs</code> file here.</li>
|
||||
<li>Upload the same TGS to your Telegram server as a custom emoji (using tools or bot).</li>
|
||||
<li>Copy the <strong>Document ID</strong> from the Telegram upload response.</li>
|
||||
<li>Enter that ID in step 3 and save.</li>
|
||||
<li>The system updates <code className="bg-muted px-1.5 py-0.5 rounded text-fg">queryserver.appsettings.json</code> automatically.</li>
|
||||
<li><strong>Restart QueryServer</strong> - frozen accounts will now show the snowflake emoji!</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Star, Edit, Trash2, Filter, Plus } from 'lucide-react'
|
||||
import useGiftsStore from '../store/useGiftsStore'
|
||||
import StickerPreview from '../components/StickerPreview'
|
||||
|
||||
export default function GiftsList() {
|
||||
const { gifts, loading, filters, setFilters, fetchGifts, deleteGift, toggleSoldOut } = useGiftsStore()
|
||||
|
||||
useEffect(() => {
|
||||
fetchGifts()
|
||||
}, [filters])
|
||||
|
||||
const handleDelete = async (giftId) => {
|
||||
if (window.confirm('Are you sure you want to delete this gift?')) {
|
||||
await deleteGift(giftId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black font-heading text-fg">All Gifts</h1>
|
||||
<p className="text-fg-muted font-medium mt-1">{gifts.length} gifts in catalog</p>
|
||||
</div>
|
||||
<Link to="/gifts/create" className="btn btn-primary gap-2 shadow-[0_0_15px_var(--accent-glow)]">
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Gift
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-4">
|
||||
<Filter className="w-5 h-5 text-fg-muted" />
|
||||
<select
|
||||
className="input w-auto bg-bg-app border-border focus:border-accent"
|
||||
value={filters.soldOut ?? ''}
|
||||
onChange={(e) => setFilters({ soldOut: e.target.value === '' ? undefined : e.target.value === 'true' })}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="false">Available</option>
|
||||
<option value="true">Sold Out</option>
|
||||
</select>
|
||||
<select
|
||||
className="input w-auto bg-bg-app border-border focus:border-accent"
|
||||
value={filters.limited ?? ''}
|
||||
onChange={(e) => setFilters({ limited: e.target.value === '' ? undefined : e.target.value === 'true' })}
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
<option value="false">Regular</option>
|
||||
<option value="true">Limited</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gifts Grid */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-accent"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{gifts.map((gift) => (
|
||||
<div key={gift.GiftId} className="card group hover:border-accent hover:shadow-[0_0_20px_var(--accent-glow)] transition-all duration-300">
|
||||
{/* Sticker Preview */}
|
||||
<div className="mb-6 flex justify-center p-4 bg-bg-app rounded-xl border border-border group-hover:border-accent/30 transition-colors">
|
||||
<StickerPreview
|
||||
documentId={typeof gift.Sticker === 'number' ? gift.Sticker : null}
|
||||
stickerBase64={typeof gift.Sticker !== 'number' ? gift.Sticker : null}
|
||||
size={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold font-heading text-fg flex items-center gap-2">
|
||||
#{gift.GiftId}
|
||||
</h3>
|
||||
<p className="text-fg-muted font-medium text-sm mt-1">{gift.Title || 'Untitled'}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{gift.Limited && (
|
||||
<span className="px-2 py-1 bg-purple-500/10 text-purple-400 border border-purple-500/20 text-[10px] font-bold uppercase tracking-wider rounded">
|
||||
Limited
|
||||
</span>
|
||||
)}
|
||||
{gift.Birthday && (
|
||||
<span className="px-2 py-1 bg-pink-500/10 text-pink-400 border border-pink-500/20 text-[10px] font-bold uppercase tracking-wider rounded">
|
||||
Birthday
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6 p-4 bg-bg-app rounded-lg border border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-fg-muted text-sm font-medium uppercase tracking-wide">Price</span>
|
||||
<span className="flex items-center gap-1 font-bold text-yellow-500">
|
||||
<Star className="w-4 h-4 fill-current" />{gift.Stars}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-[1px] bg-border" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-fg-muted text-sm font-medium uppercase tracking-wide">Convert</span>
|
||||
<span className="font-bold text-fg">{gift.ConvertStars}</span>
|
||||
</div>
|
||||
{gift.Limited && (
|
||||
<>
|
||||
<div className="w-full h-[1px] bg-border" />
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-fg-muted text-sm font-medium uppercase tracking-wide">Supply</span>
|
||||
<span className="font-bold text-fg">
|
||||
<span className="text-accent">{gift.AvailabilityRemains}</span>
|
||||
<span className="text-fg-muted mx-1">/</span>
|
||||
{gift.AvailabilityTotal}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link to={`/gifts/edit/${gift.GiftId}`} className="btn btn-secondary flex-1 group-hover:bg-bg-app group-hover:text-fg">
|
||||
<Edit className="w-4 h-4" />
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(gift.GiftId)}
|
||||
className="p-2.5 rounded-lg border border-border text-fg-muted hover:text-red-500 hover:border-red-500/50 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Coins, Sparkles, TrendingUp, User, Calendar, Hash } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
|
||||
export default function IssueStars() {
|
||||
const [userId, setUserId] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [reason, setReason] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [userInfo, setUserInfo] = useState(null)
|
||||
const [recentTransactions, setRecentTransactions] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
loadRecentTransactions()
|
||||
}, [])
|
||||
|
||||
const loadRecentTransactions = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/stars/recent?limit=10`)
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setRecentTransactions(data.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load recent transactions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const searchUser = async () => {
|
||||
if (!userId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/stars/user/${userId}`)
|
||||
const data = await response.json()
|
||||
if (data.success) {
|
||||
setUserInfo(data.data)
|
||||
toast.success(`User found: ${data.data.user.firstName || 'User ' + userId}`)
|
||||
} else {
|
||||
toast.error(data.error || 'User not found')
|
||||
setUserInfo(null)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('User not found')
|
||||
setUserInfo(null)
|
||||
}
|
||||
}
|
||||
|
||||
const issueStars = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!userId || !amount || amount <= 0) {
|
||||
toast.error('Please enter valid User ID and amount')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/stars/issue`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: parseInt(userId),
|
||||
amount: parseInt(amount),
|
||||
reason: reason || 'Admin issued stars'
|
||||
})
|
||||
})
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
toast.success(`✨ Successfully issued ${amount} stars!`)
|
||||
setAmount('')
|
||||
setReason('')
|
||||
loadRecentTransactions()
|
||||
searchUser() // Refresh user info
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to issue stars')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to issue stars')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<div className="p-2.5 bg-gradient-to-br from-yellow to-orange-500 rounded-xl shadow-lg shadow-orange-500/20">
|
||||
<Coins className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
Issue Telegram Stars
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-2">Grant stars to users directly from admin panel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Form */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* User Search Card */}
|
||||
<div className="card p-6 border-border">
|
||||
<h2 className="text-xl font-heading font-semibold text-fg mb-4 flex items-center gap-2">
|
||||
<Search className="w-5 h-5 text-purple" />
|
||||
Find User
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<Hash className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-fg-muted" />
|
||||
<input
|
||||
type="number"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && searchUser()}
|
||||
placeholder="Enter User ID"
|
||||
className="input w-full pl-12"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={searchUser}
|
||||
className="btn btn-primary flex items-center gap-2 px-6"
|
||||
>
|
||||
<Search className="w-5 h-5" />
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Info Display */}
|
||||
{userInfo && (
|
||||
<div className="mt-4 p-4 bg-muted/40 border border-border rounded-xl animate-scale-in">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple to-blue rounded-full flex items-center justify-center shadow-lg shadow-purple/20">
|
||||
<User className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-fg font-semibold text-lg">
|
||||
{userInfo.user.firstName} {userInfo.user.lastName || ''}
|
||||
</h3>
|
||||
<p className="text-fg-muted text-sm font-mono">
|
||||
@{userInfo.user.userName || `user${userInfo.user.userId}`} · ID: {userInfo.user.userId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-fg-muted text-xs uppercase tracking-wider mb-1">Current Balance</p>
|
||||
<p className="text-2xl font-bold text-yellow flex items-center justify-end gap-1.5">
|
||||
<Coins className="w-6 h-6 drop-shadow-sm" />
|
||||
{userInfo.balance}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Issue Stars Form */}
|
||||
<form onSubmit={issueStars} className="card p-6 border-border relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute top-0 right-0 p-32 bg-yellow/5 rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none"></div>
|
||||
|
||||
<h2 className="text-xl font-heading font-semibold text-fg mb-6 flex items-center gap-2 relative z-10">
|
||||
<Sparkles className="w-5 h-5 text-yellow" />
|
||||
Issue Stars
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 relative z-10">
|
||||
<div>
|
||||
<label className="block text-fg font-medium text-sm mb-2">
|
||||
Amount of Stars
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Coins className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-yellow" />
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="Enter amount"
|
||||
min="1"
|
||||
required
|
||||
className="input w-full pl-12 text-lg font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Amount Buttons */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{[50, 100, 500, 1000, 5000].map((amt) => (
|
||||
<button
|
||||
key={amt}
|
||||
type="button"
|
||||
onClick={() => setAmount(amt.toString())}
|
||||
className="px-3 py-1.5 bg-muted border border-border hover:border-yellow hover:text-yellow rounded-lg text-sm text-fg-muted transition-all hover:-translate-y-0.5"
|
||||
>
|
||||
+{amt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-fg font-medium text-sm mb-2">
|
||||
Reason (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. Promotional bonus, Bug bounty reward, etc."
|
||||
rows="3"
|
||||
className="input w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !userId || !amount}
|
||||
className="btn w-full bg-gradient-to-r from-yellow to-orange-500 hover:from-yellow/90 hover:to-orange-500/90 text-black font-bold text-lg py-4 shadow-xl shadow-orange-500/20 hover:shadow-orange-500/30 transition-all transform hover:scale-[1.02] disabled:opacity-50 disabled:scale-100 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-black/30 border-t-black rounded-full animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-5 h-5" />
|
||||
Issue {amount || '0'} Stars
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Recent Transactions Sidebar */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="card p-6 border-border sticky top-6 max-h-[calc(100vh-2rem)] flex flex-col">
|
||||
<h2 className="text-xl font-heading font-semibold text-fg mb-4 flex items-center gap-2 shrink-0">
|
||||
<TrendingUp className="w-5 h-5 text-success" />
|
||||
Recent Transactions
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3 overflow-y-auto pr-2 custom-scrollbar flex-1">
|
||||
{recentTransactions.length === 0 ? (
|
||||
<div className="text-center py-10 bg-muted/20 rounded-xl border border-dashed border-border">
|
||||
<div className="w-10 h-10 bg-muted rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<TrendingUp className="w-5 h-5 text-fg-muted" />
|
||||
</div>
|
||||
<p className="text-fg-muted font-medium">No transactions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
recentTransactions.map((tx, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3 bg-muted/20 border border-border rounded-xl hover:border-purple/50 hover:bg-muted/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-fg font-medium flex items-center gap-1.5 text-sm">
|
||||
<User className="w-3.5 h-3.5 text-fg-muted" />
|
||||
User {tx.userId}
|
||||
</span>
|
||||
<span className={`font-bold text-sm ${tx.Amount > 0 ? 'text-success' : 'text-red'}`}>
|
||||
{tx.Amount > 0 ? '+' : ''}{tx.Amount} ⭐
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-fg-muted text-xs line-clamp-1 mb-1.5 group-hover:text-fg transition-colors">{tx.Reason}</p>
|
||||
<div className="flex items-center gap-1.5 text-fg-muted opacity-70 text-[10px]">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{new Date(tx.Date).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Upload, Trash2, Download, ExternalLink, Copy, BarChart3 } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function ManageEmojiPack() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [pack, setPack] = useState(null);
|
||||
const [emojis, setEmojis] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
// Redirect if no ID
|
||||
useEffect(() => {
|
||||
if (!id || id === 'undefined' || id === 'null') {
|
||||
navigate('/emojipacks', { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
const [uploadForm, setUploadForm] = useState({
|
||||
file: null,
|
||||
alt: '',
|
||||
is_free: true,
|
||||
has_text_color: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (id && id !== 'undefined' && id !== 'null') {
|
||||
fetchPackData();
|
||||
fetchStats();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchPackData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/emojipacks/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pack');
|
||||
}
|
||||
const data = await response.json();
|
||||
setPack(data.pack);
|
||||
setEmojis(data.emojis || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching pack:', error);
|
||||
toast.error('Failed to load emoji pack');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/emojipacks/${id}/stats`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
setUploadForm({ ...uploadForm, file: e.target.files[0] });
|
||||
};
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!uploadForm.file || !uploadForm.alt) {
|
||||
toast.error('Please select a file and enter a fallback emoji');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadForm.file);
|
||||
formData.append('alt', uploadForm.alt);
|
||||
formData.append('is_free', uploadForm.is_free);
|
||||
formData.append('has_text_color', uploadForm.has_text_color);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/emojipacks/${id}/emojis`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Emoji uploaded successfully!');
|
||||
setUploadForm({ file: null, alt: '', is_free: true, has_text_color: false });
|
||||
document.getElementById('file-input').value = '';
|
||||
fetchPackData();
|
||||
fetchStats();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(`Upload failed: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading:', error);
|
||||
toast.error('Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEmoji = async (emojiId) => {
|
||||
if (!confirm('Delete this emoji?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/emojipacks/${id}/emojis/${emojiId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Emoji deleted');
|
||||
fetchPackData();
|
||||
fetchStats();
|
||||
} else {
|
||||
toast.error('Failed to delete emoji');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting emoji:', error);
|
||||
toast.error('Failed to delete emoji');
|
||||
}
|
||||
};
|
||||
|
||||
const copyPackLink = () => {
|
||||
const shortName = pack.ShortName || pack.short_name;
|
||||
const link = `https://t.me/addemoji/${shortName}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
toast.success('Pack link copied to clipboard!');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pack) {
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-red-400">Pack not found</p>
|
||||
<Link to="/emojipacks" className="text-purple hover:underline mt-4 inline-block">
|
||||
Back to Packs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const packTitle = pack.Title || pack.title;
|
||||
const packShortName = pack.ShortName || pack.short_name;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
to="/emojipacks"
|
||||
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back to Packs
|
||||
</Link>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg">{packTitle}</h1>
|
||||
<p className="text-fg-muted">@{packShortName}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyPackLink}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
Copy Link
|
||||
</button>
|
||||
<a
|
||||
href={`https://t.me/addemoji/${packShortName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Open in Telegram
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-fg-muted">Total Emojis</div>
|
||||
<div className="text-2xl font-bold text-purple">{stats.total_emojis}</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-fg-muted">Free Emojis</div>
|
||||
<div className="text-2xl font-bold text-success">{stats.free_emojis}</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-fg-muted">Premium Emojis</div>
|
||||
<div className="text-2xl font-bold text-yellow">{stats.premium_emojis}</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-fg-muted">Total Usage</div>
|
||||
<div className="text-2xl font-bold text-blue">{stats.total_usage}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Form */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-bold text-fg mb-6 flex items-center gap-2">
|
||||
<Upload className="w-6 h-6 text-purple" />
|
||||
Upload New Emoji
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleUpload} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
TGS/WEBM File <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".tgs,.json,.webm"
|
||||
onChange={handleFileChange}
|
||||
className="input w-full p-2"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-fg-muted">Max 64 KB, 512×512px, 60 FPS, ≤3 sec</p>
|
||||
</div>
|
||||
|
||||
{/* Alt Emoji */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Fallback Emoji <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadForm.alt}
|
||||
onChange={(e) => setUploadForm({ ...uploadForm, alt: e.target.value })}
|
||||
placeholder="🔥"
|
||||
maxLength="10"
|
||||
className="input w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-fg-muted">UTF-8 emoji for fallback</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex flex-wrap gap-6 pt-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadForm.is_free}
|
||||
onChange={(e) => setUploadForm({ ...uploadForm, is_free: e.target.checked })}
|
||||
className="w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
|
||||
/>
|
||||
<span className="text-sm text-fg group-hover:text-purple transition-colors">Free (non-Premium users)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={uploadForm.has_text_color}
|
||||
onChange={(e) => setUploadForm({ ...uploadForm, has_text_color: e.target.checked })}
|
||||
className="w-4 h-4 text-purple rounded border-input focus:ring-purple/50 bg-muted"
|
||||
/>
|
||||
<span className="text-sm text-fg group-hover:text-purple transition-colors">Text Color Support</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="btn btn-primary flex items-center justify-center gap-2 w-full md:w-auto px-8"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
{uploading ? 'Uploading...' : 'Upload Emoji'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Emojis List */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-bold text-fg mb-6">
|
||||
Emojis ({emojis.length})
|
||||
</h2>
|
||||
|
||||
{emojis.length === 0 ? (
|
||||
<p className="text-fg-muted text-center py-8">
|
||||
No emojis yet. Upload your first emoji above!
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{emojis.map((emoji) => (
|
||||
<div
|
||||
key={emoji.document_id}
|
||||
className="card border-border hover:border-purple transition-colors relative group p-4"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-5xl mb-2">{emoji.attributes.alt}</div>
|
||||
<div className="text-xs text-fg-muted truncate w-full">ID: {emoji.document_id}</div>
|
||||
<div className="text-xs text-fg-muted">{(emoji.size / 1024).toFixed(1)} KB</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="mt-3 flex flex-wrap gap-1 justify-center">
|
||||
{emoji.attributes.free && (
|
||||
<span className="px-2 py-0.5 bg-success/10 text-success text-[10px] rounded border border-success/20">Free</span>
|
||||
)}
|
||||
{!emoji.attributes.free && (
|
||||
<span className="px-2 py-0.5 bg-yellow/10 text-yellow text-[10px] rounded border border-yellow/20">Premium</span>
|
||||
)}
|
||||
{emoji.attributes.text_color && (
|
||||
<span className="px-2 py-0.5 bg-purple/10 text-purple text-[10px] rounded border border-purple/20">Color</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={() => deleteEmoji(emoji.document_id)}
|
||||
className="absolute top-2 right-2 p-1.5 text-red-400 hover:text-red hover:bg-red/10 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Upload, Trash2, Copy, ExternalLink, BarChart3, FileImage, FileVideo, Film } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function ManageStickerPack() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [pack, setPack] = useState(null);
|
||||
const [stickers, setStickers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
// Redirect if no ID
|
||||
useEffect(() => {
|
||||
if (!id || id === 'undefined' || id === 'null') {
|
||||
navigate('/stickerpacks', { replace: true });
|
||||
return;
|
||||
}
|
||||
}, [id, navigate]);
|
||||
|
||||
const [uploadForm, setUploadForm] = useState({
|
||||
file: null,
|
||||
emoji: '😀',
|
||||
creator_id: '2010001' // Default user ID
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (id && id !== 'undefined' && id !== 'null') {
|
||||
fetchPackData();
|
||||
fetchStats();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchPackData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/stickerpacks/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch pack');
|
||||
}
|
||||
const data = await response.json();
|
||||
setPack(data.pack);
|
||||
setStickers(data.stickers || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching pack:', error);
|
||||
toast.error('Failed to load sticker pack');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stickerpacks/${id}/stats`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
setUploadForm({ ...uploadForm, file: e.target.files[0] });
|
||||
};
|
||||
|
||||
const handleUpload = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!uploadForm.file || !uploadForm.emoji) {
|
||||
toast.error('Please select a file and enter an emoji');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
const ext = uploadForm.file.name.split('.').pop().toLowerCase();
|
||||
const allowedExtensions = ['tgs', 'webm', 'webp', 'png'];
|
||||
|
||||
if (!allowedExtensions.includes(ext)) {
|
||||
toast.error('Invalid file type. Allowed: .tgs, .webm, .webp, .png');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadForm.file);
|
||||
formData.append('emoji', uploadForm.emoji);
|
||||
formData.append('creator_id', uploadForm.creator_id);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stickerpacks/${id}/stickers`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Sticker uploaded successfully!');
|
||||
setUploadForm({ file: null, emoji: '😀', creator_id: uploadForm.creator_id });
|
||||
document.getElementById('file-input').value = '';
|
||||
fetchPackData();
|
||||
fetchStats();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
toast.error(`Upload failed: ${error.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading:', error);
|
||||
toast.error('Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSticker = async (stickerId) => {
|
||||
if (!confirm('Delete this sticker?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stickerpacks/${id}/stickers/${stickerId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Sticker deleted');
|
||||
fetchPackData();
|
||||
fetchStats();
|
||||
} else {
|
||||
toast.error('Failed to delete sticker');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting sticker:', error);
|
||||
toast.error('Failed to delete sticker');
|
||||
}
|
||||
};
|
||||
|
||||
const copyPackLink = () => {
|
||||
const shortName = pack.ShortName;
|
||||
const link = `https://t.me/addstickers/${shortName}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
toast.success('Pack link copied to clipboard!');
|
||||
};
|
||||
|
||||
const getStickerIcon = (mimeType) => {
|
||||
if (mimeType === 'application/x-tgsticker') {
|
||||
return <Film className="w-5 h-5 text-purple" />;
|
||||
} else if (mimeType === 'video/webm') {
|
||||
return <FileVideo className="w-5 h-5 text-blue" />;
|
||||
} else {
|
||||
return <FileImage className="w-5 h-5 text-success" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStickerTypeLabel = (mimeType) => {
|
||||
switch (mimeType) {
|
||||
case 'application/x-tgsticker':
|
||||
return 'Animated (TGS)';
|
||||
case 'video/webm':
|
||||
return 'Video (WebM)';
|
||||
case 'image/webp':
|
||||
return 'Static (WebP)';
|
||||
case 'image/png':
|
||||
return 'Static (PNG)';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pack) {
|
||||
return (
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-red-400">Pack not found</p>
|
||||
<Link to="/stickerpacks" className="text-purple hover:underline mt-4 inline-block">
|
||||
Back to Sticker Packs
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
to="/stickerpacks"
|
||||
className="inline-flex items-center text-fg-muted hover:text-purple mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Back to Sticker Packs
|
||||
</Link>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg">{pack.Title}</h1>
|
||||
<p className="text-fg-muted mt-1">@{pack.ShortName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyPackLink}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Copy className="w-5 h-5" />
|
||||
Copy Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-fg-muted">Total Stickers</div>
|
||||
<div className="text-2xl font-bold text-purple">{stats.total_stickers}</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-fg-muted">Animated (TGS)</div>
|
||||
<div className="text-2xl font-bold text-blue">{stats.sticker_types?.tgs || 0}</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-fg-muted">Video (WebM)</div>
|
||||
<div className="text-2xl font-bold text-yellow">{stats.sticker_types?.webm || 0}</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm text-fg-muted">Static (WebP/PNG)</div>
|
||||
<div className="text-2xl font-bold text-success">
|
||||
{(stats.sticker_types?.webp || 0) + (stats.sticker_types?.png || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Form */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-bold text-fg mb-6 flex items-center gap-2">
|
||||
<Upload className="w-6 h-6 text-purple" />
|
||||
Upload Sticker
|
||||
</h2>
|
||||
<form onSubmit={handleUpload} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Sticker File <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept=".tgs,.webm,.webp,.png"
|
||||
onChange={handleFileChange}
|
||||
className="input w-full p-2"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">
|
||||
Formats: TGS (animated), WebM (video), WebP/PNG (static)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Associated Emoji <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadForm.emoji}
|
||||
onChange={(e) => setUploadForm({ ...uploadForm, emoji: e.target.value })}
|
||||
className="input w-full focus:ring-2 focus:ring-purple/50"
|
||||
placeholder="😀"
|
||||
maxLength="2"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">
|
||||
Emoji that represents this sticker
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-2">
|
||||
Creator User ID <span className="text-red">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={uploadForm.creator_id}
|
||||
onChange={(e) => setUploadForm({ ...uploadForm, creator_id: e.target.value })}
|
||||
className="input w-full focus:ring-2 focus:ring-purple/50"
|
||||
placeholder="2010001"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-fg-muted mt-1">
|
||||
User ID who created the sticker
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-lg p-4 text-sm">
|
||||
<strong className="text-blue">Requirements:</strong>
|
||||
<ul className="mt-2 space-y-1 text-blue/80">
|
||||
<li>• Static (WebP/PNG): 512x512 pixels</li>
|
||||
<li>• Animated (TGS): 512x512 px, max 3 sec, 60 FPS</li>
|
||||
<li>• Video (WebM): VP9 codec, 512x512 px, max 3 sec, no audio</li>
|
||||
<li>• Max file size: 512 KB</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
className="btn btn-primary flex items-center justify-center gap-2 w-full md:w-auto px-8"
|
||||
>
|
||||
<Upload className="w-5 h-5" />
|
||||
{uploading ? 'Uploading...' : 'Upload Sticker'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Stickers List */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-bold text-fg mb-6">Stickers ({stickers.length})</h2>
|
||||
|
||||
{stickers.length === 0 ? (
|
||||
<div className="text-center py-12 text-fg-muted">
|
||||
<Upload className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No stickers yet. Upload your first sticker above!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{stickers.map((sticker) => (
|
||||
<div
|
||||
key={sticker.DocumentId}
|
||||
className="card border-border hover:border-purple transition-colors relative group p-3"
|
||||
>
|
||||
<div className="aspect-square bg-muted/30 rounded-lg mb-2 flex items-center justify-center">
|
||||
{getStickerIcon(sticker.MimeType)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-fg-muted mb-1 text-center">
|
||||
{getStickerTypeLabel(sticker.MimeType)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-fg-muted mb-2 text-center">
|
||||
{Math.round(sticker.Size / 1024)} KB
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-fg-muted mb-2 font-mono truncate text-center">
|
||||
ID: {sticker.DocumentId}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => deleteSticker(sticker.DocumentId)}
|
||||
className="w-full px-2 py-1 bg-red/10 text-red rounded hover:bg-red/20 text-xs flex items-center justify-center gap-1 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pack Link */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-bold text-fg mb-4">Share Pack</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={`https://t.me/addstickers/${pack.ShortName}`}
|
||||
readOnly
|
||||
className="input flex-1 bg-muted font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={copyPackLink}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<Copy className="w-5 h-5" />
|
||||
Copy
|
||||
</button>
|
||||
<a
|
||||
href={`https://t.me/addstickers/${pack.ShortName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Smile, Plus, Trash2, Edit, Loader, Upload } from 'lucide-react';
|
||||
import Lottie from 'lottie-react';
|
||||
|
||||
export default function Reactions() {
|
||||
const [reactions, setReactions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReactions();
|
||||
}, []);
|
||||
|
||||
const fetchReactions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/reactions');
|
||||
const data = await response.json();
|
||||
setReactions(data.reactions || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching reactions:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (reactionId) => {
|
||||
if (!confirm('Are you sure you want to delete this reaction?')) return;
|
||||
|
||||
try {
|
||||
setDeleting(reactionId);
|
||||
const response = await fetch(`/api/reactions/${reactionId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setReactions(reactions.filter(r => r.id !== reactionId));
|
||||
} else {
|
||||
alert('Failed to delete reaction');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting reaction:', error);
|
||||
alert('Error deleting reaction');
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader className="w-8 h-8 animate-spin text-purple-600" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-gradient-to-br from-purple-500 to-pink-600 p-3 rounded-lg">
|
||||
<Smile className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Message Reactions</h1>
|
||||
<p className="text-[#8b98a5]">Manage available reactions for messages</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/reactions/bulk-upload" className="btn btn-secondary flex items-center gap-2">
|
||||
<Upload className="w-5 h-5" />
|
||||
Bulk Upload ZIP
|
||||
</Link>
|
||||
<Link to="/reactions/create" className="btn btn-primary flex items-center gap-2">
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Reaction
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="card">
|
||||
<div className="text-sm text-[#8b98a5]">Total Reactions</div>
|
||||
<div className="text-2xl font-bold">{reactions.length}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-sm text-[#8b98a5]">Free Reactions</div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{reactions.filter(r => !r.premium).length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-sm text-[#8b98a5]">Premium Reactions</div>
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{reactions.filter(r => r.premium).length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-sm text-[#8b98a5]">Inactive</div>
|
||||
<div className="text-2xl font-bold text-[#8b98a5]">
|
||||
{reactions.filter(r => r.inactive).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reactions Grid */}
|
||||
{reactions.length === 0 ? (
|
||||
<div className="card text-center py-12">
|
||||
<Smile className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">No reactions yet</h3>
|
||||
<p className="text-[#8b98a5] mb-6">Create your first reaction or bulk upload from ZIP</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Link to="/reactions/bulk-upload" className="btn btn-secondary">
|
||||
Bulk Upload
|
||||
</Link>
|
||||
<Link to="/reactions/create" className="btn btn-primary">
|
||||
Create Reaction
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{reactions.map((reaction) => (
|
||||
<div key={reaction.id} className="card hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<div className="text-lg font-semibold">{reaction.emoji}</div>
|
||||
<div className="text-sm text-[#8b98a5]">{reaction.title}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/reactions/edit/${reaction.id}`}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit className="w-4 h-4 text-[#8b98a5]" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(reaction.id)}
|
||||
disabled={deleting === reaction.id}
|
||||
className="p-2 hover:bg-red-500/300/20 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{deleting === reaction.id ? (
|
||||
<Loader className="w-4 h-4 text-red-400 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{reaction.selectAnimation && (
|
||||
<div className="bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg p-4 mb-3 flex items-center justify-center">
|
||||
<Lottie
|
||||
animationData={reaction.selectAnimation}
|
||||
loop={true}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{reaction.premium && (
|
||||
<span className="px-2 py-1 bg-purple-100 text-purple-400 text-xs font-medium rounded">
|
||||
Premium
|
||||
</span>
|
||||
)}
|
||||
{reaction.inactive && (
|
||||
<span className="px-2 py-1 bg-gray-100 text-white text-xs font-medium rounded">
|
||||
Inactive
|
||||
</span>
|
||||
)}
|
||||
{reaction.hasAllAnimations && (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-400 text-xs font-medium rounded">
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Send, Gift, User, MessageSquare, EyeOff, CheckCircle2, AlertCircle, Search } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function SendGift() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const [gifts, setGifts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
giftId: '',
|
||||
userId: searchParams.get('userId') || '',
|
||||
fromUserId: '',
|
||||
message: '',
|
||||
nameHidden: false,
|
||||
count: 1
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchGifts()
|
||||
|
||||
// If userId is in URL params, show a toast
|
||||
const userIdFromUrl = searchParams.get('userId')
|
||||
if (userIdFromUrl) {
|
||||
toast.success(`User ID ${userIdFromUrl} pre-filled from URL`)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const searchUsers = async (query) => {
|
||||
if (!query || query.length < 2) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setSearching(true)
|
||||
try {
|
||||
// Try to search by phone or username
|
||||
const isPhone = /^\d+$/.test(query)
|
||||
const params = isPhone
|
||||
? `phone=${encodeURIComponent(query)}`
|
||||
: `username=${encodeURIComponent(query)}`
|
||||
|
||||
const response = await fetch(`/api/users/search?${params}`)
|
||||
const data = await response.json()
|
||||
setSearchResults(data)
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
const value = e.target.value
|
||||
setSearchQuery(value)
|
||||
searchUsers(value)
|
||||
}
|
||||
|
||||
const selectUser = (user) => {
|
||||
setFormData({ ...formData, userId: user.UserId.toString() })
|
||||
setSearchQuery('')
|
||||
setSearchResults([])
|
||||
toast.success(`Selected: ${user.FirstName} (ID: ${user.UserId})`)
|
||||
}
|
||||
|
||||
const fetchGifts = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/gifts')
|
||||
const data = await response.json()
|
||||
setGifts(data.filter(g => !g.SoldOut)) // Only available gifts
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load gifts')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!formData.giftId || !formData.userId) {
|
||||
toast.error('Gift and User ID are required')
|
||||
return
|
||||
}
|
||||
|
||||
setSending(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/gifts/${formData.giftId}/send-to-user`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: parseInt(formData.userId),
|
||||
fromUserId: formData.fromUserId ? parseInt(formData.fromUserId) : 0,
|
||||
message: formData.message || null,
|
||||
nameHidden: formData.nameHidden,
|
||||
count: parseInt(formData.count) || 1
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to send gift')
|
||||
}
|
||||
|
||||
// Show success with restart instructions
|
||||
toast.success(`🎁 Gift added to database!`)
|
||||
|
||||
if (data.instructions) {
|
||||
setTimeout(() => {
|
||||
toast((t) => (
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold">⚠️ Important: Restart Query Server</p>
|
||||
<p className="text-sm">To see the gift in user profile, run:</p>
|
||||
<code className="block bg-gray-800 text-white px-2 py-1 rounded text-xs mt-1">
|
||||
{data.instructions.command}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(data.instructions.command)
|
||||
toast.success('Command copied!')
|
||||
}}
|
||||
className="text-xs text-primary-600 hover:underline"
|
||||
>
|
||||
Copy command
|
||||
</button>
|
||||
</div>
|
||||
), {
|
||||
duration: 8000,
|
||||
icon: '🔄',
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
giftId: '',
|
||||
userId: '',
|
||||
fromUserId: '',
|
||||
message: '',
|
||||
nameHidden: false
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error.message)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedGift = gifts.find(g => g.GiftId === parseInt(formData.giftId))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">Send Gift to User</h1>
|
||||
<p className="mt-1 text-sm text-[#8b98a5]">
|
||||
Manually send a gift to any user (Admin function)
|
||||
</p>
|
||||
</div>
|
||||
<Send className="w-8 h-8 text-primary-500" />
|
||||
</div>
|
||||
|
||||
{/* Info Alert */}
|
||||
<div className="bg-blue-500/20 border border-blue-500/30 rounded-lg p-4 flex gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-300">
|
||||
<p className="font-medium text-blue-400">Admin Gift Delivery</p>
|
||||
<p className="mt-1">
|
||||
This will directly insert a gift into the user's received gifts collection.
|
||||
The gift will be automatically saved to their profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="card p-6 space-y-6">
|
||||
{/* Gift Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<Gift className="w-4 h-4 inline mr-2" />
|
||||
Select Gift
|
||||
</label>
|
||||
<select
|
||||
value={formData.giftId}
|
||||
onChange={(e) => setFormData({ ...formData, giftId: e.target.value })}
|
||||
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
required
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Choose a gift...</option>
|
||||
{gifts.map((gift) => (
|
||||
<option key={gift.GiftId} value={gift.GiftId}>
|
||||
#{gift.GiftId} - {gift.Title || 'Unnamed'} ({gift.Stars} ⭐)
|
||||
{gift.Limited && ` - Limited (${gift.AvailabilityRemains}/${gift.AvailabilityTotal})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Selected Gift Preview */}
|
||||
{selectedGift && (
|
||||
<div className="bg-gradient-to-br from-[#2b5278] to-[#3d5a7a] rounded-lg p-4 border border-[#5288c1]/30">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
{selectedGift.Title || `Gift #${selectedGift.GiftId}`}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-1 text-sm">
|
||||
<p className="text-white">💫 Price: <span className="font-medium">{selectedGift.Stars} Stars</span></p>
|
||||
<p className="text-white">💰 Convert Value: <span className="font-medium">{selectedGift.ConvertStars} Stars</span></p>
|
||||
{selectedGift.UpgradeStars && (
|
||||
<p className="text-white">⬆️ Upgrade: <span className="font-medium">{selectedGift.UpgradeStars} Stars</span></p>
|
||||
)}
|
||||
{selectedGift.Limited && (
|
||||
<p className="text-amber-700">🎯 Limited Edition: {selectedGift.AvailabilityRemains} remaining</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<Search className="w-4 h-4 inline mr-2" />
|
||||
Search User (Optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search by phone (79123456789) or username"
|
||||
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
{searching && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<div className="w-5 h-5 border-2 border-primary-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 card border border-[#2b5278] rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user._id}
|
||||
type="button"
|
||||
onClick={() => selectUser(user)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-[#0e1621] border-b last:border-b-0 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-white">
|
||||
{user.FirstName} {user.LastName}
|
||||
</p>
|
||||
<p className="text-xs text-[#8b98a5]">
|
||||
{user.UserName && `@${user.UserName} • `}
|
||||
{user.PhoneNumber}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-primary-600">
|
||||
ID: {user.UserId}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searchQuery && !searching && searchResults.length === 0 && (
|
||||
<p className="mt-2 text-sm text-amber-600">
|
||||
No users found. Try phone number or username.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<User className="w-4 h-4 inline mr-2" />
|
||||
Recipient User ID *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.userId}
|
||||
onChange={(e) => setFormData({ ...formData, userId: e.target.value })}
|
||||
placeholder="e.g. 2010001 (or search above)"
|
||||
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[#8b98a5]">
|
||||
The Telegram user ID who will receive this gift
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* From User ID */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<User className="w-4 h-4 inline mr-2" />
|
||||
From User ID (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.fromUserId}
|
||||
onChange={(e) => setFormData({ ...formData, fromUserId: e.target.value })}
|
||||
placeholder="Leave empty for system gift (0)"
|
||||
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[#8b98a5]">
|
||||
Who sent this gift (0 = System/Admin)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Count */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<Send className="w-4 h-4 inline mr-2" />
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.count}
|
||||
onChange={(e) => setFormData({ ...formData, count: e.target.value })}
|
||||
placeholder="1"
|
||||
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[#8b98a5]">
|
||||
Number of gifts to send (will charge stars per gift)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
<MessageSquare className="w-4 h-4 inline mr-2" />
|
||||
Gift Message (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
placeholder="Add a personal message with the gift..."
|
||||
rows={3}
|
||||
className="input focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
|
||||
maxLength={255}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-[#8b98a5]">
|
||||
{formData.message.length}/255 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Name Hidden */}
|
||||
<div className="flex items-center gap-3 p-4 bg-[#0e1621] rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="nameHidden"
|
||||
checked={formData.nameHidden}
|
||||
onChange={(e) => setFormData({ ...formData, nameHidden: e.target.checked })}
|
||||
className="w-4 h-4 text-primary-600 border-[#2b5278] rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label htmlFor="nameHidden" className="flex items-center gap-2 text-sm text-white cursor-pointer">
|
||||
<EyeOff className="w-4 h-4" />
|
||||
<span>Send Anonymously (hide sender name)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/gifts')}
|
||||
className="flex-1 px-6 py-3 border border-[#2b5278] text-white rounded-lg hover:bg-[#0e1621] font-medium transition-colors"
|
||||
disabled={sending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || !formData.giftId || !formData.userId}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-[#2b5278]0 to-primary-600 text-white rounded-lg hover:from-primary-600 hover:to-primary-700 font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{sending ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Send Gift
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Important Notice */}
|
||||
<div className="bg-amber-50 border-2 border-amber-300 rounded-xl p-6">
|
||||
<div className="flex gap-3">
|
||||
<AlertCircle className="w-6 h-6 text-amber-600 flex-shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-900 mb-2">⚠️ Important: Restart Required</h3>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
Gifts are inserted directly into the database. To make them visible in user profiles,
|
||||
you must <strong>restart the Query Server</strong>:
|
||||
</p>
|
||||
<code className="block bg-amber-900 text-amber-100 px-3 py-2 rounded text-sm font-mono">
|
||||
docker restart messenger-query-server-1
|
||||
</code>
|
||||
<p className="text-xs text-amber-700 mt-2">
|
||||
This is because MyTelegram uses Event Sourcing and caches ReadModel in memory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">💡 Tips & Tricks</h2>
|
||||
<ul className="space-y-2 text-sm text-[#8b98a5]">
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span><strong>Search users</strong> - Type phone number or @username to quickly find user ID</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span><strong>Auto-saved</strong> - Gifts sent via admin are automatically saved to user profile</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span><strong>Limited gifts</strong> - Availability counter will automatically decrease</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span><strong>System gifts</strong> - Leave "From User ID" empty to send as System (ID: 0)</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<span><strong>Find your ID</strong> - Search for yourself by phone to get your User ID</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Plus, Send, Edit, Trash2, Bell, AlertCircle, Info, FileText, CheckCircle2 } from 'lucide-react';
|
||||
import SendNotificationModal from '../components/SendNotificationModal';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function ServiceNotifications() {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sendModalOpen, setSendModalOpen] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`${API_URL}/api/service-notifications`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTemplates(data.templates);
|
||||
} else {
|
||||
toast.error('Failed to load templates');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
toast.error('Error loading templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Are you sure you want to delete this template?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/service-notifications/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Template deleted successfully');
|
||||
loadTemplates();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to delete template');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting template:', error);
|
||||
toast.error('Error deleting template');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (template) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/service-notifications/${template.Id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive: !template.IsActive })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success(`Template ${!template.IsActive ? 'activated' : 'deactivated'}`);
|
||||
loadTemplates();
|
||||
} else {
|
||||
toast.error(data.error || 'Failed to update template');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating template:', error);
|
||||
toast.error('Error updating template');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendClick = (template) => {
|
||||
setSelectedTemplate(template);
|
||||
setSendModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSendComplete = () => {
|
||||
setSendModalOpen(false);
|
||||
setSelectedTemplate(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<Bell className="w-8 h-8 text-purple" />
|
||||
Service Notifications
|
||||
</h1>
|
||||
<p className="mt-1 text-fg-muted">
|
||||
Manage system notifications and popups for users
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/service-notifications/create"
|
||||
className="btn btn-primary flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Template
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Templates List */}
|
||||
<div className="bg-card rounded-xl shadow-lg overflow-hidden border border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-border">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
|
||||
Title
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
|
||||
Message
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
|
||||
Display
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-semibold text-fg-muted uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-semibold text-fg-muted uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{templates.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-6 py-12 text-center text-fg-muted">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 bg-muted rounded-full flex items-center justify-center">
|
||||
<Bell className="w-6 h-6 text-fg-muted" />
|
||||
</div>
|
||||
<p>No templates found. Create your first notification template!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
templates.map((template) => (
|
||||
<tr key={template.Id} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2.5 py-1 text-xs font-medium bg-blue/10 text-blue border border-blue/20 rounded-lg">
|
||||
{template.Type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm font-medium text-fg">
|
||||
{template.Title}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-fg-muted max-w-md truncate" title={template.Message}>
|
||||
{template.Message}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg border ${template.IsPopup
|
||||
? 'bg-purple/10 text-purple border-purple/20'
|
||||
: 'bg-muted text-fg-muted border-border'
|
||||
}`}>
|
||||
{template.IsPopup ? <AlertCircle className="w-3 h-3" /> : <FileText className="w-3 h-3" />}
|
||||
{template.IsPopup ? 'Popup' : 'Message'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleToggleActive(template)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg border transition-colors ${template.IsActive
|
||||
? 'bg-success/10 text-success border-success/20 hover:bg-success/20'
|
||||
: 'bg-red/10 text-red border-red/20 hover:bg-red/20'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{template.IsActive ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleSendClick(template)}
|
||||
disabled={!template.IsActive}
|
||||
className="p-2 text-blue hover:bg-blue/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Send Notification"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
<Link
|
||||
to={`/service-notifications/edit/${template.Id}`}
|
||||
className="p-2 text-fg-muted hover:text-purple hover:bg-purple/10 rounded-lg transition-colors"
|
||||
title="Edit Template"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(template.Id)}
|
||||
className="p-2 text-red hover:bg-red/10 rounded-lg transition-colors"
|
||||
title="Delete Template"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue/5 border border-blue/20 rounded-xl p-5 flex items-start gap-4">
|
||||
<div className="p-2 bg-blue/10 rounded-lg shrink-0">
|
||||
<Info className="w-5 h-5 text-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-blue mb-2">About Service Notifications</h3>
|
||||
<ul className="text-sm text-fg-muted space-y-1.5">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue/50"></span>
|
||||
<span><strong className="text-fg">Popup:</strong> Shows as an alert/popup in the client (user must dismiss)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue/50"></span>
|
||||
<span><strong className="text-fg">Message:</strong> Saved as a message from "Telegram" (user 777000)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue/50"></span>
|
||||
<span><strong className="text-fg">Type:</strong> Used for deduplication (same type won't show twice within 15 minutes)</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue/50"></span>
|
||||
<span><strong className="text-fg">Examples:</strong> Premium ads, system announcements, new features</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Modal */}
|
||||
{sendModalOpen && selectedTemplate && (
|
||||
<SendNotificationModal
|
||||
template={selectedTemplate}
|
||||
onClose={handleSendComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Edit, Trash2, Power, BarChart3, Filter, X } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function SponsoredMessages() {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingMessage, setEditingMessage] = useState(null);
|
||||
const [filterChannelId, setFilterChannelId] = useState('');
|
||||
const [filterActive, setFilterActive] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
channelId: '',
|
||||
title: '',
|
||||
message: '',
|
||||
url: '',
|
||||
buttonText: 'Learn More',
|
||||
photoUrl: '',
|
||||
sponsorInfo: '',
|
||||
additionalInfo: '',
|
||||
isActive: true,
|
||||
recommended: false,
|
||||
canReport: true,
|
||||
postsBetween: 10,
|
||||
expiresDate: null
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, [filterChannelId, filterActive]);
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (filterChannelId) params.append('channelId', filterChannelId);
|
||||
if (filterActive !== '') params.append('isActive', filterActive);
|
||||
|
||||
const response = await fetch(`${API_URL}/api/sponsored-messages?${params}`);
|
||||
const data = await response.json();
|
||||
setMessages(data.messages || []);
|
||||
} catch (error) {
|
||||
console.error('Error fetching sponsored messages:', error);
|
||||
toast.error('Failed to load sponsored messages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setActionLoading(true);
|
||||
|
||||
try {
|
||||
const url = editingMessage
|
||||
? `${API_URL}/api/sponsored-messages/${editingMessage.Id}`
|
||||
: `${API_URL}/api/sponsored-messages`;
|
||||
|
||||
const method = editingMessage ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
channelId: parseInt(formData.channelId),
|
||||
postsBetween: parseInt(formData.postsBetween),
|
||||
expiresDate: formData.expiresDate ? Math.floor(new Date(formData.expiresDate).getTime() / 1000) : null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success(editingMessage ? 'Sponsored message updated!' : 'Sponsored message created!');
|
||||
setShowModal(false);
|
||||
resetForm();
|
||||
fetchMessages();
|
||||
} else {
|
||||
toast.error('Error: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving sponsored message:', error);
|
||||
toast.error('Failed to save sponsored message');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (message) => {
|
||||
setEditingMessage(message);
|
||||
setFormData({
|
||||
channelId: message.ChannelId?.toString() || '',
|
||||
title: message.Title || '',
|
||||
message: message.Message || '',
|
||||
url: message.Url || '',
|
||||
buttonText: message.ButtonText || '',
|
||||
photoUrl: message.PhotoUrl || '',
|
||||
sponsorInfo: message.SponsorInfo || '',
|
||||
additionalInfo: message.AdditionalInfo || '',
|
||||
isActive: message.IsActive,
|
||||
recommended: message.Recommended,
|
||||
canReport: message.CanReport,
|
||||
postsBetween: message.PostsBetween || 10,
|
||||
expiresDate: message.ExpiresDate ? new Date(message.ExpiresDate * 1000).toISOString().split('T')[0] : ''
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Are you sure you want to delete this sponsored message?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/sponsored-messages/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
toast.success('Sponsored message deleted!');
|
||||
fetchMessages();
|
||||
} else {
|
||||
toast.error('Error: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting sponsored message:', error);
|
||||
toast.error('Failed to delete sponsored message');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (id) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/sponsored-messages/${id}/toggle`, {
|
||||
method: 'PATCH'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
fetchMessages();
|
||||
toast.success('Status updated successfully');
|
||||
} else {
|
||||
toast.error('Error: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling status:', error);
|
||||
toast.error('Failed to toggle status');
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingMessage(null);
|
||||
setFormData({
|
||||
channelId: '',
|
||||
title: '',
|
||||
message: '',
|
||||
url: '',
|
||||
buttonText: 'Learn More',
|
||||
photoUrl: '',
|
||||
sponsorInfo: '',
|
||||
additionalInfo: '',
|
||||
isActive: true,
|
||||
recommended: false,
|
||||
canReport: true,
|
||||
postsBetween: 10,
|
||||
expiresDate: null
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
if (!timestamp) return 'Never';
|
||||
return new Date(timestamp * 1000).toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg">Sponsored Messages</h1>
|
||||
<p className="text-fg-muted mt-1">{messages.length} campaigns</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { resetForm(); setShowModal(true); }}
|
||||
className="btn btn-primary flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Campaign
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-4">
|
||||
<div className="flex flex-col md:flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
||||
<Filter className="w-5 h-5 text-fg-muted" />
|
||||
<span className="text-sm font-medium text-fg">Filters:</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Filter by Channel ID"
|
||||
className="input md:w-64 w-full"
|
||||
value={filterChannelId}
|
||||
onChange={(e) => setFilterChannelId(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="input md:w-auto w-full"
|
||||
value={filterActive}
|
||||
onChange={(e) => setFilterActive(e.target.value)}
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="true">Active</option>
|
||||
<option value="false">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages List */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.Id} className="card p-6 hover:border-purple transition-all duration-300">
|
||||
<div className="flex flex-col md:flex-row items-start justify-between gap-4">
|
||||
<div className="flex-1 w-full">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-bold text-fg">{msg.Title}</h3>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-semibold uppercase tracking-wider ${msg.IsActive
|
||||
? 'bg-success/10 text-success border border-success/20'
|
||||
: 'bg-muted text-fg-muted border border-border'
|
||||
}`}>
|
||||
{msg.IsActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{msg.Recommended && (
|
||||
<span className="px-2 py-0.5 bg-blue/10 text-blue border border-blue/20 rounded text-xs font-semibold uppercase tracking-wider">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-fg-muted mb-4 line-clamp-2">{msg.Message}</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm bg-muted/30 p-4 rounded-lg border border-border">
|
||||
<div>
|
||||
<span className="text-fg-muted block text-xs uppercase tracking-wide mb-1">Channel ID</span>
|
||||
<p className="font-medium text-fg font-mono">{msg.ChannelId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-fg-muted block text-xs uppercase tracking-wide mb-1">URL</span>
|
||||
<p className="font-medium truncate">
|
||||
<a href={msg.Url} target="_blank" rel="noopener noreferrer" className="text-purple hover:underline hover:text-purple/80">
|
||||
{msg.Url}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-fg-muted block text-xs uppercase tracking-wide mb-1">Frequency</span>
|
||||
<p className="font-medium text-fg">Every {msg.PostsBetween || 10} posts</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-fg-muted block text-xs uppercase tracking-wide mb-1">Expires</span>
|
||||
<p className="font-medium text-fg">{formatDate(msg.ExpiresDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-6 text-sm text-fg-muted">
|
||||
<span className="flex items-center gap-1.5" title="Displays">
|
||||
<BarChart3 className="w-4 h-4" /> {msg.DisplayCount || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5" title="Clicks">
|
||||
<span className="text-lg leading-none">🖱️</span> {msg.ClickCount || 0}
|
||||
</span>
|
||||
<span className="ml-auto text-xs">Created: {formatDate(msg.CreatedDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 self-start">
|
||||
<button
|
||||
onClick={() => handleToggleActive(msg.Id)}
|
||||
className={`p-2 rounded-lg border transition-colors ${msg.IsActive
|
||||
? 'border-yellow text-yellow hover:bg-yellow/10'
|
||||
: 'border-success text-success hover:bg-success/10'
|
||||
}`}
|
||||
title={msg.IsActive ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
<Power className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(msg)}
|
||||
className="p-2 rounded-lg border border-border text-fg-muted hover:text-purple hover:border-purple hover:bg-purple/5 transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(msg.Id)}
|
||||
className="p-2 rounded-lg border border-red text-red hover:bg-red/10 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center py-20 bg-card rounded-xl border border-border border-dashed">
|
||||
<div className="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Plus className="w-8 h-8 text-fg-muted" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-fg mb-1">No campaigns yet</h3>
|
||||
<p className="text-fg-muted mb-4">Create your first sponsored message campaign</p>
|
||||
<button
|
||||
onClick={() => { resetForm(); setShowModal(true); }}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Create Campaign
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4 animate-fade-in">
|
||||
<div className="bg-card border border-border text-fg rounded-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-scale-in">
|
||||
<div className="p-6 border-b border-border flex justify-between items-center sticky top-0 bg-card z-10">
|
||||
<h2 className="text-2xl font-heading font-bold">
|
||||
{editingMessage ? 'Edit Campaign' : 'Create Campaign'}
|
||||
</h2>
|
||||
<button onClick={() => setShowModal(false)} className="text-fg-muted hover:text-fg transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Channel ID <span className="text-red">*</span></label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
className="input w-full"
|
||||
value={formData.channelId}
|
||||
onChange={(e) => setFormData({ ...formData, channelId: e.target.value })}
|
||||
placeholder="-100..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Posts Between Ads</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
className="input w-full"
|
||||
value={formData.postsBetween}
|
||||
onChange={(e) => setFormData({ ...formData, postsBetween: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Title <span className="text-red">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength="100"
|
||||
className="input w-full"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="Campaign Title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Message <span className="text-red">*</span></label>
|
||||
<textarea
|
||||
required
|
||||
maxLength="500"
|
||||
rows="3"
|
||||
className="input w-full resize-none"
|
||||
value={formData.message}
|
||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
placeholder="Ad content..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">URL <span className="text-red">*</span></label>
|
||||
<input
|
||||
type="url"
|
||||
required
|
||||
className="input w-full"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData({ ...formData, url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Button Text <span className="text-red">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
maxLength="50"
|
||||
className="input w-full"
|
||||
value={formData.buttonText}
|
||||
onChange={(e) => setFormData({ ...formData, buttonText: e.target.value })}
|
||||
placeholder="e.g. Learn More"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Photo URL (optional)</label>
|
||||
<input
|
||||
type="url"
|
||||
className="input w-full"
|
||||
value={formData.photoUrl}
|
||||
onChange={(e) => setFormData({ ...formData, photoUrl: e.target.value })}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Expires Date (optional)</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input w-full"
|
||||
value={formData.expiresDate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, expiresDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Sponsor Info (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength="200"
|
||||
className="input w-full"
|
||||
value={formData.sponsorInfo}
|
||||
onChange={(e) => setFormData({ ...formData, sponsorInfo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-fg mb-1.5">Additional Info (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
maxLength="200"
|
||||
className="input w-full"
|
||||
value={formData.additionalInfo}
|
||||
onChange={(e) => setFormData({ ...formData, additionalInfo: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-6 pt-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-muted rounded-full peer peer-focus:ring-2 peer-focus:ring-purple/20 dark:peer-focus:ring-purple/40 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple"></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-fg group-hover:text-purple transition-colors">Active</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
checked={formData.recommended}
|
||||
onChange={(e) => setFormData({ ...formData, recommended: e.target.checked })}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-muted rounded-full peer peer-focus:ring-2 peer-focus:ring-purple/20 dark:peer-focus:ring-purple/40 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple"></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-fg group-hover:text-purple transition-colors">Recommended</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer group">
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
checked={formData.canReport}
|
||||
onChange={(e) => setFormData({ ...formData, canReport: e.target.checked })}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-muted rounded-full peer peer-focus:ring-2 peer-focus:ring-purple/20 dark:peer-focus:ring-purple/40 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple"></div>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-fg group-hover:text-purple transition-colors">Can Report</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-6 border-t border-border">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={actionLoading}
|
||||
className="btn btn-primary flex-1 py-2.5 font-semibold text-base"
|
||||
>
|
||||
{actionLoading ? 'Saving...' : (editingMessage ? 'Update Campaign' : 'Create Campaign')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionLoading}
|
||||
onClick={() => { setShowModal(false); resetForm(); }}
|
||||
className="btn btn-secondary px-6"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { TrendingUp, Gift, Star, Users } from 'lucide-react'
|
||||
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
|
||||
import { statsApi } from '../lib/api'
|
||||
|
||||
const COLORS = ['#f97316', '#3b82f6', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899']
|
||||
|
||||
export default function Statistics() {
|
||||
const [stats, setStats] = useState(null)
|
||||
const [sentStats, setSentStats] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [])
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const [overview, sent] = await Promise.all([
|
||||
statsApi.getOverview(),
|
||||
statsApi.getSentStats()
|
||||
])
|
||||
setStats(overview.data)
|
||||
setSentStats(sent.data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const giftDistribution = [
|
||||
{ name: 'Available', value: stats.availableGifts, color: COLORS[2] },
|
||||
{ name: 'Sold Out', value: stats.soldOutGifts, color: COLORS[0] },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black font-heading text-fg">Statistics</h1>
|
||||
<p className="text-fg-muted font-medium mt-1">Analytics and insights</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{[
|
||||
{ label: 'Total Gifts', value: stats.totalGifts, icon: Gift, color: 'text-blue-500' },
|
||||
{ label: 'Sent', value: stats.totalSentGifts, icon: TrendingUp, color: 'text-success' },
|
||||
{ label: 'Stars Earned', value: stats.totalStarsEarned?.toLocaleString(), icon: Star, color: 'text-yellow-500' },
|
||||
{ label: 'Limited', value: stats.limitedGifts, icon: Gift, color: 'text-purple-500' },
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="card group">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-fg-muted uppercase tracking-wider">{stat.label}</p>
|
||||
<p className="text-2xl font-black font-heading text-fg mt-1 group-hover:text-accent transition-colors">{stat.value}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-bg-app rounded-lg border border-border group-hover:border-accent group-hover:shadow-[0_0_10px_var(--accent-glow)] transition-all">
|
||||
<stat.icon className={`w-6 h-6 ${stat.color} group-hover:text-accent transition-colors`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Gift Distribution */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold font-heading text-fg mb-4">Gift Status Distribution</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={giftDistribution}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{giftDistribution.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} stroke="var(--bg-panel)" strokeWidth={2} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--fg)' }}
|
||||
itemStyle={{ color: 'var(--fg)' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Conversion Rate */}
|
||||
{sentStats?.conversion && (
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold font-heading text-fg mb-4">Gift Actions</h3>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-fg-muted font-medium">Saved</span>
|
||||
<span className="font-bold text-fg">{sentStats.conversion[0]?.saved || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-bg-app rounded-full h-2 border border-border overflow-hidden">
|
||||
<div
|
||||
className="bg-success h-full rounded-full shadow-[0_0_10px_var(--success)]"
|
||||
style={{ width: `${(sentStats.conversion[0]?.saved / sentStats.conversion[0]?.total * 100) || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-fg-muted font-medium">Converted</span>
|
||||
<span className="font-bold text-fg">{sentStats.conversion[0]?.converted || 0}</span>
|
||||
</div>
|
||||
<div className="w-full bg-bg-app rounded-full h-2 border border-border overflow-hidden">
|
||||
<div
|
||||
className="bg-accent h-full rounded-full shadow-[0_0_10px_var(--accent)]"
|
||||
style={{ width: `${(sentStats.conversion[0]?.converted / sentStats.conversion[0]?.total * 100) || 0}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Stats */}
|
||||
{sentStats?.byDay && sentStats.byDay.length > 0 && (
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold font-heading text-fg mb-4">Gifts Sent Over Time</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={sentStats.byDay.reverse()}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="_id" stroke="var(--fg-muted)" />
|
||||
<YAxis stroke="var(--fg-muted)" />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--fg)' }}
|
||||
labelStyle={{ color: 'var(--accent)' }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="count" stroke="var(--accent)" strokeWidth={3} dot={{ fill: 'var(--bg-app)', strokeWidth: 2 }} activeDot={{ r: 6, fill: 'var(--accent)' }} name="Gifts Sent" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Gifts */}
|
||||
{sentStats?.byGift && sentStats.byGift.length > 0 && (
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-bold font-heading text-fg mb-4">Most Sent Gifts</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={sentStats.byGift.slice(0, 10)}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="_id" stroke="var(--fg-muted)" />
|
||||
<YAxis stroke="var(--fg-muted)" />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: 'var(--bg-panel)', borderColor: 'var(--border)', color: 'var(--fg)' }}
|
||||
cursor={{ fill: 'var(--bg-app)' }}
|
||||
/>
|
||||
<Bar dataKey="count" fill="var(--accent)" radius={[4, 4, 0, 0]} name="Times Sent" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Sticker, Plus, Search, ExternalLink, Trash2, Edit, FileArchive, Star } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function StickerPacks() {
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks();
|
||||
}, [currentPage, searchTerm]);
|
||||
|
||||
const fetchPacks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/stickerpacks?page=${currentPage}&limit=20&search=${searchTerm}`
|
||||
);
|
||||
const data = await response.json();
|
||||
console.log('Sticker packs response:', data);
|
||||
setPacks(data.packs || []);
|
||||
setTotalPages(data.pagination?.totalPages || 1);
|
||||
} catch (error) {
|
||||
console.error('Error fetching sticker packs:', error);
|
||||
toast.error('Failed to load sticker packs');
|
||||
setPacks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deletePack = async (packId, packName) => {
|
||||
if (!confirm(`Delete sticker pack "${packName}"? This will delete all stickers in the pack.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stickerpacks/${packId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success('Pack deleted successfully');
|
||||
fetchPacks();
|
||||
} else {
|
||||
toast.error('Failed to delete pack');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting pack:', error);
|
||||
toast.error('Error deleting pack');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-heading font-bold text-fg flex items-center gap-3">
|
||||
<Sticker className="w-8 h-8 text-blue" />
|
||||
Sticker Packs
|
||||
</h1>
|
||||
<p className="text-fg-muted mt-1">Manage regular sticker sets (not custom emoji)</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
to="/stickerpacks/featured"
|
||||
className="btn btn-secondary text-yellow hover:text-yellow/80 hover:bg-yellow/10 border-yellow/20 flex items-center gap-2"
|
||||
>
|
||||
<Star className="w-5 h-5" />
|
||||
Featured
|
||||
</Link>
|
||||
<Link
|
||||
to="/stickerpacks/bulk-upload"
|
||||
className="btn bg-success/10 text-success hover:bg-success/20 border-success/20 flex items-center gap-2"
|
||||
>
|
||||
<FileArchive className="w-5 h-5" />
|
||||
Bulk ZIP
|
||||
</Link>
|
||||
<Link
|
||||
to="/stickerpacks/bulk-upload-stickers"
|
||||
className="btn bg-purple/10 text-purple hover:bg-purple/20 border-purple/20 flex items-center gap-2"
|
||||
>
|
||||
<FileArchive className="w-5 h-5" />
|
||||
Bulk TGS
|
||||
</Link>
|
||||
<Link
|
||||
to="/stickerpacks/create"
|
||||
className="btn btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Pack
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card p-4">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-3 w-5 h-5 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title or short name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
className="input pl-10 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packs Grid */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue"></div>
|
||||
</div>
|
||||
) : packs.length === 0 ? (
|
||||
<div className="text-center py-20 card">
|
||||
<Sticker className="w-16 h-16 text-fg-muted mx-auto mb-4" />
|
||||
<p className="text-fg-muted text-lg">No sticker packs found</p>
|
||||
<Link
|
||||
to="/stickerpacks/create"
|
||||
className="mt-6 btn btn-primary inline-flex"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Create First Pack
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{packs.map((pack) => (
|
||||
<div
|
||||
key={pack.StickerSetId}
|
||||
className="card p-6 hover:border-blue/50 transition-colors group"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-fg group-hover:text-blue transition-colors">
|
||||
{pack.Title}
|
||||
</h3>
|
||||
<p className="text-sm text-fg-muted">@{pack.ShortName}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
to={`/stickerpacks/${pack.StickerSetId}/edit`}
|
||||
className="p-2 text-fg-muted hover:text-blue hover:bg-blue/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => deletePack(pack.StickerSetId, pack.Title)}
|
||||
className="p-2 text-fg-muted hover:text-red hover:bg-red/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6 bg-muted/30 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-fg-muted">Stickers</span>
|
||||
<span className="font-semibold text-fg">{pack.Count || 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-fg-muted">Type</span>
|
||||
<span className="text-fg">
|
||||
{pack.Masks ? 'Mask Stickers' : 'Regular Stickers'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-fg-muted">Status</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${pack.Archived
|
||||
? 'bg-red/10 text-red'
|
||||
: 'bg-success/10 text-success'
|
||||
}`}>
|
||||
{pack.Archived ? 'Archived' : 'Active'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
to={`/stickerpacks/${pack.StickerSetId}`}
|
||||
className="flex-1 btn btn-secondary text-center justify-center"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
<a
|
||||
href={`https://t.me/addstickers/${pack.ShortName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-ghost px-3 text-fg-muted hover:text-blue"
|
||||
title="Open in Telegram"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 pt-6">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="btn btn-secondary disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="btn btn-ghost cursor-default">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="btn btn-secondary disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
.user-management {
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 10px 20px;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #2b5278;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
font-size: 18px;
|
||||
color: #8b98a5;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
background: #17212b;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.users-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.users-table tr.frozen {
|
||||
background: #fff3cd;
|
||||
}
|
||||
|
||||
.users-table tr.frozen:hover {
|
||||
background: #ffe69c;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.badge.bot {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.verified {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge.premium {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status.frozen {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status.deleted {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn.freeze {
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.btn.freeze:hover:not(:disabled) {
|
||||
background: #e0a800;
|
||||
}
|
||||
|
||||
.btn.unfreeze {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.unfreeze:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.btn.delete {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.delete:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.btn.cancel {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn.cancel:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 8px 16px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #17212b;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal p {
|
||||
margin-bottom: 20px;
|
||||
color: #8b98a5;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #2b5278;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, UserX, Trash2, Shield, ChevronLeft, ChevronRight, X, Star } from 'lucide-react';
|
||||
|
||||
function UserManagement() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [showFreezeModal, setShowFreezeModal] = useState(false);
|
||||
const [freezeReason, setFreezeReason] = useState('Account restricted for violating Terms of Service');
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [page, search]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/users?page=${page}&limit=20&search=${search}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setUsers(data.users);
|
||||
setTotalPages(data.pagination.pages);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error);
|
||||
alert('Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFreeze = (user) => {
|
||||
setSelectedUser(user);
|
||||
setShowFreezeModal(true);
|
||||
};
|
||||
|
||||
const confirmFreeze = async () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${selectedUser.UserId}/freeze`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: freezeReason })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(`User ${selectedUser.UserId} has been frozen`);
|
||||
setShowFreezeModal(false);
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('Failed to freeze user: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Freeze error:', error);
|
||||
alert('Failed to freeze user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnfreeze = async (user) => {
|
||||
if (!confirm(`Unfreeze user ${user.UserId} (${user.FirstName})?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.UserId}/unfreeze`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(`User ${user.UserId} has been unfrozen`);
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('Failed to unfreeze user: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Unfreeze error:', error);
|
||||
alert('Failed to unfreeze user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (user) => {
|
||||
if (!confirm(`Delete user ${user.UserId} (${user.FirstName})? This will mark account as deleted.`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.UserId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(`User ${user.UserId} has been deleted`);
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('Failed to delete user: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
alert('Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePremium = async (user) => {
|
||||
if (!confirm(`Remove Premium from ${user.FirstName} (${user.UserId})?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.UserId}/premium`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(`Premium removed from user ${user.UserId}`);
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('Failed to remove premium: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Remove premium error:', error);
|
||||
alert('Failed to remove premium');
|
||||
}
|
||||
};
|
||||
|
||||
const handleGivePremium = async (user) => {
|
||||
if (!confirm(`Give Premium to ${user.FirstName} (${user.UserId})?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${user.UserId}/premium`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert(`Premium given to user ${user.UserId}`);
|
||||
loadUsers();
|
||||
} else {
|
||||
alert('Failed to give premium: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Give premium error:', error);
|
||||
alert('Failed to give premium');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black font-heading text-fg">User Management</h1>
|
||||
<p className="text-fg-muted font-medium mt-1">Manage user accounts and permissions</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="card p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name, username, or phone..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="input pl-10 bg-bg-app border-border focus:border-accent w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-accent"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Users Table */}
|
||||
<div className="card overflow-hidden p-0 border border-border">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-bg-panel border-b border-border">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">User ID</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Username</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Phone</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">Premium</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-bold text-fg-muted uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-bg-app">
|
||||
{users.map(user => (
|
||||
<tr key={user.UserId} className="hover:bg-bg-panel/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-fg font-mono text-sm font-medium">{user.UserId}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-fg font-bold">{user.FirstName} {user.LastName}</span>
|
||||
{user.Bot && <span className="px-2 py-0.5 bg-blue-500/10 text-blue-400 border border-blue-500/20 text-[10px] rounded font-bold uppercase tracking-wider">BOT</span>}
|
||||
{user.Verified && <span className="text-accent" title="Verified">✓</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-accent font-medium text-sm">@{user.UserName || <span className="text-fg-muted font-normal">N/A</span>}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-fg-muted font-mono text-sm">{user.PhoneNumber}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{user.Restricted ? (
|
||||
<span className="px-2 py-0.5 bg-red-500/10 text-red-400 border border-red-500/20 text-[10px] rounded-full font-bold uppercase tracking-wider" title={user.RestrictionReason}>
|
||||
🔒 FROZEN
|
||||
</span>
|
||||
) : user.IsDeleted ? (
|
||||
<span className="px-2 py-0.5 bg-bg-panel text-fg-muted border border-border text-[10px] rounded-full font-bold uppercase tracking-wider">
|
||||
🗑️ DELETED
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 text-[10px] rounded-full font-bold uppercase tracking-wider">
|
||||
✅ ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{user.Premium ? (
|
||||
<span className="px-2 py-0.5 bg-yellow-500/10 text-yellow-400 border border-yellow-500/20 text-[10px] rounded-full font-bold uppercase tracking-wider">
|
||||
⭐ Premium
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 bg-bg-panel text-fg-muted border border-border text-[10px] rounded-full font-bold uppercase tracking-wider">
|
||||
Free
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{user.Premium ? (
|
||||
<button
|
||||
onClick={() => handleRemovePremium(user)}
|
||||
className="p-1.5 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-400 rounded-lg transition-colors"
|
||||
title="Remove Premium"
|
||||
>
|
||||
<div className="w-4 h-4 flex items-center justify-center font-bold text-xs">−</div>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleGivePremium(user)}
|
||||
className="p-1.5 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-400 rounded-lg transition-colors"
|
||||
title="Give Premium"
|
||||
>
|
||||
<div className="w-4 h-4 flex items-center justify-center font-bold text-xs">+</div>
|
||||
</button>
|
||||
)}
|
||||
{user.Restricted ? (
|
||||
<button
|
||||
onClick={() => handleUnfreeze(user)}
|
||||
className="px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors flex items-center gap-1 border border-emerald-500/10"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
Unfreeze
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleFreeze(user)}
|
||||
className="px-3 py-1.5 bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 rounded-lg text-xs font-bold uppercase tracking-wider transition-colors flex items-center gap-1 border border-orange-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={user.IsDeleted}
|
||||
>
|
||||
<UserX className="w-3.5 h-3.5" />
|
||||
Freeze
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(user)}
|
||||
className="p-1.5 text-fg-muted hover:text-red-500 hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={user.IsDeleted}
|
||||
title="Delete User"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between bg-bg-panel border border-border rounded-lg px-6 py-4">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 border border-border bg-bg-app hover:bg-bg-panel text-fg rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-fg font-medium">
|
||||
Page <span className="text-accent font-bold">{page}</span> of <span className="text-fg-muted">{totalPages}</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 border border-border bg-bg-app hover:bg-bg-panel text-fg rounded-lg flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Freeze Modal */}
|
||||
{showFreezeModal && (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 animate-fade-in" onClick={() => setShowFreezeModal(false)}>
|
||||
<div className="card w-full max-w-md mx-4 shadow-2xl shadow-black/50 border-border scale-100 animate-scale-in" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-border">
|
||||
<h2 className="text-xl font-black font-heading text-fg flex items-center gap-2">
|
||||
<UserX className="w-6 h-6 text-orange-400" />
|
||||
Freeze Account
|
||||
</h2>
|
||||
<button onClick={() => setShowFreezeModal(false)} className="text-fg-muted hover:text-fg transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-bg-app border border-border rounded-xl p-4 mb-6">
|
||||
<p className="text-fg-muted text-xs font-bold uppercase tracking-wider mb-1">User</p>
|
||||
<p className="text-fg font-bold text-lg">{selectedUser?.FirstName} {selectedUser?.LastName}</p>
|
||||
<p className="text-fg-muted text-sm mt-1">ID: <span className="text-accent font-mono">{selectedUser?.UserId}</span></p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="block text-fg-muted text-xs font-bold uppercase tracking-wider mb-2">Restriction Reason</label>
|
||||
<textarea
|
||||
value={freezeReason}
|
||||
onChange={(e) => setFreezeReason(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Enter reason for account restriction..."
|
||||
className="input w-full resize-none h-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={confirmFreeze}
|
||||
className="flex-1 btn bg-orange-500 hover:bg-orange-600 text-white shadow-[0_0_15px_rgba(249,115,22,0.3)] border-none"
|
||||
>
|
||||
Freeze Account
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowFreezeModal(false)}
|
||||
className="flex-1 btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UserManagement;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Users as UsersIcon, Search, Copy, Check, Bot, UserX, ChevronLeft, ChevronRight, Hash } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState([])
|
||||
const [channels, setChannels] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [copiedId, setCopiedId] = useState(null)
|
||||
const [showChannels, setShowChannels] = useState(false)
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (showChannels) {
|
||||
fetchChannels()
|
||||
} else {
|
||||
fetchUsers()
|
||||
}
|
||||
}, [pagination.page, searchQuery, showChannels])
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
search: searchQuery
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/users?${params}`)
|
||||
const data = await response.json()
|
||||
|
||||
setUsers(data.users)
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.pagination.total,
|
||||
totalPages: data.pagination.totalPages
|
||||
}))
|
||||
} catch (error) {
|
||||
toast.error('Failed to load users')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChannels = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/channels')
|
||||
const data = await response.json()
|
||||
setChannels(data.channels || [])
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.channels?.length || 0,
|
||||
totalPages: 1
|
||||
}))
|
||||
} catch (error) {
|
||||
toast.error('Failed to load channels')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (userId) => {
|
||||
navigator.clipboard.writeText(userId.toString())
|
||||
setCopiedId(userId)
|
||||
toast.success('User ID copied!')
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
}
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
}
|
||||
|
||||
const nextPage = () => {
|
||||
if (pagination.page < pagination.totalPages) {
|
||||
setPagination(prev => ({ ...prev, page: prev.page + 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
const prevPage = () => {
|
||||
if (pagination.page > 1) {
|
||||
setPagination(prev => ({ ...prev, page: prev.page - 1 }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black font-heading text-fg">
|
||||
{showChannels ? 'All Channels' : 'All Users'}
|
||||
</h1>
|
||||
<p className="mt-1 text-fg-muted font-medium">
|
||||
{pagination.total} {showChannels ? 'channels' : 'users'} in MyTelegram
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowChannels(!showChannels)
|
||||
setPagination(prev => ({ ...prev, page: 1 }))
|
||||
setSearchQuery('')
|
||||
}}
|
||||
className="btn btn-secondary flex items-center gap-2"
|
||||
>
|
||||
{showChannels ? <UsersIcon className="w-5 h-5" /> : <Hash className="w-5 h-5" />}
|
||||
{showChannels ? 'Show Users' : 'Show Channels'}
|
||||
</button>
|
||||
|
||||
<div className="p-2 bg-bg-app rounded-lg border border-border">
|
||||
{showChannels ? <Hash className="w-8 h-8 text-accent" /> : <UsersIcon className="w-8 h-8 text-accent" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="card p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 w-5 h-5 text-fg-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search by name, username, or phone..."
|
||||
className="input pl-10 bg-bg-app border-border focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="card overflow-hidden p-0 border border-border">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-8 h-8 border-4 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-fg-muted">
|
||||
<UserX className="w-12 h-12 mb-2" />
|
||||
<p>No users found</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-bg-panel border-b border-border">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
|
||||
{showChannels ? 'Channel ID' : 'User ID'}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
|
||||
{showChannels ? 'Title' : 'Name'}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
|
||||
Username
|
||||
</th>
|
||||
{!showChannels && (
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
|
||||
Phone
|
||||
</th>
|
||||
)}
|
||||
<th className="px-6 py-4 text-left text-xs font-bold text-fg-muted uppercase tracking-wider">
|
||||
{showChannels ? 'Members' : 'Type'}
|
||||
</th>
|
||||
<th className="px-6 py-4 text-right text-xs font-bold text-fg-muted uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border bg-bg-app">
|
||||
{showChannels ? channels.map((channel) => (
|
||||
<tr key={channel._id} className="hover:bg-bg-panel/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium text-fg">
|
||||
{channel.ChannelId}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(channel.ChannelId)}
|
||||
className="p-1 hover:bg-bg-panel rounded transition-colors group"
|
||||
title="Copy Channel ID"
|
||||
>
|
||||
{copiedId === channel.ChannelId ? (
|
||||
<Check className="w-3.5 h-3.5 text-success" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5 text-fg-muted group-hover:text-fg" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-bold text-fg">
|
||||
{channel.Title || <span className="text-fg-muted">Untitled</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-accent">
|
||||
{channel.UserName ? `@${channel.UserName}` : <span className="text-fg-muted font-normal">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-fg-muted">
|
||||
<span className="text-fg font-bold">{channel.MembersCount || 0}</span> members
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<a
|
||||
href={`/sponsored-messages?channelId=${channel.ChannelId}`}
|
||||
className="text-accent hover:text-accent-hover text-sm font-bold transition-colors"
|
||||
>
|
||||
Create Ad →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
)) : users.map((user) => (
|
||||
<tr key={user._id} className="hover:bg-bg-panel/50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium text-fg">
|
||||
{user.UserId}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => copyToClipboard(user.UserId)}
|
||||
className="p-1 hover:bg-bg-panel rounded transition-colors group"
|
||||
title="Copy User ID"
|
||||
>
|
||||
{copiedId === user.UserId ? (
|
||||
<Check className="w-3.5 h-3.5 text-success" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5 text-fg-muted group-hover:text-fg" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-bold text-fg">
|
||||
{user.FirstName} {user.LastName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-accent">
|
||||
{user.UserName ? `@${user.UserName}` : <span className="text-fg-muted font-normal">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-mono text-fg-muted">
|
||||
{user.PhoneNumber || <span className="text-fg-muted">—</span>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.Bot && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-500/10 text-blue-400 border border-blue-500/20 rounded text-[10px] font-bold uppercase tracking-wider">
|
||||
<Bot className="w-3 h-3" />
|
||||
Bot
|
||||
</span>
|
||||
)}
|
||||
{user.IsDeleted && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 bg-red-500/10 text-red-500 border border-red-500/20 rounded text-[10px] font-bold uppercase tracking-wider">
|
||||
<UserX className="w-3 h-3" />
|
||||
Deleted
|
||||
</span>
|
||||
)}
|
||||
{!user.Bot && !user.IsDeleted && (
|
||||
<span className="text-xs font-medium text-fg-muted px-2 py-0.5 rounded bg-bg-panel border border-border">User</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||
<a
|
||||
href={`/gifts/send?userId=${user.UserId}`}
|
||||
className="text-accent hover:text-accent-hover text-sm font-bold transition-colors"
|
||||
>
|
||||
Send Gift →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="bg-bg-panel px-6 py-4 border-t border-border flex items-center justify-between">
|
||||
<div className="text-sm text-fg-muted">
|
||||
Showing <span className="font-bold text-fg">{(pagination.page - 1) * pagination.limit + 1}</span> to{' '}
|
||||
<span className="font-bold text-fg">
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)}
|
||||
</span>{' '}
|
||||
of <span className="font-bold text-fg">{pagination.total}</span> users
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={prevPage}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-4 py-2 border border-border rounded-lg text-sm font-medium text-fg hover:bg-bg-app hover:border-accent/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span className="px-4 py-2 text-sm text-fg">
|
||||
Page <span className="font-bold text-accent">{pagination.page}</span> of{' '}
|
||||
<span className="font-medium">{pagination.totalPages}</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={nextPage}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
className="px-4 py-2 border border-border rounded-lg text-sm font-medium text-fg hover:bg-bg-app hover:border-accent/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center gap-2"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-accent/10 border border-accent/20 rounded-lg p-5 flex gap-4 animate-fade-in shadow-[0_0_15px_var(--accent-glow-subtle)]">
|
||||
<div className="p-2 bg-accent/20 rounded-lg h-fit">
|
||||
<span className="text-lg">💡</span>
|
||||
</div>
|
||||
<div className="text-sm text-fg">
|
||||
<p className="font-bold text-accent mb-2">Quick Actions</p>
|
||||
<ul className="space-y-2 text-fg-muted font-medium">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent"></span>
|
||||
Click <Copy className="w-3.5 h-3.5 inline text-fg" /> to copy User ID
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent"></span>
|
||||
Click "Send Gift →" to send a gift directly to this user
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent"></span>
|
||||
Use search to quickly find users by name, username, or phone
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Search, Shield, Upload, CheckCircle, XCircle, Plus, Edit2, Trash2 } from 'lucide-react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export default function Verification() {
|
||||
const [activeTab, setActiveTab] = useState('assign'); // assign or manage
|
||||
const [verifiers, setVerifiers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState(null);
|
||||
|
||||
// Assign verification state
|
||||
const [userId, setUserId] = useState('');
|
||||
const [selectedVerifier, setSelectedVerifier] = useState('');
|
||||
const [customDescription, setCustomDescription] = useState('');
|
||||
const [currentVerification, setCurrentVerification] = useState(null);
|
||||
|
||||
// Create verifier state
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [botUserId, setBotUserId] = useState('');
|
||||
const [iconFile, setIconFile] = useState(null);
|
||||
const [iconDocumentId, setIconDocumentId] = useState('');
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [canModifyDesc, setCanModifyDesc] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadVerifiers();
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const loadVerifiers = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/verification/bots`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setVerifiers(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading verifiers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/verification/stats`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setStats(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkUserVerification = async (uid) => {
|
||||
if (!uid) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/verification/users/${uid}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setCurrentVerification(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking verification:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file) => {
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('icon', file);
|
||||
|
||||
try {
|
||||
setUploadProgress(10);
|
||||
const response = await fetch(`${API_URL}/api/verification/upload-icon`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
setUploadProgress(90);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setIconDocumentId(data.data.documentId.toString());
|
||||
setUploadProgress(100);
|
||||
alert(`✅ Файл загружен! Document ID: ${data.data.documentId}`);
|
||||
setTimeout(() => setUploadProgress(0), 2000);
|
||||
} else {
|
||||
alert('❌ Ошибка загрузки: ' + data.error);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
alert('❌ Ошибка загрузки файла');
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const createVerifier = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!botUserId || !iconDocumentId || !companyName) {
|
||||
alert('Заполните все обязательные поля');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/verification/bots`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
botUserId: parseInt(botUserId),
|
||||
iconEmojiId: parseInt(iconDocumentId),
|
||||
companyName,
|
||||
canModifyCustomDescription: canModifyDesc
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('✅ Верификатор создан!');
|
||||
setShowCreateForm(false);
|
||||
setBotUserId('');
|
||||
setIconDocumentId('');
|
||||
setCompanyName('');
|
||||
setCanModifyDesc(false);
|
||||
setIconFile(null);
|
||||
loadVerifiers();
|
||||
loadStats();
|
||||
} else {
|
||||
alert('❌ Ошибка: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating verifier:', error);
|
||||
alert('❌ Ошибка создания верификатора');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assignVerification = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!userId || !selectedVerifier) {
|
||||
alert('Заполните User ID и выберите верификатора');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/verification/users/${userId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
botUserId: parseInt(selectedVerifier),
|
||||
customDescription: customDescription || null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('✅ Верификация выдана!');
|
||||
checkUserVerification(userId);
|
||||
setCustomDescription('');
|
||||
loadStats();
|
||||
} else {
|
||||
alert('❌ Ошибка: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error assigning verification:', error);
|
||||
alert('❌ Ошибка выдачи верификации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeVerification = async () => {
|
||||
if (!userId || !confirm('Удалить верификацию?')) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/verification/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('✅ Верификация удалена!');
|
||||
setCurrentVerification(null);
|
||||
loadStats();
|
||||
} else {
|
||||
alert('❌ Ошибка: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing verification:', error);
|
||||
alert('❌ Ошибка удаления верификации');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteVerifier = async (botUserId) => {
|
||||
if (!confirm(`Деактивировать верификатора ${botUserId}?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/verification/bots/${botUserId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('✅ Верификатор деактивирован!');
|
||||
loadVerifiers();
|
||||
loadStats();
|
||||
} else {
|
||||
alert('❌ Ошибка: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting verifier:', error);
|
||||
alert('❌ Ошибка деактивации');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-blue-400" />
|
||||
Third-Party Verification
|
||||
</h1>
|
||||
<p className="text-[#8b98a5] mt-1">Управление сторонней верификацией пользователей</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="card rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[#8b98a5] text-sm">Всего верификаторов</p>
|
||||
<p className="text-2xl font-bold text-white">{stats.totalVerifiers}</p>
|
||||
</div>
|
||||
<Shield className="w-8 h-8 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[#8b98a5] text-sm">Активных</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.activeVerifiers}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[#8b98a5] text-sm">Верифицировано пользователей</p>
|
||||
<p className="text-2xl font-bold text-purple-600">{stats.userVerifications}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[#8b98a5] text-sm">Верифицировано каналов</p>
|
||||
<p className="text-2xl font-bold text-indigo-600">{stats.channelVerifications}</p>
|
||||
</div>
|
||||
<CheckCircle className="w-8 h-8 text-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="card rounded-lg shadow">
|
||||
<div className="border-b border-[#2b5278]">
|
||||
<nav className="flex -mb-px">
|
||||
<button
|
||||
onClick={() => setActiveTab('assign')}
|
||||
className={`px-6 py-4 text-sm font-medium ${
|
||||
activeTab === 'assign'
|
||||
? 'border-b-2 border-blue-500 text-blue-400'
|
||||
: 'text-[#8b98a5] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Выдать верификацию
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('manage')}
|
||||
className={`px-6 py-4 text-sm font-medium ${
|
||||
activeTab === 'manage'
|
||||
? 'border-b-2 border-blue-500 text-blue-400'
|
||||
: 'text-[#8b98a5] hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Управление верификаторами
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'assign' ? (
|
||||
<div className="space-y-6">
|
||||
{/* Check user form */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
User ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
onBlur={(e) => checkUserVerification(e.target.value)}
|
||||
className="input flex-1"
|
||||
placeholder="Введите User ID"
|
||||
/>
|
||||
<button
|
||||
onClick={() => checkUserVerification(userId)}
|
||||
className="px-4 py-2 bg-[#5288c1] text-white rounded-lg hover:bg-[#3d5a7a] flex items-center gap-2"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
Проверить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current verification status */}
|
||||
{userId && (
|
||||
<div className={`p-4 rounded-lg ${currentVerification ? 'bg-green-500/20 border border-green-500/30' : 'bg-[#0e1621] border border-[#2b5278]'}`}>
|
||||
{currentVerification ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-green-400 font-medium">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Пользователь уже верифицирован
|
||||
</div>
|
||||
<div className="text-sm text-[#8b98a5] space-y-1">
|
||||
<p><strong>Bot ID:</strong> {currentVerification.BotVerifierId}</p>
|
||||
<p><strong>Icon ID:</strong> {currentVerification.IconEmojiId}</p>
|
||||
<p><strong>Компания (дефолт):</strong> {currentVerification.Description}</p>
|
||||
{currentVerification.CustomDescription && currentVerification.CustomDescription.trim() !== '' ? (
|
||||
<p><strong>Кастомное описание:</strong> <span className="text-green-400 font-medium">{currentVerification.CustomDescription}</span></p>
|
||||
) : (
|
||||
<p className="text-[#8b98a5] text-xs">Кастомное описание не задано</p>
|
||||
)}
|
||||
<p className="pt-2 border-t border-[#2b5278]">
|
||||
<strong>Клиент видит:</strong> <span className="text-purple-400 font-semibold">
|
||||
{currentVerification.CustomDescription && currentVerification.CustomDescription.trim() !== ''
|
||||
? currentVerification.CustomDescription
|
||||
: currentVerification.Description}
|
||||
</span>
|
||||
</p>
|
||||
<p><strong>Верифицирован:</strong> {new Date(currentVerification.VerifiedAt).toLocaleString('ru-RU')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={removeVerification}
|
||||
disabled={loading}
|
||||
className="mt-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Удалить верификацию
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-[#8b98a5]">
|
||||
<XCircle className="w-5 h-5" />
|
||||
Пользователь не верифицирован
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assign verification form */}
|
||||
{userId && !currentVerification && (
|
||||
<form onSubmit={assignVerification} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Выберите верификатора <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedVerifier}
|
||||
onChange={(e) => setSelectedVerifier(e.target.value)}
|
||||
className="input focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value="">-- Выберите верификатора --</option>
|
||||
{verifiers.map((v) => (
|
||||
<option key={v.BotUserId} value={v.BotUserId}>
|
||||
{v.CompanyName} (Bot ID: {v.BotUserId}, Icon: {v.IconEmojiId})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Кастомное описание (опционально)
|
||||
</label>
|
||||
<textarea
|
||||
value={customDescription}
|
||||
onChange={(e) => setCustomDescription(e.target.value)}
|
||||
className="input focus:ring-2 focus:ring-blue-500"
|
||||
rows={3}
|
||||
placeholder="Оставьте пустым для использования названия компании"
|
||||
disabled={!selectedVerifier || !verifiers.find(v => v.BotUserId === parseInt(selectedVerifier))?.CanModifyCustomDescription}
|
||||
/>
|
||||
{selectedVerifier && !verifiers.find(v => v.BotUserId === parseInt(selectedVerifier))?.CanModifyCustomDescription && (
|
||||
<p className="text-sm text-red-500 mt-1">⚠️ Этот верификатор не разрешает кастомные описания</p>
|
||||
)}
|
||||
{selectedVerifier && verifiers.find(v => v.BotUserId === parseInt(selectedVerifier))?.CanModifyCustomDescription && (
|
||||
<div className="mt-2 p-3 bg-blue-50 border border-blue-500/30 rounded-lg">
|
||||
<p className="text-sm text-blue-300">
|
||||
<strong>Клиент увидит:</strong> {customDescription && customDescription.trim() !== ''
|
||||
? <span className="text-purple-400 font-semibold">{customDescription}</span>
|
||||
: <span className="text-white">{verifiers.find(v => v.BotUserId === parseInt(selectedVerifier))?.CompanyName}</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:bg-[#2b5278]/50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
{loading ? 'Выдаю...' : 'Выдать верификацию'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Create verifier button */}
|
||||
{!showCreateForm && (
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="px-6 py-3 bg-[#5288c1] text-white rounded-lg hover:bg-[#3d5a7a] flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Создать верификатора
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Create verifier form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-blue-500/20 border border-blue-500/30 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Создание нового верификатора</h3>
|
||||
<form onSubmit={createVerifier} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Bot User ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={botUserId}
|
||||
onChange={(e) => setBotUserId(e.target.value)}
|
||||
className="input focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="ID бота-верификатора"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
TGS иконка <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input
|
||||
type="file"
|
||||
accept=".tgs,.json"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setIconFile(file);
|
||||
handleFileUpload(file);
|
||||
}
|
||||
}}
|
||||
className="input flex-1"
|
||||
/>
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="text-sm text-blue-400">Загрузка: {uploadProgress}%</div>
|
||||
)}
|
||||
</div>
|
||||
{iconDocumentId && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-sm text-green-600">✅ Document ID: {iconDocumentId}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/verification/icons/${iconDocumentId}/check`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
const status = data.data;
|
||||
alert(
|
||||
`📊 Проверка документа ${iconDocumentId}:\n\n` +
|
||||
`MongoDB: ${status.existsInMongoDB ? '✅ Найден' : '❌ Не найден'}\n` +
|
||||
`MinIO: ${status.existsInMinIO ? '✅ Найден' : '❌ Не найден'}\n` +
|
||||
(status.document ? `\nРазмер: ${status.document.Size} байт\nMimeType: ${status.document.MimeType}` : '')
|
||||
);
|
||||
} else {
|
||||
alert('❌ Ошибка проверки: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('❌ Ошибка: ' + error.message);
|
||||
}
|
||||
}}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
🔍 Проверить документ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Icon Document ID <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={iconDocumentId}
|
||||
onChange={(e) => setIconDocumentId(e.target.value)}
|
||||
className="input focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Заполнится автоматически после загрузки"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-2">
|
||||
Название компании <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
className="input focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Например: Verified Company"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={canModifyDesc}
|
||||
onChange={(e) => setCanModifyDesc(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-400 border-[#2b5278] rounded focus:ring-blue-500"
|
||||
/>
|
||||
<label className="text-sm text-white">
|
||||
Разрешить изменение кастомного описания
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-6 py-3 bg-green-500 text-white rounded-lg hover:bg-green-600 disabled:bg-[#2b5278]/50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Создаю...' : 'Создать'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setBotUserId('');
|
||||
setIconDocumentId('');
|
||||
setCompanyName('');
|
||||
setCanModifyDesc(false);
|
||||
setIconFile(null);
|
||||
}}
|
||||
className="px-6 py-3 bg-[#2b5278] text-white rounded-lg hover:bg-[#3d5a7a]"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verifiers list */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Список верификаторов</h3>
|
||||
{verifiers.length === 0 ? (
|
||||
<p className="text-[#8b98a5] text-center py-8">Нет верификаторов</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{verifiers.map((verifier) => (
|
||||
<div
|
||||
key={verifier.BotUserId}
|
||||
className="card border border-[#2b5278] rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-blue-400" />
|
||||
<div>
|
||||
<h4 className="font-semibold text-white">{verifier.CompanyName}</h4>
|
||||
<p className="text-sm text-[#8b98a5]">Bot User ID: {verifier.BotUserId}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-[#8b98a5] space-y-1">
|
||||
<p><strong>Icon Emoji ID:</strong> {verifier.IconEmojiId}</p>
|
||||
<p><strong>Кастомное описание:</strong> {verifier.CanModifyCustomDescription ? '✅ Разрешено' : '❌ Запрещено'}</p>
|
||||
<p><strong>Создан:</strong> {new Date(verifier.CreatedAt).toLocaleString('ru-RU')}</p>
|
||||
{verifier.UpdatedAt && (
|
||||
<p><strong>Обновлен:</strong> {new Date(verifier.UpdatedAt).toLocaleString('ru-RU')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => deleteVerifier(verifier.BotUserId)}
|
||||
className="p-2 text-red-400 hover:bg-red-500/300/20 rounded-lg"
|
||||
title="Деактивировать"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { create } from 'zustand'
|
||||
import { giftsApi } from '../lib/api'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
const useGiftsStore = create((set, get) => ({
|
||||
gifts: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
filters: {
|
||||
soldOut: undefined,
|
||||
limited: undefined,
|
||||
sort: 'stars',
|
||||
},
|
||||
|
||||
setFilters: (filters) => set((state) => ({
|
||||
filters: { ...state.filters, ...filters }
|
||||
})),
|
||||
|
||||
fetchGifts: async () => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const response = await giftsApi.getAll(get().filters)
|
||||
set({ gifts: response.data, loading: false })
|
||||
} catch (error) {
|
||||
set({ error: error.message, loading: false })
|
||||
toast.error('Failed to load gifts')
|
||||
}
|
||||
},
|
||||
|
||||
createGift: async (formData) => {
|
||||
try {
|
||||
const response = await giftsApi.create(formData)
|
||||
set((state) => ({ gifts: [...state.gifts, response.data] }))
|
||||
toast.success('Gift created successfully!')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'Failed to create gift')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
updateGift: async (giftId, formData) => {
|
||||
try {
|
||||
const response = await giftsApi.update(giftId, formData)
|
||||
set((state) => ({
|
||||
gifts: state.gifts.map((g) =>
|
||||
g.GiftId === giftId ? response.data : g
|
||||
),
|
||||
}))
|
||||
toast.success('Gift updated successfully!')
|
||||
return response.data
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.error || 'Failed to update gift')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
deleteGift: async (giftId) => {
|
||||
try {
|
||||
await giftsApi.delete(giftId)
|
||||
set((state) => ({
|
||||
gifts: state.gifts.filter((g) => g.GiftId !== giftId),
|
||||
}))
|
||||
toast.success('Gift deleted successfully!')
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete gift')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
toggleSoldOut: async (giftId, soldOut) => {
|
||||
try {
|
||||
const response = await giftsApi.markSoldOut(giftId, soldOut)
|
||||
set((state) => ({
|
||||
gifts: state.gifts.map((g) =>
|
||||
g.GiftId === giftId ? response.data : g
|
||||
),
|
||||
}))
|
||||
toast.success(soldOut ? 'Marked as sold out' : 'Marked as available')
|
||||
} catch (error) {
|
||||
toast.error('Failed to update gift status')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
export default useGiftsStore
|
||||
@@ -0,0 +1,48 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
heading: ['Outfit', 'sans-serif'],
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
bg: {
|
||||
app: 'var(--bg-app)',
|
||||
side: 'var(--bg-side)',
|
||||
panel: 'var(--bg-panel)',
|
||||
},
|
||||
fg: {
|
||||
DEFAULT: 'var(--fg)',
|
||||
muted: 'var(--fg-muted)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'var(--accent)',
|
||||
glow: 'var(--accent-glow)',
|
||||
},
|
||||
border: 'var(--border)',
|
||||
glass: 'var(--glass)',
|
||||
success: 'var(--success)',
|
||||
// Keep primary as an alias to accent for compatibility or gradual migration
|
||||
primary: {
|
||||
DEFAULT: 'var(--accent)',
|
||||
50: '#ecfeff',
|
||||
100: '#cffafe',
|
||||
200: '#a5f3fc',
|
||||
300: '#67e8f9',
|
||||
400: '#22d3ee',
|
||||
500: '#06b6d4',
|
||||
600: '#0891b2',
|
||||
700: '#0e7490',
|
||||
800: '#155e75',
|
||||
900: '#164e63',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Generated
+6878
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "stargift-admin-workspace",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"install:all": "npm install && npm install --workspace=backend && npm install --workspace=frontend",
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"dev:backend": "npm run dev --workspace=backend",
|
||||
"dev:frontend": "npm run dev --workspace=frontend",
|
||||
"build": "npm run build --workspace=frontend",
|
||||
"start": "npm run start --workspace=backend"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user