Add portfolio video upload support in admin panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-13 12:11:32 +03:30
co-authored by Cursor
parent 259a725fba
commit 3e436e506a
9 changed files with 231 additions and 12 deletions
+1
View File
@@ -4,3 +4,4 @@ ADMIN_EMAIL=admin@norostudio.ir
ADMIN_PASSWORD=admin123
API_PUBLIC_URL=http://localhost:4000
CORS_ORIGIN=http://localhost:5173,http://localhost:3000
MAX_UPLOAD_MB=200
+29 -1
View File
@@ -22,7 +22,20 @@ const storage = multer.diskStorage({
cb(null, `${randomUUID()}${ext}`)
},
})
const upload = multer({ storage })
const MAX_UPLOAD_BYTES = Number(process.env.MAX_UPLOAD_MB || 200) * 1024 * 1024
const upload = multer({
storage,
limits: { fileSize: MAX_UPLOAD_BYTES },
fileFilter: (_req, file, cb) => {
const allowed =
file.mimetype.startsWith('image/') ||
file.mimetype.startsWith('video/') ||
file.mimetype === 'application/pdf' ||
file.mimetype === 'image/svg+xml'
if (allowed) return cb(null, true)
cb(new Error('فقط تصویر، ویدیو یا PDF مجاز است'))
},
})
const app = express()
const corsOrigin = process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173']
@@ -320,6 +333,21 @@ app.delete('/api/media/:id', authMiddleware, (req, res) => {
res.status(204).end()
})
app.use((err, _req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: `حجم فایل بیش از حد مجاز است (حداکثر ${Number(process.env.MAX_UPLOAD_MB || 200)} مگابایت)`,
})
}
return res.status(400).json({ error: err.message })
}
if (err) {
return res.status(400).json({ error: err.message || 'خطای آپلود' })
}
next()
})
app.listen(PORT, () => {
console.log(`Noro CMS API running at http://localhost:${PORT}`)
})
+1
View File
@@ -24,6 +24,7 @@ export interface Project {
year?: string
featured?: boolean
imageUrl?: string
videoUrl?: string
galleryUrls?: string[]
client?: string
deliverables?: string[]
+28 -7
View File
@@ -6,11 +6,11 @@
<p v-if="description" class="mt-1 text-sm text-slate-500">{{ description }}</p>
</div>
<label class="btn-secondary cursor-pointer text-xs">
{{ uploading ? 'در حال آپلود...' : 'آپلود تصویر' }}
{{ uploading ? 'در حال آپلود...' : 'آپلود رسانه' }}
<input
type="file"
class="hidden"
accept="image/*"
:accept="accept"
multiple
:disabled="uploading"
@change="onUpload"
@@ -24,7 +24,7 @@
v-if="modelValue.length === 0"
class="rounded-lg border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
>
هنوز تصویری در گالری نیست.
هنوز رسانه‌ای در گالری نیست.
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -33,20 +33,38 @@
:key="`${url}-${index}`"
class="overflow-hidden rounded-xl border border-slate-200 bg-white"
>
<img :src="url" :alt="`${title || 'گالری'} ${index + 1}`" class="aspect-[4/3] w-full object-cover" />
<video
v-if="isVideoUrl(url)"
:src="url"
controls
preload="metadata"
class="aspect-[4/3] w-full bg-black object-contain"
/>
<img
v-else
:src="url"
:alt="`${title || 'گالری'} ${index + 1}`"
class="aspect-[4/3] w-full object-cover"
/>
<div class="flex flex-wrap gap-2 p-3">
<span
v-if="isVideoUrl(url)"
class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600"
>
ویدیو
</span>
<label class="cursor-pointer text-xs text-brand hover:underline">
{{ uploadingIndex === index ? '...' : 'جایگزین' }}
<input
type="file"
class="hidden"
accept="image/*"
:accept="accept"
:disabled="uploadingIndex === index"
@change="onReplace($event, index)"
/>
</label>
<button
v-if="showSetCover"
v-if="showSetCover && !isVideoUrl(url)"
type="button"
class="text-xs text-brand hover:underline"
@click="emit('set-cover', url)"
@@ -65,6 +83,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { api } from '@/api/client'
import { isVideoUrl } from '@/utils/media'
const props = withDefaults(
defineProps<{
@@ -72,10 +91,12 @@ const props = withDefaults(
title?: string
description?: string
showSetCover?: boolean
accept?: string
}>(),
{
modelValue: () => [],
showSetCover: false,
accept: 'image/*,video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogg,.mov',
}
)
@@ -135,7 +156,7 @@ async function onReplace(e: Event, index: number) {
}
function remove(index: number) {
if (!confirm('آیا از حذف این تصویر از گالری مطمئن هستید؟')) return
if (!confirm('آیا از حذف این مورد از گالری مطمئن هستید؟')) return
emit(
'update:modelValue',
props.modelValue.filter((_, i) => i !== index)
+122
View File
@@ -0,0 +1,122 @@
<template>
<div class="space-y-3 rounded-xl border border-slate-100 bg-slate-50/50 p-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<label v-if="label" class="label mb-0">{{ label }}</label>
<div class="flex flex-wrap gap-2">
<label class="btn-secondary cursor-pointer text-xs">
{{ uploading ? 'در حال آپلود...' : modelValue ? 'جایگزین ویدیو' : 'آپلود ویدیو' }}
<input
type="file"
class="hidden"
accept="video/mp4,video/webm,video/ogg,video/quicktime,.mp4,.webm,.ogg,.mov"
:disabled="uploading"
@change="onUpload"
/>
</label>
<button
v-if="modelValue"
type="button"
class="btn-secondary text-xs"
@click="editingUrl = !editingUrl"
>
{{ editingUrl ? 'بستن' : 'ویرایش آدرس' }}
</button>
<button
v-if="modelValue"
type="button"
class="btn-danger text-xs"
@click="remove"
>
حذف
</button>
</div>
</div>
<p v-if="hint" class="text-xs text-slate-500">{{ hint }}</p>
<p v-if="localError" class="text-xs text-red-600">{{ localError }}</p>
<div v-if="modelValue" class="overflow-hidden rounded-lg border border-slate-200 bg-black">
<video
:src="modelValue"
:poster="poster"
controls
preload="metadata"
class="aspect-video w-full max-w-xl object-contain"
/>
</div>
<p v-else class="text-sm text-slate-500">ویدیویی انتخاب نشده است.</p>
<div v-if="editingUrl || !modelValue">
<label class="label">آدرس ویدیو</label>
<input
:value="modelValue"
class="input"
dir="ltr"
placeholder="/uploads/....mp4"
@input="onUrlInput"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { api } from '@/api/client'
withDefaults(
defineProps<{
label?: string
modelValue?: string
poster?: string
hint?: string
}>(),
{
modelValue: '',
poster: '',
hint: 'فرمت‌های پیشنهادی: MP4 یا WebM — حداکثر حدود ۲۰۰ مگابایت',
}
)
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const uploading = ref(false)
const editingUrl = ref(false)
const localError = ref('')
async function onUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
if (!file.type.startsWith('video/') && !/\.(mp4|webm|ogg|mov)$/i.test(file.name)) {
localError.value = 'فقط فایل ویدیو مجاز است.'
input.value = ''
return
}
uploading.value = true
localError.value = ''
try {
const uploaded = await api.uploadMedia(file)
emit('update:modelValue', uploaded.url)
editingUrl.value = false
} catch (err) {
localError.value = err instanceof Error ? err.message : 'خطا در آپلود ویدیو'
} finally {
uploading.value = false
input.value = ''
}
}
function onUrlInput(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
function remove() {
if (!confirm('آیا از حذف این ویدیو مطمئن هستید؟')) return
emit('update:modelValue', '')
editingUrl.value = false
}
</script>
+10
View File
@@ -0,0 +1,10 @@
export function isVideoUrl(url?: string): boolean {
if (!url) return false
return /\.(mp4|webm|ogg|ogv|mov)(\?.*)?$/i.test(url.split('#')[0] || '')
}
export function isImageUrl(url?: string): boolean {
if (!url) return false
if (isVideoUrl(url)) return false
return /\.(png|jpe?g|gif|webp|svg|avif)(\?.*)?$/i.test(url.split('#')[0] || '')
}
+13
View File
@@ -26,6 +26,13 @@
:alt="item.filename"
class="h-full w-full object-cover"
/>
<video
v-else-if="isVideo(item)"
:src="item.url"
class="h-full w-full object-cover"
muted
preload="metadata"
/>
<span v-else class="text-4xl text-slate-400">📄</span>
</div>
@@ -89,6 +96,12 @@ function isImage(item: MediaItem) {
return mime.startsWith('image/') || /\.(png|jpe?g|gif|webp|svg)$/.test(name)
}
function isVideo(item: MediaItem) {
const mime = item.mimeType || ''
const name = item.filename?.toLowerCase() || ''
return mime.startsWith('video/') || /\.(mp4|webm|ogg|mov)$/.test(name)
}
async function load() {
loading.value = true
error.value = ''
+16 -3
View File
@@ -52,14 +52,25 @@
</section>
<section class="card">
<ImageField v-model="form.imageUrl" label="تصویر کاور" />
<ImageField v-model="form.imageUrl" label="تصویر کاور (پوستر)" />
<p class="mt-2 text-xs text-slate-500">
برای نمونه‌کار ویدیویی، این تصویر به‌عنوان پوستر و کاور کارت استفاده می‌شود.
</p>
</section>
<section class="card">
<VideoField
v-model="form.videoUrl"
label="ویدیو نمونه‌کار"
:poster="form.imageUrl"
/>
</section>
<section class="card">
<ImageGalleryField
v-model="galleryUrls"
title="گالری تصاویر"
description="تصاویر صفحه جزئیات نمونه‌کار"
title="گالری رسانه"
description="تصاویر یا ویدیوهای صفحه جزئیات نمونه‌کار"
show-set-cover
@set-cover="setCover"
/>
@@ -90,6 +101,7 @@ import { useRoute, useRouter } from 'vue-router'
import { api, type PortfolioCategory, type Project } from '@/api/client'
import ImageField from '@/components/ImageField.vue'
import ImageGalleryField from '@/components/ImageGalleryField.vue'
import VideoField from '@/components/VideoField.vue'
const route = useRoute()
const router = useRouter()
@@ -112,6 +124,7 @@ const form = reactive<Partial<Project>>({
client: '',
featured: false,
imageUrl: '',
videoUrl: '',
galleryUrls: [],
seoTitle: '',
seoDescription: '',
+11 -1
View File
@@ -26,7 +26,17 @@
</thead>
<tbody>
<tr v-for="p in projects" :key="p.id" class="border-b border-slate-50 hover:bg-slate-50/50">
<td class="px-4 py-3 font-medium">{{ p.title }}</td>
<td class="px-4 py-3 font-medium">
<span class="inline-flex items-center gap-2">
{{ p.title }}
<span
v-if="p.videoUrl"
class="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600"
>
ویدیو
</span>
</span>
</td>
<td class="px-4 py-3 text-slate-600">{{ categoryLabel(p.category) }}</td>
<td class="px-4 py-3 text-slate-600">{{ p.year || '—' }}</td>
<td class="px-4 py-3">