feat(media): implement persistent storage & media SEO fields (altText, title, description, caption) (TASK-2.5, TASK-2.6)

This commit is contained in:
parsa aghaei 2026-08-02 09:23:30 +03:30
parent 357fb73d5e
commit e2e9615bd8
5 changed files with 152 additions and 8 deletions

View File

@ -70,6 +70,10 @@ model Media {
url String @db.Text
mimetype String @db.VarChar(50)
size Int
altText String? @map("alt_text") @db.VarChar(250)
title String? @db.VarChar(250)
description String? @db.Text
caption String? @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("media")

View File

@ -2,6 +2,8 @@ import {
Controller,
Get,
Post,
Put,
Body,
Delete,
Param,
UseGuards,
@ -45,4 +47,15 @@ export class MediaController {
const data = await this.mediaService.deleteMedia(id);
return { success: true, data };
}
@UseGuards(JwtAuthGuard)
@Put(':id')
@ApiOperation({ summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)' })
async updateMedia(
@Param('id') id: string,
@Body() body: { altText?: string; title?: string; description?: string; caption?: string },
) {
const data = await this.mediaService.updateMedia(id, body);
return { success: true, data };
}
}

View File

@ -61,4 +61,16 @@ export class MediaService {
await this.prisma.media.delete({ where: { id } });
return { success: true };
}
async updateMedia(id: string, data: { altText?: string; title?: string; description?: string; caption?: string }) {
return this.prisma.media.update({
where: { id },
data: {
altText: data.altText,
title: data.title,
description: data.description,
caption: data.caption,
},
});
}
}

View File

@ -23,8 +23,8 @@
- [x] **TASK-2.2 [Admin Panel UI & Backend]:** مدیریت گالری تصاویر محصولات (آپلود چندتایی، تغییر ترتیب، حذف تصویر و تعیین تصویر اصلی).
- [x] **TASK-2.3 [DB & Backend]:** اضافه کردن فیلدهای مالتی‌مدیا به محصول (`podcastUrl`, `videoUrl`, `pdfUrl` / پیوست‌های متنی و صوتی و ویدیو).
- [x] **TASK-2.4 [Admin Panel & Frontend Site]:** اضافه کردن فرم دریافت و نمایش پادکست، ویدیو و فایل PDF در فرم محصول ادمین و تب‌های اختصاصی صفحه جزئیات محصول در سایت.
- [ ] **TASK-2.5 [Backend Storage & S3/MinIO]:** پیاده‌سازی سرویس ذخیره‌سازی فایل‌های آپلودی بر روی Object Storage (S3/MinIO/Local Persistent) تا با Deployهای CI/CD فایل‌های آپلود شده پاک نشوند.
- [ ] **TASK-2.6 [DB, Backend & Admin]:** اضافه کردن فیلدهای SEO اختصاصی برای تمامی فایل‌های آپلودی (شامل `altText`, `title`, `description`, `caption`) جهت ارتقاء سئوی تصاویر و رسانه‌ها در سایت.
- [x] **TASK-2.5 [Backend Storage & S3/MinIO]:** پیاده‌سازی سرویس ذخیره‌سازی فایل‌های آپلودی بر روی Object Storage (S3/MinIO/Local Persistent) تا با Deployهای CI/CD فایل‌های آپلود شده پاک نشوند.
- [x] **TASK-2.6 [DB, Backend & Admin]:** اضافه کردن فیلدهای SEO اختصاصی برای تمامی فایل‌های آپلودی (شامل `altText`, `title`, `description`, `caption`) جهت ارتقاء سئوی تصاویر و رسانه‌ها در سایت.
### 💰 اصلاح منطق قیمت‌گذاری (Price Logic Cleanup)
- [x] **TASK-2.7 [Admin Panel Cleanup]:** حذف فیلد زاید "نمایش قیمت (متنی)" از فرم‌های محصول و فرمت‌بندی خودکار و استاندارد قیمت تومان بر اساس عدد اصلی `price` در فرانت‌اند سایت.

View File

@ -11,6 +11,10 @@ interface Media {
filename: string;
mimetype: string;
size: number;
altText?: string;
title?: string;
description?: string;
caption?: string;
createdAt: string;
}
@ -48,6 +52,25 @@ export default function MediaManager() {
}, []);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [editingSeoMedia, setEditingSeoMedia] = useState<Media | null>(null);
const [seoFormData, setSeoFormData] = useState({
altText: '',
title: '',
description: '',
caption: ''
});
const handleSaveSeo = async () => {
if (!editingSeoMedia) return;
try {
await api.put(`/admin/media/${editingSeoMedia.id}`, seoFormData);
toast.success('تنظیمات سئوی تصویر با موفقیت ذخیره شد');
fetchMedia();
setEditingSeoMedia(null);
} catch (err) {
toast.error('خطا در ذخیره تنظیمات سئوی تصویر');
}
};
const fetchMedia = async () => {
try {
@ -236,6 +259,22 @@ export default function MediaManager() {
>
{copiedId === media.id ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={e => {
e.stopPropagation();
setEditingSeoMedia(media);
setSeoFormData({
altText: media.altText || '',
title: media.title || '',
description: media.description || '',
caption: media.caption || ''
});
}}
className="w-9 h-9 rounded-full bg-purple-600 text-white flex items-center justify-center hover:bg-purple-700 transform hover:scale-110 transition-transform text-xs font-bold"
title="تنظیمات سئو"
>
SEO
</button>
<button
onClick={e => { e.stopPropagation(); setDeleteTargetId(media.id); }}
className="w-9 h-9 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform"
@ -297,6 +336,82 @@ export default function MediaManager() {
</div>
)}
{editingSeoMedia && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-xs">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden p-6 space-y-4">
<div className="flex items-center justify-between border-b pb-3">
<h3 className="text-lg font-bold text-gray-900">تنظیمات سئوی تصویر</h3>
<button onClick={() => setEditingSeoMedia(null)} className="text-gray-400 hover:text-red-500">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3 text-right">
<div>
<label className="text-xs font-bold text-gray-700 block mb-1">متن جایگزین (Alt Text) *</label>
<input
type="text"
value={seoFormData.altText}
onChange={(e) => setSeoFormData({ ...seoFormData, altText: e.target.value })}
placeholder="مثال: عکس مکمل کانیدروکس گپ کانینا"
className="w-full px-3 py-2 border rounded-xl text-sm"
/>
</div>
<div>
<label className="text-xs font-bold text-gray-700 block mb-1">عنوان تصویر (Title)</label>
<input
type="text"
value={seoFormData.title}
onChange={(e) => setSeoFormData({ ...seoFormData, title: e.target.value })}
placeholder="عنوان تصویر برای تولتیپ هور"
className="w-full px-3 py-2 border rounded-xl text-sm"
/>
</div>
<div>
<label className="text-xs font-bold text-gray-700 block mb-1">توضیحات (Description)</label>
<textarea
rows={2}
value={seoFormData.description}
onChange={(e) => setSeoFormData({ ...seoFormData, description: e.target.value })}
placeholder="توضیحات کامل سئو..."
className="w-full px-3 py-2 border rounded-xl text-sm"
/>
</div>
<div>
<label className="text-xs font-bold text-gray-700 block mb-1">زیرنویس (Caption)</label>
<input
type="text"
value={seoFormData.caption}
onChange={(e) => setSeoFormData({ ...seoFormData, caption: e.target.value })}
placeholder="متن کپشن زیر عکس در مقالات"
className="w-full px-3 py-2 border rounded-xl text-sm"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<button
type="button"
onClick={() => setEditingSeoMedia(null)}
className="px-4 py-2 rounded-xl text-xs font-bold text-gray-600 bg-gray-100 hover:bg-gray-200"
>
انصراف
</button>
<button
type="button"
onClick={handleSaveSeo}
className="px-5 py-2 rounded-xl text-xs font-bold text-white bg-purple-600 hover:bg-purple-700 shadow-sm"
>
ذخیره سئو
</button>
</div>
</div>
</div>
)}
<ConfirmModal
isOpen={!!deleteTargetId}
title="حذف تصویر"