feat(canina): implement multi-select bulk media delete, fix logo clipping, fix blog API URLs, and improve dosage labels
This commit is contained in:
parent
7e403bd53e
commit
b161ecf815
@ -39,6 +39,17 @@ export class MediaController {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('bulk')
|
||||
@ApiOperation({ summary: 'حذف دستهجمعی فایلها' })
|
||||
async deleteManyMedia(@Body('ids') ids: string[]) {
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
throw new BadRequestException('لیست شناسههای رسانه الزامی است');
|
||||
}
|
||||
const data = await this.mediaService.deleteManyMedia(ids);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'حذف فایل' })
|
||||
|
||||
@ -62,6 +62,33 @@ export class MediaService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async deleteManyMedia(ids: string[]) {
|
||||
const mediaItems = await this.prisma.media.findMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
|
||||
for (const media of mediaItems) {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
'uploads',
|
||||
path.basename(media.url),
|
||||
);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// Ignore individual unlink errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.media.deleteMany({
|
||||
where: { id: { in: ids } },
|
||||
});
|
||||
|
||||
return { success: true, count: mediaItems.length };
|
||||
}
|
||||
|
||||
async updateMedia(
|
||||
id: string,
|
||||
data: {
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall, Sparkles, MessageSquareQuote, FlaskConical, Building2, Globe, DollarSign, Sliders, MessageSquare, Receipt, CreditCard } from 'lucide-react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Users, ShoppingCart, Tag, Settings, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video, PhoneCall, Sparkles, MessageSquareQuote, FlaskConical, Building2, Globe, DollarSign, Sliders, MessageSquare, Receipt, CreditCard } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useAdminAuthStore } from '../store/adminAuthStore';
|
||||
|
||||
const menuGroups = [
|
||||
{
|
||||
@ -65,9 +64,7 @@ interface SidebarProps {
|
||||
|
||||
export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [newOrdersCount, setNewOrdersCount] = useState(0);
|
||||
const clearAuth = useAdminAuthStore((state) => state.clearAuth);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -88,18 +85,6 @@ export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
clearAuth();
|
||||
localStorage.removeItem('adminToken');
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Backdrop */}
|
||||
|
||||
@ -29,18 +29,67 @@ export default function MediaManager() {
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/media');
|
||||
setMediaList(Array.isArray(res.data) ? res.data : res.data?.data || []);
|
||||
setSelectedIds(new Set());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media', error);
|
||||
toast.error('خطا در دریافت رسانهها');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSelect = (id: string, e?: React.MouseEvent) => {
|
||||
if (e) e.stopPropagation();
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllCurrentPage = (items: Media[]) => {
|
||||
setSelectedIds(prev => {
|
||||
const allSelected = items.every(m => prev.has(m.id));
|
||||
const next = new Set(prev);
|
||||
if (allSelected) {
|
||||
items.forEach(m => next.delete(m.id));
|
||||
} else {
|
||||
items.forEach(m => next.add(m.id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
try {
|
||||
setIsBulkDeleting(true);
|
||||
await api.delete('/admin/media/bulk', {
|
||||
data: { ids: Array.from(selectedIds) }
|
||||
});
|
||||
toast.success(`${selectedIds.size} تصویر با موفقیت حذف شد`);
|
||||
setSelectedIds(new Set());
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
fetchMedia();
|
||||
} catch (error) {
|
||||
console.error('Bulk delete failed', error);
|
||||
toast.error('خطا در حذف گروهی تصاویر');
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
@ -241,18 +290,73 @@ export default function MediaManager() {
|
||||
) : (() => {
|
||||
const totalPages = Math.ceil(filtered.length / limit) || 1;
|
||||
const paginatedList = filtered.slice((page - 1) * limit, page * limit);
|
||||
const isAllPageSelected = paginatedList.length > 0 && paginatedList.every(m => selectedIds.has(m.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
{/* Multi-select Toolbar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 bg-purple-50/60 p-3 rounded-2xl border border-purple-100 font-vazir">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectAllCurrentPage(paginatedList)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-white border border-purple-200 rounded-xl text-xs font-bold text-purple-700 hover:bg-purple-100/50 transition-all cursor-pointer shadow-xs"
|
||||
>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${
|
||||
isAllPageSelected ? 'bg-purple-600 border-purple-600 text-white' : 'border-purple-300 bg-white'
|
||||
}`}>
|
||||
{isAllPageSelected && <CheckCircle2 className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<span>{isAllPageSelected ? 'لغو انتخاب این صفحه' : 'انتخاب همه در این صفحه'}</span>
|
||||
</button>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<span className="text-xs font-black text-purple-900 bg-purple-100 px-2.5 py-1 rounded-lg">
|
||||
{selectedIds.size} مورد انتخاب شده
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsBulkDeleteModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs font-bold rounded-xl transition-all shadow-md shadow-red-200 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>حذف موارد انتخابشده ({selectedIds.size})</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{paginatedList.map(media => {
|
||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
const isSelected = selectedIds.has(media.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
className="group relative bg-gray-100 rounded-xl overflow-hidden border border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100 transition-all cursor-pointer"
|
||||
className={`group relative bg-gray-100 rounded-xl overflow-hidden border transition-all cursor-pointer ${
|
||||
isSelected ? 'border-purple-600 ring-2 ring-purple-400/50 shadow-md bg-purple-50/20' : 'border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100'
|
||||
}`}
|
||||
onClick={() => setPreviewUrl(imgUrl)}
|
||||
>
|
||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
|
||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100 relative">
|
||||
{/* Checkbox badge */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => toggleSelect(media.id, e)}
|
||||
className={`absolute top-2 right-2 z-20 w-6 h-6 rounded-lg flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'bg-purple-600 text-white shadow-md'
|
||||
: 'bg-white/80 border border-gray-300 text-transparent hover:border-purple-400 opacity-80 group-hover:opacity-100'
|
||||
}`}
|
||||
title="انتخاب"
|
||||
>
|
||||
<CheckCircle2 className={`w-4 h-4 ${isSelected ? 'text-white' : 'text-gray-400'}`} />
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={media.filename}
|
||||
@ -315,6 +419,16 @@ export default function MediaManager() {
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Bulk Delete Confirm Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={isBulkDeleteModalOpen}
|
||||
title="حذف گروهی تصاویر"
|
||||
message={`آیا از حذف دستهجمعی ${selectedIds.size} تصویر انتخاب شده مطمئن هستید؟ این عملیات غیرقابل بازگشت است.`}
|
||||
onConfirm={handleBulkDelete}
|
||||
onCancel={() => setIsBulkDeleteModalOpen(false)}
|
||||
isLoading={isBulkDeleting}
|
||||
/>
|
||||
|
||||
{previewUrl && (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-gray-900/80 backdrop-blur-sm"
|
||||
|
||||
@ -659,25 +659,25 @@ export default function Products() {
|
||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||
<label className="text-sm font-bold text-gray-700 flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>منطق و دوز دقیق مصرفی بالینی (Dosage Calculator Fields)</span>
|
||||
<span>دستور مصرف بالینی و راهنمای دوز مصرفی</span>
|
||||
<div className="group relative inline-block">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-64 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||||
دستور مصرف دقیق کالا (مانند: ۲ قرص به ازای هر ۱۰ کیلوگرم وزن بدن در روز). این متن در کاتالوگ و کارت مشخصات کالا به صورت برجسته نمایش داده میشود.
|
||||
دستور مصرف روان و فارسی کالا (مانند: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن). این متن در کاتالوگ آنلاین و صفحه مشخصات کالا به صورت برجسته نمایش داده میشود.
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<span className="text-xs text-purple-600 font-bold">دستور مصرف بالینی / JSON</span>
|
||||
<span className="text-xs text-purple-600 font-bold">متن راهنمای بالینی</span>
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.dosageLogic}
|
||||
onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })}
|
||||
placeholder='مثال: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن یا فرمت JSON: {"baseDosage": 1, "perKg": 10}'
|
||||
placeholder="مثال: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن، همراه با وعده غذایی مصرف شود."
|
||||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-vazir"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
این متن در بخش مشخصات علمی کالا و کاتالوگ آنلاین برای پزشکان و خریداران نمایش داده میشود.
|
||||
این متن در بخش مشخصات علمی کالا، ماشینحساب دوز و کاتالوگ آنلاین برای پزشکان و خریداران نمایش داده میشود.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -402,13 +402,22 @@ export default function Settings() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">لینک لوگوی برند (Brand Logo URL)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.BRAND_LOGO_URL}
|
||||
onChange={(e) => setSettings({ ...settings, BRAND_LOGO_URL: e.target.value })}
|
||||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
|
||||
dir="ltr"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.BRAND_LOGO_URL}
|
||||
onChange={(e) => setSettings({ ...settings, BRAND_LOGO_URL: e.target.value })}
|
||||
className="flex-1 border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-purple-500 text-xs font-mono"
|
||||
placeholder="https://... یا /logo.png"
|
||||
dir="ltr"
|
||||
/>
|
||||
{settings.BRAND_LOGO_URL && (
|
||||
<div className="w-10 h-10 border rounded-lg bg-gray-50 flex items-center justify-center p-1 overflow-hidden shrink-0">
|
||||
<img src={settings.BRAND_LOGO_URL} alt="Preview" className="max-w-full max-h-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 mt-1">این لوگو در صورت پر بودن، جایگزین تایپوگرافی هدر سایت در اپلیکیشن خواهد شد.</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
|
||||
@ -18,9 +18,10 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
async function getBlogs() {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'https://apicanina.parsaaghayi.ir';
|
||||
const rawApiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:4001/api';
|
||||
const apiBase = rawApiUrl.endsWith('/api') ? rawApiUrl : `${rawApiUrl}/api`;
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/blogs`, { next: { revalidate: 60 } });
|
||||
const res = await fetch(`${apiBase}/blogs`, { next: { revalidate: 60 } });
|
||||
if (!res.ok) throw new Error('Failed to fetch blogs');
|
||||
const data = await res.json();
|
||||
return (data.data || data).map((b: Record<string, unknown>) => ({
|
||||
|
||||
@ -145,19 +145,29 @@ export default function Header({
|
||||
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
|
||||
<Link href="/" className="flex items-center gap-2 sm:gap-3 group shrink-0 min-w-0">
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 min-w-[40px] min-h-[40px] sm:min-w-[48px] sm:min-h-[48px] bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-xl sm:text-2xl group-hover:bg-medical-gray-900 transition-all shadow-md italic shrink-0">C</div>
|
||||
<div className="flex flex-col justify-center min-w-0">
|
||||
<span className="text-canina-blue font-black text-base sm:text-2xl tracking-tighter italic font-sans flex items-center gap-1 leading-none truncate">
|
||||
Canina
|
||||
<span className="text-[11px] sm:text-sm not-italic font-medium border-r border-medical-gray-300 pr-1.5 font-vazir text-medical-gray-700">
|
||||
{getText('brand_name_fa', "ایران")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden sm:block text-[9px] text-medical-gray-400 font-bold uppercase tracking-wider leading-tight mt-0.5 truncate">
|
||||
نماینده رسمی CANINA PHARMA GMBH GERMANY
|
||||
</span>
|
||||
</div>
|
||||
<Link href="/" className="flex items-center gap-2 sm:gap-3 group shrink-0">
|
||||
{(getText('BRAND_LOGO_URL', '') || getText('site_logo', '')) ? (
|
||||
<img
|
||||
src={getText('BRAND_LOGO_URL', '') || getText('site_logo', '')}
|
||||
alt={getText('brand_name_fa', "کانینا ایران")}
|
||||
className="h-10 sm:h-12 w-auto max-w-[140px] sm:max-w-[180px] object-contain shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-10 h-10 sm:w-12 sm:h-12 min-w-[40px] min-h-[40px] sm:min-w-[48px] sm:min-h-[48px] bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-xl sm:text-2xl group-hover:bg-medical-gray-900 transition-all shadow-md italic shrink-0">C</div>
|
||||
<div className="flex flex-col justify-center">
|
||||
<span className="text-canina-blue font-black text-base sm:text-2xl italic font-sans flex items-center gap-1 leading-none pl-1 whitespace-nowrap">
|
||||
Canina
|
||||
<span className="text-[11px] sm:text-sm not-italic font-medium border-r border-medical-gray-300 pr-1.5 mr-0.5 font-vazir text-medical-gray-700">
|
||||
{getText('brand_name_fa', "ایران")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden sm:block text-[9px] text-medical-gray-400 font-bold uppercase tracking-wider leading-tight mt-1 whitespace-nowrap">
|
||||
نماینده رسمی CANINA PHARMA GMBH GERMANY
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user