mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge pull request #218 from BEDOLAGA-DEV/dev
feat: add admin pinned messages section
This commit is contained in:
32
src/App.tsx
32
src/App.tsx
@@ -73,6 +73,8 @@ const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
||||
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
|
||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
||||
const AdminPinnedMessages = lazy(() => import('./pages/AdminPinnedMessages'));
|
||||
const AdminPinnedMessageCreate = lazy(() => import('./pages/AdminPinnedMessageCreate'));
|
||||
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
@@ -687,6 +689,36 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/pinned-messages"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPinnedMessages />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/pinned-messages/create"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPinnedMessageCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/pinned-messages/:id/edit"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPinnedMessageCreate />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/email-templates/preview/:type/:lang"
|
||||
element={
|
||||
|
||||
175
src/api/adminPinnedMessages.ts
Normal file
175
src/api/adminPinnedMessages.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface PinnedMessageMedia {
|
||||
type: 'photo' | 'video';
|
||||
file_id: string;
|
||||
}
|
||||
|
||||
export interface PinnedMessageResponse {
|
||||
id: number;
|
||||
content: string;
|
||||
media_type: 'photo' | 'video' | null;
|
||||
media_file_id: string | null;
|
||||
send_before_menu: boolean;
|
||||
send_on_every_start: boolean;
|
||||
is_active: boolean;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface PinnedMessageListResponse {
|
||||
items: PinnedMessageResponse[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface PinnedMessageCreateRequest {
|
||||
content: string;
|
||||
media?: PinnedMessageMedia;
|
||||
send_before_menu: boolean;
|
||||
send_on_every_start: boolean;
|
||||
broadcast: boolean;
|
||||
}
|
||||
|
||||
export interface PinnedMessageUpdateRequest {
|
||||
content?: string;
|
||||
media?: PinnedMessageMedia;
|
||||
send_before_menu?: boolean;
|
||||
send_on_every_start?: boolean;
|
||||
}
|
||||
|
||||
export interface PinnedMessageBroadcastResponse {
|
||||
message: PinnedMessageResponse;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
export interface PinnedMessageUnpinResponse {
|
||||
unpinned_count: number;
|
||||
failed_count: number;
|
||||
was_active: boolean;
|
||||
}
|
||||
|
||||
export interface MediaUploadResponse {
|
||||
media_type: string;
|
||||
file_id: string;
|
||||
file_unique_id: string | null;
|
||||
media_url: string;
|
||||
}
|
||||
|
||||
export const adminPinnedMessagesApi = {
|
||||
// Get list of pinned messages with pagination
|
||||
list: async (limit = 20, offset = 0, activeOnly = false): Promise<PinnedMessageListResponse> => {
|
||||
const response = await apiClient.get<PinnedMessageListResponse>(
|
||||
'/cabinet/admin/pinned-messages',
|
||||
{
|
||||
params: { limit, offset, active_only: activeOnly },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get current active message
|
||||
getActive: async (): Promise<PinnedMessageResponse> => {
|
||||
const response = await apiClient.get<PinnedMessageResponse>(
|
||||
'/cabinet/admin/pinned-messages/active',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get pinned message by ID
|
||||
get: async (id: number): Promise<PinnedMessageResponse> => {
|
||||
const response = await apiClient.get<PinnedMessageResponse>(
|
||||
`/cabinet/admin/pinned-messages/${id}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create pinned message
|
||||
create: async (data: PinnedMessageCreateRequest): Promise<PinnedMessageResponse> => {
|
||||
const response = await apiClient.post<PinnedMessageResponse>(
|
||||
'/cabinet/admin/pinned-messages',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update pinned message
|
||||
update: async (id: number, data: PinnedMessageUpdateRequest): Promise<PinnedMessageResponse> => {
|
||||
const response = await apiClient.patch<PinnedMessageResponse>(
|
||||
`/cabinet/admin/pinned-messages/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update settings only
|
||||
updateSettings: async (
|
||||
id: number,
|
||||
data: Pick<PinnedMessageUpdateRequest, 'send_before_menu' | 'send_on_every_start'>,
|
||||
): Promise<PinnedMessageResponse> => {
|
||||
const response = await apiClient.patch<PinnedMessageResponse>(
|
||||
`/cabinet/admin/pinned-messages/${id}/settings`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Deactivate active message
|
||||
deactivate: async (): Promise<PinnedMessageResponse> => {
|
||||
const response = await apiClient.post<PinnedMessageResponse>(
|
||||
'/cabinet/admin/pinned-messages/active/deactivate',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Unpin from all users + deactivate
|
||||
unpin: async (): Promise<PinnedMessageUnpinResponse> => {
|
||||
const response = await apiClient.post<PinnedMessageUnpinResponse>(
|
||||
'/cabinet/admin/pinned-messages/active/unpin',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Activate a message
|
||||
activate: async (id: number, broadcast = false): Promise<PinnedMessageResponse> => {
|
||||
const response = await apiClient.post<PinnedMessageResponse>(
|
||||
`/cabinet/admin/pinned-messages/${id}/activate`,
|
||||
null,
|
||||
{ params: { broadcast } },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Broadcast to all users
|
||||
broadcast: async (id: number): Promise<PinnedMessageBroadcastResponse> => {
|
||||
const response = await apiClient.post<PinnedMessageBroadcastResponse>(
|
||||
`/cabinet/admin/pinned-messages/${id}/broadcast`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete pinned message (only inactive)
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/pinned-messages/${id}`);
|
||||
},
|
||||
|
||||
// Upload media (uses existing media endpoint)
|
||||
uploadMedia: async (file: File, mediaType: 'photo' | 'video'): Promise<MediaUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('media_type', mediaType);
|
||||
|
||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default adminPinnedMessagesApi;
|
||||
@@ -768,7 +768,8 @@
|
||||
"promocodes": "Promo Codes",
|
||||
"promoGroups": "Discount Groups",
|
||||
"trafficUsage": "Traffic Usage",
|
||||
"updates": "Updates"
|
||||
"updates": "Updates",
|
||||
"pinnedMessages": "Pinned Messages"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Admin Panel",
|
||||
@@ -792,7 +793,8 @@
|
||||
"promocodesDesc": "Manage promo codes",
|
||||
"promoGroupsDesc": "Discount groups for users",
|
||||
"trafficUsageDesc": "Per-user traffic by nodes",
|
||||
"updatesDesc": "Bot & cabinet versions"
|
||||
"updatesDesc": "Bot & cabinet versions",
|
||||
"pinnedMessagesDesc": "Manage pinned messages for users"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Traffic Usage",
|
||||
@@ -1157,6 +1159,48 @@
|
||||
"bothCreated": "Broadcasts created",
|
||||
"atLeastOneChannel": "Select at least one channel"
|
||||
},
|
||||
"pinnedMessages": {
|
||||
"title": "Pinned Messages",
|
||||
"subtitle": "Manage pinned messages for users",
|
||||
"create": "Create",
|
||||
"empty": "No pinned messages",
|
||||
"content": "Message text",
|
||||
"contentPlaceholder": "Enter pinned message text...",
|
||||
"noContent": "No text",
|
||||
"media": "Media file",
|
||||
"addMedia": "Add media",
|
||||
"uploading": "Uploading...",
|
||||
"settings": "Settings",
|
||||
"sendBeforeMenu": "Send before menu",
|
||||
"sendOnEveryStart": "Send on every start",
|
||||
"broadcast": "Broadcast",
|
||||
"broadcastOnCreate": "Broadcast on create",
|
||||
"broadcastConfirm": "Broadcast pinned message to all active users?",
|
||||
"broadcastToAll": "Broadcast to all",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"unpin": "Unpin",
|
||||
"unpinAll": "Unpin for everyone",
|
||||
"unpinConfirm": "Unpin message for all users?",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete this pinned message?",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"createSuccess": "Pinned message created",
|
||||
"updateSuccess": "Message updated",
|
||||
"deleteSuccess": "Message deleted",
|
||||
"activateSuccess": "Message activated",
|
||||
"deactivateSuccess": "Message deactivated",
|
||||
"broadcastSuccess": "Broadcast completed",
|
||||
"unpinSuccess": "Messages unpinned",
|
||||
"sentCount": "Sent",
|
||||
"failedCount": "Failed",
|
||||
"unpinnedCount": "Unpinned",
|
||||
"editMessage": "Edit",
|
||||
"cantDeleteActive": "Cannot delete active message"
|
||||
},
|
||||
"settings": {
|
||||
"title": "System Settings",
|
||||
"allCategories": "All categories",
|
||||
|
||||
@@ -649,7 +649,8 @@
|
||||
"remnawave": "RemnaWave",
|
||||
"users": "کاربران",
|
||||
"trafficUsage": "مصرف ترافیک",
|
||||
"updates": "بهروزرسانیها"
|
||||
"updates": "بهروزرسانیها",
|
||||
"pinnedMessages": "پیامهای سنجاقشده"
|
||||
},
|
||||
"panel": {
|
||||
"title": "پنل مدیریت",
|
||||
@@ -673,7 +674,8 @@
|
||||
"remnawaveDesc": "مدیریت پنل و آمار",
|
||||
"usersDesc": "مدیریت کاربران ربات",
|
||||
"trafficUsageDesc": "ترافیک هر کاربر بر اساس نود",
|
||||
"updatesDesc": "نسخههای ربات و کابینت"
|
||||
"updatesDesc": "نسخههای ربات و کابینت",
|
||||
"pinnedMessagesDesc": "مدیریت پیامهای سنجاقشده"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "مصرف ترافیک",
|
||||
@@ -861,6 +863,48 @@
|
||||
"bothCreated": "پیامرسانیها ایجاد شدند",
|
||||
"atLeastOneChannel": "حداقل یک کانال را انتخاب کنید"
|
||||
},
|
||||
"pinnedMessages": {
|
||||
"title": "Pinned Messages",
|
||||
"subtitle": "Manage pinned messages for users",
|
||||
"create": "Create",
|
||||
"empty": "No pinned messages",
|
||||
"content": "Message text",
|
||||
"contentPlaceholder": "Enter pinned message text...",
|
||||
"noContent": "No text",
|
||||
"media": "Media file",
|
||||
"addMedia": "Add media",
|
||||
"uploading": "Uploading...",
|
||||
"settings": "Settings",
|
||||
"sendBeforeMenu": "Send before menu",
|
||||
"sendOnEveryStart": "Send on every start",
|
||||
"broadcast": "Broadcast",
|
||||
"broadcastOnCreate": "Broadcast on create",
|
||||
"broadcastConfirm": "Broadcast pinned message to all active users?",
|
||||
"broadcastToAll": "Broadcast to all",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"unpin": "Unpin",
|
||||
"unpinAll": "Unpin for everyone",
|
||||
"unpinConfirm": "Unpin message for all users?",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete this pinned message?",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"createSuccess": "Pinned message created",
|
||||
"updateSuccess": "Message updated",
|
||||
"deleteSuccess": "Message deleted",
|
||||
"activateSuccess": "Message activated",
|
||||
"deactivateSuccess": "Message deactivated",
|
||||
"broadcastSuccess": "Broadcast completed",
|
||||
"unpinSuccess": "Messages unpinned",
|
||||
"sentCount": "Sent",
|
||||
"failedCount": "Failed",
|
||||
"unpinnedCount": "Unpinned",
|
||||
"editMessage": "Edit",
|
||||
"cantDeleteActive": "Cannot delete active message"
|
||||
},
|
||||
"payments": {
|
||||
"title": "تأیید پرداخت",
|
||||
"description": "پرداختهای معلق در ۲۴ ساعت گذشته",
|
||||
|
||||
@@ -789,7 +789,8 @@
|
||||
"promocodes": "Промокоды",
|
||||
"promoGroups": "Группы скидок",
|
||||
"trafficUsage": "Расход трафика",
|
||||
"updates": "Обновления"
|
||||
"updates": "Обновления",
|
||||
"pinnedMessages": "Закреплённые"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -813,7 +814,8 @@
|
||||
"promocodesDesc": "Управление промокодами",
|
||||
"promoGroupsDesc": "Группы скидок для пользователей",
|
||||
"trafficUsageDesc": "Трафик пользователей по нодам",
|
||||
"updatesDesc": "Версии бота и кабинета"
|
||||
"updatesDesc": "Версии бота и кабинета",
|
||||
"pinnedMessagesDesc": "Управление закреплёнными сообщениями"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Расход трафика",
|
||||
@@ -1182,6 +1184,48 @@
|
||||
"bothCreated": "Рассылки созданы",
|
||||
"atLeastOneChannel": "Выберите хотя бы один канал"
|
||||
},
|
||||
"pinnedMessages": {
|
||||
"title": "Закреплённые сообщения",
|
||||
"subtitle": "Управление закреплёнными сообщениями для пользователей",
|
||||
"create": "Создать",
|
||||
"empty": "Нет закреплённых сообщений",
|
||||
"content": "Текст сообщения",
|
||||
"contentPlaceholder": "Введите текст закреплённого сообщения...",
|
||||
"noContent": "Без текста",
|
||||
"media": "Медиафайл",
|
||||
"addMedia": "Добавить медиа",
|
||||
"uploading": "Загрузка...",
|
||||
"settings": "Настройки",
|
||||
"sendBeforeMenu": "Отправлять перед меню",
|
||||
"sendOnEveryStart": "Отправлять при каждом старте",
|
||||
"broadcast": "Разослать",
|
||||
"broadcastOnCreate": "Разослать при создании",
|
||||
"broadcastConfirm": "Разослать закреплённое сообщение всем активным пользователям?",
|
||||
"broadcastToAll": "Разослать всем",
|
||||
"activate": "Активировать",
|
||||
"deactivate": "Деактивировать",
|
||||
"unpin": "Открепить",
|
||||
"unpinAll": "Открепить у всех",
|
||||
"unpinConfirm": "Открепить сообщение у всех пользователей?",
|
||||
"delete": "Удалить",
|
||||
"deleteConfirm": "Удалить это закреплённое сообщение?",
|
||||
"active": "Активно",
|
||||
"inactive": "Неактивно",
|
||||
"prev": "Назад",
|
||||
"next": "Далее",
|
||||
"createSuccess": "Закреплённое сообщение создано",
|
||||
"updateSuccess": "Сообщение обновлено",
|
||||
"deleteSuccess": "Сообщение удалено",
|
||||
"activateSuccess": "Сообщение активировано",
|
||||
"deactivateSuccess": "Сообщение деактивировано",
|
||||
"broadcastSuccess": "Рассылка завершена",
|
||||
"unpinSuccess": "Сообщения откреплены",
|
||||
"sentCount": "Отправлено",
|
||||
"failedCount": "Ошибки",
|
||||
"unpinnedCount": "Откреплено",
|
||||
"editMessage": "Редактировать",
|
||||
"cantDeleteActive": "Нельзя удалить активное сообщение"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки системы",
|
||||
"allCategories": "Все категории",
|
||||
|
||||
@@ -649,7 +649,8 @@
|
||||
"remnawave": "RemnaWave",
|
||||
"users": "用户",
|
||||
"trafficUsage": "流量使用",
|
||||
"updates": "更新"
|
||||
"updates": "更新",
|
||||
"pinnedMessages": "置顶消息"
|
||||
},
|
||||
"panel": {
|
||||
"title": "管理面板",
|
||||
@@ -673,7 +674,8 @@
|
||||
"remnawaveDesc": "面板管理和统计",
|
||||
"usersDesc": "管理机器人用户",
|
||||
"trafficUsageDesc": "按节点统计用户流量",
|
||||
"updatesDesc": "机器人和面板版本"
|
||||
"updatesDesc": "机器人和面板版本",
|
||||
"pinnedMessagesDesc": "管理用户置顶消息"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "流量使用",
|
||||
@@ -913,6 +915,48 @@
|
||||
"bothCreated": "群发已创建",
|
||||
"atLeastOneChannel": "请至少选择一个渠道"
|
||||
},
|
||||
"pinnedMessages": {
|
||||
"title": "置顶消息",
|
||||
"subtitle": "管理用户置顶消息",
|
||||
"create": "创建",
|
||||
"empty": "暂无置顶消息",
|
||||
"content": "消息文本",
|
||||
"contentPlaceholder": "输入置顶消息文本...",
|
||||
"noContent": "无文本",
|
||||
"media": "媒体文件",
|
||||
"addMedia": "添加媒体",
|
||||
"uploading": "上传中...",
|
||||
"settings": "设置",
|
||||
"sendBeforeMenu": "菜单前发送",
|
||||
"sendOnEveryStart": "每次启动时发送",
|
||||
"broadcast": "广播",
|
||||
"broadcastOnCreate": "创建时广播",
|
||||
"broadcastConfirm": "向所有活跃用户广播置顶消息?",
|
||||
"broadcastToAll": "广播给所有人",
|
||||
"activate": "激活",
|
||||
"deactivate": "停用",
|
||||
"unpin": "取消置顶",
|
||||
"unpinAll": "取消所有人的置顶",
|
||||
"unpinConfirm": "取消所有用户的置顶消息?",
|
||||
"delete": "删除",
|
||||
"deleteConfirm": "删除此置顶消息?",
|
||||
"active": "活跃",
|
||||
"inactive": "不活跃",
|
||||
"prev": "上一页",
|
||||
"next": "下一页",
|
||||
"createSuccess": "置顶消息已创建",
|
||||
"updateSuccess": "消息已更新",
|
||||
"deleteSuccess": "消息已删除",
|
||||
"activateSuccess": "消息已激活",
|
||||
"deactivateSuccess": "消息已停用",
|
||||
"broadcastSuccess": "广播完成",
|
||||
"unpinSuccess": "消息已取消置顶",
|
||||
"sentCount": "已发送",
|
||||
"failedCount": "失败",
|
||||
"unpinnedCount": "已取消置顶",
|
||||
"editMessage": "编辑",
|
||||
"cantDeleteActive": "无法删除活跃消息"
|
||||
},
|
||||
"settings": {
|
||||
"title": "系统设置",
|
||||
"allCategories": "所有分类",
|
||||
|
||||
@@ -155,6 +155,17 @@ const MegaphoneIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PinnedMessageIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PaperAirplaneIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
@@ -426,6 +437,12 @@ export default function AdminPanel() {
|
||||
title: t('admin.nav.broadcasts'),
|
||||
description: t('admin.panel.broadcastsDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/pinned-messages',
|
||||
icon: <PinnedMessageIcon />,
|
||||
title: t('admin.nav.pinnedMessages'),
|
||||
description: t('admin.panel.pinnedMessagesDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/wheel',
|
||||
icon: <SparklesIcon />,
|
||||
|
||||
367
src/pages/AdminPinnedMessageCreate.tsx
Normal file
367
src/pages/AdminPinnedMessageCreate.tsx
Normal file
@@ -0,0 +1,367 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
adminPinnedMessagesApi,
|
||||
PinnedMessageCreateRequest,
|
||||
PinnedMessageUpdateRequest,
|
||||
} from '../api/adminPinnedMessages';
|
||||
import { AdminBackButton, Toggle } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const PinIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PhotoIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideoIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SaveIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function AdminPinnedMessageCreate() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isEditing = Boolean(id);
|
||||
const messageId = id ? parseInt(id, 10) : null;
|
||||
|
||||
// Form state
|
||||
const [content, setContent] = useState('');
|
||||
const [sendBeforeMenu, setSendBeforeMenu] = useState(true);
|
||||
const [sendOnEveryStart, setSendOnEveryStart] = useState(true);
|
||||
const [broadcastOnCreate, setBroadcastOnCreate] = useState(false);
|
||||
|
||||
// Media state
|
||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||
const [mediaType, setMediaType] = useState<'photo' | 'video'>('photo');
|
||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [existingMediaType, setExistingMediaType] = useState<'photo' | 'video' | null>(null);
|
||||
|
||||
// Load existing message for editing
|
||||
const { data: existingMessage, isLoading: isLoadingMessage } = useQuery({
|
||||
queryKey: ['admin', 'pinned-messages', 'detail', messageId],
|
||||
queryFn: () => adminPinnedMessagesApi.get(messageId!),
|
||||
enabled: isEditing && messageId !== null,
|
||||
});
|
||||
|
||||
// Pre-fill form when editing
|
||||
useEffect(() => {
|
||||
if (existingMessage) {
|
||||
setContent(existingMessage.content || '');
|
||||
setSendBeforeMenu(existingMessage.send_before_menu);
|
||||
setSendOnEveryStart(existingMessage.send_on_every_start);
|
||||
if (existingMessage.media_file_id && existingMessage.media_type) {
|
||||
setUploadedFileId(existingMessage.media_file_id);
|
||||
setExistingMediaType(existingMessage.media_type);
|
||||
setMediaType(existingMessage.media_type);
|
||||
}
|
||||
}
|
||||
}, [existingMessage]);
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: PinnedMessageCreateRequest) => adminPinnedMessagesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
|
||||
navigate('/admin/pinned-messages');
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: PinnedMessageUpdateRequest) =>
|
||||
adminPinnedMessagesApi.update(messageId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
|
||||
navigate('/admin/pinned-messages');
|
||||
},
|
||||
});
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setMediaFile(file);
|
||||
setExistingMediaType(null);
|
||||
|
||||
let detectedType: 'photo' | 'video' = 'photo';
|
||||
if (file.type.startsWith('image/')) {
|
||||
detectedType = 'photo';
|
||||
setMediaPreview(URL.createObjectURL(file));
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
detectedType = 'video';
|
||||
setMediaPreview(null);
|
||||
}
|
||||
setMediaType(detectedType);
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const result = await adminPinnedMessagesApi.uploadMedia(file, detectedType);
|
||||
setUploadedFileId(result.file_id);
|
||||
} catch {
|
||||
setMediaFile(null);
|
||||
setMediaPreview(null);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Remove media
|
||||
const handleRemoveMedia = () => {
|
||||
setMediaFile(null);
|
||||
setMediaPreview(null);
|
||||
setUploadedFileId(null);
|
||||
setExistingMediaType(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Validate
|
||||
const isValid = content.trim().length > 0 || uploadedFileId !== null;
|
||||
|
||||
// Submit
|
||||
const handleSubmit = () => {
|
||||
if (!isValid) return;
|
||||
|
||||
if (isEditing && messageId !== null) {
|
||||
const data: PinnedMessageUpdateRequest = {
|
||||
content,
|
||||
send_before_menu: sendBeforeMenu,
|
||||
send_on_every_start: sendOnEveryStart,
|
||||
};
|
||||
if (uploadedFileId) {
|
||||
data.media = { type: mediaType, file_id: uploadedFileId };
|
||||
}
|
||||
updateMutation.mutate(data);
|
||||
} else {
|
||||
const data: PinnedMessageCreateRequest = {
|
||||
content,
|
||||
send_before_menu: sendBeforeMenu,
|
||||
send_on_every_start: sendOnEveryStart,
|
||||
broadcast: broadcastOnCreate,
|
||||
};
|
||||
if (uploadedFileId) {
|
||||
data.media = { type: mediaType, file_id: uploadedFileId };
|
||||
}
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
if (isEditing && isLoadingMessage) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||
<p>{t('common.loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton to="/admin/pinned-messages" />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-error-500/20 p-2 text-error-400">
|
||||
<PinIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">
|
||||
{isEditing ? t('admin.pinnedMessages.editMessage') : t('admin.pinnedMessages.create')}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.pinnedMessages.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="card space-y-6">
|
||||
{/* Message text */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.pinnedMessages.content')}
|
||||
</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t('admin.pinnedMessages.contentPlaceholder')}
|
||||
rows={6}
|
||||
maxLength={4000}
|
||||
className="input min-h-[150px] resize-y"
|
||||
/>
|
||||
<div className="mt-1 text-right text-xs text-dark-400">{content.length}/4000</div>
|
||||
</div>
|
||||
|
||||
{/* Media upload */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.pinnedMessages.media')}
|
||||
</label>
|
||||
{mediaFile || existingMediaType ? (
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{(mediaType === 'photo' || existingMediaType === 'photo') && <PhotoIcon />}
|
||||
{(mediaType === 'video' || existingMediaType === 'video') && <VideoIcon />}
|
||||
<div>
|
||||
<p className="text-sm text-dark-100">
|
||||
{mediaFile
|
||||
? mediaFile.name
|
||||
: `${existingMediaType} (${t('admin.pinnedMessages.media')})`}
|
||||
</p>
|
||||
{mediaFile && (
|
||||
<p className="text-xs text-dark-400">
|
||||
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveMedia}
|
||||
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
{mediaPreview && (
|
||||
<img
|
||||
src={mediaPreview}
|
||||
alt="Preview"
|
||||
className="mt-3 max-h-48 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
{isUploading && (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
|
||||
<RefreshIcon />
|
||||
{t('admin.pinnedMessages.uploading')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
|
||||
>
|
||||
<PhotoIcon />
|
||||
<span>{t('admin.pinnedMessages.addMedia')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="card space-y-4">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.pinnedMessages.settings')}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-dark-200">
|
||||
{t('admin.pinnedMessages.sendBeforeMenu')}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={sendBeforeMenu} onChange={() => setSendBeforeMenu((p) => !p)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-dark-200">
|
||||
{t('admin.pinnedMessages.sendOnEveryStart')}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={sendOnEveryStart} onChange={() => setSendOnEveryStart((p) => !p)} />
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-dark-200">
|
||||
{t('admin.pinnedMessages.broadcastOnCreate')}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={broadcastOnCreate} onChange={() => setBroadcastOnCreate((p) => !p)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="card flex items-center justify-between">
|
||||
<button onClick={() => navigate('/admin/pinned-messages')} className="btn-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isPending || isUploading}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{isPending ? <RefreshIcon /> : <SaveIcon />}
|
||||
{isEditing ? t('common.save') : t('admin.pinnedMessages.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
428
src/pages/AdminPinnedMessages.tsx
Normal file
428
src/pages/AdminPinnedMessages.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminPinnedMessagesApi, PinnedMessageResponse } from '../api/adminPinnedMessages';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
|
||||
// Icons
|
||||
const PinIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PhotoIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const VideoIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MenuIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RepeatIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BroadcastIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const UnpinIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Message card component
|
||||
function PinnedMessageCard({
|
||||
message,
|
||||
onActivate,
|
||||
onDeactivate,
|
||||
onBroadcast,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onUnpin,
|
||||
}: {
|
||||
message: PinnedMessageResponse;
|
||||
onActivate: (id: number) => void;
|
||||
onDeactivate: () => void;
|
||||
onBroadcast: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onUnpin: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border p-4 transition-all ${
|
||||
message.is_active
|
||||
? 'border-success-500/50 bg-success-500/5'
|
||||
: 'border-dark-700 bg-dark-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Status + ID + media indicator */}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
||||
message.is_active
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-500/20 text-dark-400'
|
||||
}`}
|
||||
>
|
||||
{message.is_active
|
||||
? t('admin.pinnedMessages.active')
|
||||
: t('admin.pinnedMessages.inactive')}
|
||||
</span>
|
||||
<span className="text-xs text-dark-400">#{message.id}</span>
|
||||
{message.media_type && (
|
||||
<span className="text-dark-400">
|
||||
{message.media_type === 'photo' ? <PhotoIcon /> : <VideoIcon />}
|
||||
</span>
|
||||
)}
|
||||
{message.send_before_menu && (
|
||||
<span className="text-dark-500" title={t('admin.pinnedMessages.sendBeforeMenu')}>
|
||||
<MenuIcon />
|
||||
</span>
|
||||
)}
|
||||
{message.send_on_every_start && (
|
||||
<span className="text-dark-500" title={t('admin.pinnedMessages.sendOnEveryStart')}>
|
||||
<RepeatIcon />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content preview */}
|
||||
<p className="line-clamp-3 text-sm text-dark-100">
|
||||
{message.content || t('admin.pinnedMessages.noContent')}
|
||||
</p>
|
||||
|
||||
{/* Date */}
|
||||
<div className="mt-2 text-xs text-dark-400">
|
||||
{new Date(message.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-3 flex flex-wrap gap-2 border-t border-dark-700/50 pt-3">
|
||||
<button
|
||||
onClick={() => onEdit(message.id)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
<EditIcon />
|
||||
{t('admin.pinnedMessages.editMessage')}
|
||||
</button>
|
||||
|
||||
{message.is_active ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onDeactivate}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-warning-500/20 px-3 py-1.5 text-xs text-warning-400 transition-colors hover:bg-warning-500/30"
|
||||
>
|
||||
<XIcon />
|
||||
{t('admin.pinnedMessages.deactivate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUnpin}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-error-500/20 px-3 py-1.5 text-xs text-error-400 transition-colors hover:bg-error-500/30"
|
||||
>
|
||||
<UnpinIcon />
|
||||
{t('admin.pinnedMessages.unpinAll')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onActivate(message.id)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-success-500/20 px-3 py-1.5 text-xs text-success-400 transition-colors hover:bg-success-500/30"
|
||||
>
|
||||
<CheckIcon />
|
||||
{t('admin.pinnedMessages.activate')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onBroadcast(message.id)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-500/20 px-3 py-1.5 text-xs text-accent-400 transition-colors hover:bg-accent-500/30"
|
||||
>
|
||||
<BroadcastIcon />
|
||||
{t('admin.pinnedMessages.broadcastToAll')}
|
||||
</button>
|
||||
|
||||
{!message.is_active && (
|
||||
<button
|
||||
onClick={() => onDelete(message.id)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-error-500/20 px-3 py-1.5 text-xs text-error-400 transition-colors hover:bg-error-500/30"
|
||||
>
|
||||
<TrashIcon />
|
||||
{t('admin.pinnedMessages.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function AdminPinnedMessages() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
// Fetch pinned messages
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin', 'pinned-messages', 'list', page],
|
||||
queryFn: () => adminPinnedMessagesApi.list(limit, page * limit),
|
||||
});
|
||||
|
||||
const messages = data?.items || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// Mutations
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (id: number) => adminPinnedMessagesApi.activate(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: () => adminPinnedMessagesApi.deactivate(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
|
||||
},
|
||||
});
|
||||
|
||||
const unpinMutation = useMutation({
|
||||
mutationFn: () => adminPinnedMessagesApi.unpin(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
|
||||
},
|
||||
});
|
||||
|
||||
const broadcastMutation = useMutation({
|
||||
mutationFn: (id: number) => adminPinnedMessagesApi.broadcast(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => adminPinnedMessagesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'pinned-messages'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleActivate = (id: number) => {
|
||||
activateMutation.mutate(id);
|
||||
};
|
||||
|
||||
const handleDeactivate = () => {
|
||||
deactivateMutation.mutate();
|
||||
};
|
||||
|
||||
const handleUnpin = () => {
|
||||
if (window.confirm(t('admin.pinnedMessages.unpinConfirm'))) {
|
||||
unpinMutation.mutate();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBroadcast = (id: number) => {
|
||||
if (window.confirm(t('admin.pinnedMessages.broadcastConfirm'))) {
|
||||
broadcastMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (window.confirm(t('admin.pinnedMessages.deleteConfirm'))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (id: number) => {
|
||||
navigate(`/admin/pinned-messages/${id}/edit`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton />
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-error-500/20 p-2 text-error-400">
|
||||
<PinIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.pinnedMessages.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.pinnedMessages.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:text-dark-100"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/admin/pinned-messages/create')}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('admin.pinnedMessages.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
{isLoading ? (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||
<RefreshIcon />
|
||||
<p className="mt-2">{t('common.loading')}</p>
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
|
||||
<div className="mx-auto mb-2 w-fit">
|
||||
<PinIcon />
|
||||
</div>
|
||||
<p>{t('admin.pinnedMessages.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{messages.map((message: PinnedMessageResponse) => (
|
||||
<PinnedMessageCard
|
||||
key={message.id}
|
||||
message={message}
|
||||
onActivate={handleActivate}
|
||||
onDeactivate={handleDeactivate}
|
||||
onBroadcast={handleBroadcast}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onUnpin={handleUnpin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('admin.pinnedMessages.prev')}
|
||||
</button>
|
||||
<span className="text-dark-400">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('admin.pinnedMessages.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user