Add portfolio video upload support in admin panel.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface Project {
|
||||
year?: string
|
||||
featured?: boolean
|
||||
imageUrl?: string
|
||||
videoUrl?: string
|
||||
galleryUrls?: string[]
|
||||
client?: string
|
||||
deliverables?: string[]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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] || '')
|
||||
}
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user