feat(canina): implement multi-select bulk media delete, fix logo clipping, fix blog API URLs, and improve dosage labels

This commit is contained in:
parsa aghaei 2026-08-16 13:06:32 +03:30
parent 7e403bd53e
commit b161ecf815
8 changed files with 203 additions and 46 deletions

View File

@ -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: 'حذف فایل' })

View File

@ -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: {

View File

@ -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 */}

View File

@ -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"

View File

@ -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>

View File

@ -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">

View File

@ -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>) => ({

View File

@ -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>