mirror of
https://github.com/opengram-server/opengram.git
synced 2026-07-21 21:20:16 +03:00
Initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user