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
|
ADMIN_PASSWORD=admin123
|
||||||
API_PUBLIC_URL=http://localhost:4000
|
API_PUBLIC_URL=http://localhost:4000
|
||||||
CORS_ORIGIN=http://localhost:5173,http://localhost:3000
|
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}`)
|
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 app = express()
|
||||||
const corsOrigin = process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173']
|
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()
|
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, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Noro CMS API running at http://localhost:${PORT}`)
|
console.log(`Noro CMS API running at http://localhost:${PORT}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface Project {
|
|||||||
year?: string
|
year?: string
|
||||||
featured?: boolean
|
featured?: boolean
|
||||||
imageUrl?: string
|
imageUrl?: string
|
||||||
|
videoUrl?: string
|
||||||
galleryUrls?: string[]
|
galleryUrls?: string[]
|
||||||
client?: string
|
client?: string
|
||||||
deliverables?: string[]
|
deliverables?: string[]
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
<p v-if="description" class="mt-1 text-sm text-slate-500">{{ description }}</p>
|
<p v-if="description" class="mt-1 text-sm text-slate-500">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="btn-secondary cursor-pointer text-xs">
|
<label class="btn-secondary cursor-pointer text-xs">
|
||||||
{{ uploading ? 'در حال آپلود...' : 'آپلود تصویر' }}
|
{{ uploading ? 'در حال آپلود...' : 'آپلود رسانه' }}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
accept="image/*"
|
:accept="accept"
|
||||||
multiple
|
multiple
|
||||||
:disabled="uploading"
|
:disabled="uploading"
|
||||||
@change="onUpload"
|
@change="onUpload"
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
v-if="modelValue.length === 0"
|
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"
|
class="rounded-lg border border-dashed border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
|
||||||
>
|
>
|
||||||
هنوز تصویری در گالری نیست.
|
هنوز رسانهای در گالری نیست.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@@ -33,20 +33,38 @@
|
|||||||
:key="`${url}-${index}`"
|
:key="`${url}-${index}`"
|
||||||
class="overflow-hidden rounded-xl border border-slate-200 bg-white"
|
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">
|
<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">
|
<label class="cursor-pointer text-xs text-brand hover:underline">
|
||||||
{{ uploadingIndex === index ? '...' : 'جایگزین' }}
|
{{ uploadingIndex === index ? '...' : 'جایگزین' }}
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
accept="image/*"
|
:accept="accept"
|
||||||
:disabled="uploadingIndex === index"
|
:disabled="uploadingIndex === index"
|
||||||
@change="onReplace($event, index)"
|
@change="onReplace($event, index)"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
v-if="showSetCover"
|
v-if="showSetCover && !isVideoUrl(url)"
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-brand hover:underline"
|
class="text-xs text-brand hover:underline"
|
||||||
@click="emit('set-cover', url)"
|
@click="emit('set-cover', url)"
|
||||||
@@ -65,6 +83,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
|
import { isVideoUrl } from '@/utils/media'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -72,10 +91,12 @@ const props = withDefaults(
|
|||||||
title?: string
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
showSetCover?: boolean
|
showSetCover?: boolean
|
||||||
|
accept?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: () => [],
|
modelValue: () => [],
|
||||||
showSetCover: false,
|
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) {
|
function remove(index: number) {
|
||||||
if (!confirm('آیا از حذف این تصویر از گالری مطمئن هستید؟')) return
|
if (!confirm('آیا از حذف این مورد از گالری مطمئن هستید؟')) return
|
||||||
emit(
|
emit(
|
||||||
'update:modelValue',
|
'update:modelValue',
|
||||||
props.modelValue.filter((_, i) => i !== index)
|
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"
|
:alt="item.filename"
|
||||||
class="h-full w-full object-cover"
|
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>
|
<span v-else class="text-4xl text-slate-400">📄</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -89,6 +96,12 @@ function isImage(item: MediaItem) {
|
|||||||
return mime.startsWith('image/') || /\.(png|jpe?g|gif|webp|svg)$/.test(name)
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|||||||
@@ -52,14 +52,25 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<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>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<ImageGalleryField
|
<ImageGalleryField
|
||||||
v-model="galleryUrls"
|
v-model="galleryUrls"
|
||||||
title="گالری تصاویر"
|
title="گالری رسانه"
|
||||||
description="تصاویر صفحه جزئیات نمونهکار"
|
description="تصاویر یا ویدیوهای صفحه جزئیات نمونهکار"
|
||||||
show-set-cover
|
show-set-cover
|
||||||
@set-cover="setCover"
|
@set-cover="setCover"
|
||||||
/>
|
/>
|
||||||
@@ -90,6 +101,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { api, type PortfolioCategory, type Project } from '@/api/client'
|
import { api, type PortfolioCategory, type Project } from '@/api/client'
|
||||||
import ImageField from '@/components/ImageField.vue'
|
import ImageField from '@/components/ImageField.vue'
|
||||||
import ImageGalleryField from '@/components/ImageGalleryField.vue'
|
import ImageGalleryField from '@/components/ImageGalleryField.vue'
|
||||||
|
import VideoField from '@/components/VideoField.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -112,6 +124,7 @@ const form = reactive<Partial<Project>>({
|
|||||||
client: '',
|
client: '',
|
||||||
featured: false,
|
featured: false,
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
videoUrl: '',
|
||||||
galleryUrls: [],
|
galleryUrls: [],
|
||||||
seoTitle: '',
|
seoTitle: '',
|
||||||
seoDescription: '',
|
seoDescription: '',
|
||||||
|
|||||||
@@ -26,7 +26,17 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="p in projects" :key="p.id" class="border-b border-slate-50 hover:bg-slate-50/50">
|
<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">{{ categoryLabel(p.category) }}</td>
|
||||||
<td class="px-4 py-3 text-slate-600">{{ p.year || '—' }}</td>
|
<td class="px-4 py-3 text-slate-600">{{ p.year || '—' }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user