fix: make admin media uploads reliable
This commit is contained in:
@@ -3,9 +3,12 @@
|
|||||||
<label class="file-uploader__dropzone-wrap">
|
<label class="file-uploader__dropzone-wrap">
|
||||||
<div
|
<div
|
||||||
class="file-uploader__dropzone"
|
class="file-uploader__dropzone"
|
||||||
:class="{ 'file-uploader__dropzone--active': isDragging }"
|
:class="{
|
||||||
|
'file-uploader__dropzone--active': isDragging,
|
||||||
|
'file-uploader__dropzone--disabled': disabled,
|
||||||
|
}"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@dragenter="isDragging = true"
|
@dragenter="onDragEnter"
|
||||||
@dragleave="isDragging = false"
|
@dragleave="isDragging = false"
|
||||||
@drop="handleDrop"
|
@drop="handleDrop"
|
||||||
>
|
>
|
||||||
@@ -19,6 +22,7 @@
|
|||||||
class="file-uploader__input"
|
class="file-uploader__input"
|
||||||
:accept="accept"
|
:accept="accept"
|
||||||
:multiple="multiple"
|
:multiple="multiple"
|
||||||
|
:disabled="disabled"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,6 +79,7 @@ const props = defineProps({
|
|||||||
multiple: { type: Boolean, default: true },
|
multiple: { type: Boolean, default: true },
|
||||||
maxFiles: { type: Number, default: 10 },
|
maxFiles: { type: Number, default: 10 },
|
||||||
maxSize: { type: Number, default: 0 },
|
maxSize: { type: Number, default: 0 },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
|
const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
|
||||||
@@ -82,6 +87,7 @@ const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
|
|||||||
const isDragging = ref(false)
|
const isDragging = ref(false)
|
||||||
|
|
||||||
const handleFileSelect = (event) => {
|
const handleFileSelect = (event) => {
|
||||||
|
if (props.disabled) return
|
||||||
const files = [...event.target.files]
|
const files = [...event.target.files]
|
||||||
emitFiles(files)
|
emitFiles(files)
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
@@ -90,10 +96,15 @@ const handleFileSelect = (event) => {
|
|||||||
const handleDrop = (event) => {
|
const handleDrop = (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
|
if (props.disabled) return
|
||||||
const files = [...event.dataTransfer.files]
|
const files = [...event.dataTransfer.files]
|
||||||
emitFiles(files)
|
emitFiles(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onDragEnter = () => {
|
||||||
|
if (!props.disabled) isDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const emitFiles = (files) => {
|
const emitFiles = (files) => {
|
||||||
if (props.modelValue.length + files.length > props.maxFiles) {
|
if (props.modelValue.length + files.length > props.maxFiles) {
|
||||||
emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`)
|
emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`)
|
||||||
@@ -155,6 +166,11 @@ const formatFileSize = (bytes) => {
|
|||||||
&--active {
|
&--active {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__upload-icon {
|
&__upload-icon {
|
||||||
|
|||||||
+9
-3
@@ -140,9 +140,15 @@ export const COURSE_CONTENT_TYPE = Object.freeze({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({
|
export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({
|
||||||
video: 'video/*',
|
video: '.mp4,.webm,video/mp4,video/webm',
|
||||||
voice: 'audio/*',
|
voice: '.mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm',
|
||||||
text: '.pdf,.doc,.docx,.txt',
|
text: '.pdf,application/pdf',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const COURSE_CONTENT_TYPE_MAX_SIZE = Object.freeze({
|
||||||
|
video: 300 * 1024 * 1024,
|
||||||
|
voice: 50 * 1024 * 1024,
|
||||||
|
text: 50 * 1024 * 1024,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const SESSION_TYPE = Object.freeze({
|
export const SESSION_TYPE = Object.freeze({
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<SelectField
|
<SelectField
|
||||||
v-model="form.teacherId"
|
v-model="form.teacherId"
|
||||||
name="teacherId"
|
name="teacherId"
|
||||||
label="استاد"
|
label="استاد (اختیاری)"
|
||||||
:options="teacherOptions"
|
:options="teacherOptions"
|
||||||
option-label="name"
|
option-label="name"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
@@ -251,12 +251,12 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const uploadMutation = useUploadMediaMutation()
|
const coverUploadMutation = useUploadMediaMutation()
|
||||||
|
|
||||||
const onImageCropped = async (file) => {
|
const onImageCropped = async (file) => {
|
||||||
try {
|
try {
|
||||||
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
|
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
|
||||||
const response = await uploadMutation.mutateAsync(formData)
|
const response = await coverUploadMutation.mutateAsync(formData)
|
||||||
const payload = response?.data ?? response
|
const payload = response?.data ?? response
|
||||||
image.value = { url: payload?.url, ...payload }
|
image.value = { url: payload?.url, ...payload }
|
||||||
form.value.coverMediaId = payload?.id
|
form.value.coverMediaId = payload?.id
|
||||||
@@ -270,16 +270,29 @@ const onImageError = (msg) => toast.error(msg)
|
|||||||
const addMutation = useAddAdminCourseMutation()
|
const addMutation = useAddAdminCourseMutation()
|
||||||
const updateMutation = useUpdateAdminCourseMutation()
|
const updateMutation = useUpdateAdminCourseMutation()
|
||||||
|
|
||||||
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
|
const submitting = computed(
|
||||||
|
() =>
|
||||||
|
coverUploadMutation.isPending.value ||
|
||||||
|
addMutation.isPending.value ||
|
||||||
|
updateMutation.isPending.value
|
||||||
|
)
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
|
if (coverUploadMutation.isPending.value) {
|
||||||
|
toast.info('لطفاً تا پایان بارگذاری تصویر دوره منتظر بمانید.')
|
||||||
|
return
|
||||||
|
}
|
||||||
const { isValid, payload } = await validate(form.value)
|
const { isValid, payload } = await validate(form.value)
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
|
try {
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
await updateMutation.mutateAsync({ id: courseId.value, payload })
|
await updateMutation.mutateAsync({ id: courseId.value, payload })
|
||||||
} else {
|
} else {
|
||||||
await addMutation.mutateAsync(payload)
|
await addMutation.mutateAsync(payload)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
|
||||||
router.push({ name: 'admin-courses' })
|
router.push({ name: 'admin-courses' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ const courseIcon = gallery.courseIcon
|
|||||||
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
|
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
|
||||||
|
|
||||||
const onAdd = () => {
|
const onAdd = () => {
|
||||||
router.push({ name: 'admin-add-course' }).catch(() => {})
|
router
|
||||||
|
.push({
|
||||||
|
name: 'admin-add-course',
|
||||||
|
query: routeTermId.value ? { termId: routeTermId.value } : undefined,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { array, boolean, mixed, number, object, string } from 'yup'
|
|||||||
|
|
||||||
export const courseSchema = object().shape({
|
export const courseSchema = object().shape({
|
||||||
title: string().required().min(3).max(255),
|
title: string().required().min(3).max(255),
|
||||||
teacherId: mixed().required(),
|
// The backend intentionally allows courses without an assigned teacher.
|
||||||
|
teacherId: mixed().nullable().notRequired(),
|
||||||
capacity: number()
|
capacity: number()
|
||||||
.typeError('ظرفیت باید بهصورت عدد وارد شود')
|
.typeError('ظرفیت باید بهصورت عدد وارد شود')
|
||||||
.required()
|
.required()
|
||||||
|
|||||||
@@ -95,7 +95,8 @@
|
|||||||
:accept="contentAccept"
|
:accept="contentAccept"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:max-files="1"
|
:max-files="1"
|
||||||
:disabled="!form.contentType"
|
:max-size="contentMaxSize"
|
||||||
|
:disabled="!form.contentType || contentUploadMutation.isPending.value"
|
||||||
@select="onContentSelect"
|
@select="onContentSelect"
|
||||||
@remove="onContentRemove"
|
@remove="onContentRemove"
|
||||||
@error="onContentError"
|
@error="onContentError"
|
||||||
@@ -162,7 +163,12 @@ import TextareaField from '@/components/form/TextareaField.vue'
|
|||||||
import { sessionSchema } from '@/features/admin/sessions/schema'
|
import { sessionSchema } from '@/features/admin/sessions/schema'
|
||||||
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
|
||||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||||
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums'
|
import {
|
||||||
|
COURSE_CONTENT_TYPE,
|
||||||
|
COURSE_CONTENT_TYPE_ACCEPT,
|
||||||
|
COURSE_CONTENT_TYPE_MAX_SIZE,
|
||||||
|
SESSION_TYPE,
|
||||||
|
} from '@/enums'
|
||||||
import {
|
import {
|
||||||
adminSessionsKeys,
|
adminSessionsKeys,
|
||||||
useAddAdminSessionMutation,
|
useAddAdminSessionMutation,
|
||||||
@@ -175,6 +181,7 @@ const router = useRouter()
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
|
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
|
||||||
|
const presetCourseId = computed(() => (route.query.courseId ? Number(route.query.courseId) : null))
|
||||||
const isEditMode = computed(() => !!sessionId.value)
|
const isEditMode = computed(() => !!sessionId.value)
|
||||||
|
|
||||||
const sessionTypeOptions = [
|
const sessionTypeOptions = [
|
||||||
@@ -189,7 +196,7 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
|
|||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
courseId: '',
|
courseId: presetCourseId.value ?? '',
|
||||||
contentType: '',
|
contentType: '',
|
||||||
type: '',
|
type: '',
|
||||||
link: '',
|
link: '',
|
||||||
@@ -202,6 +209,9 @@ const coverMediaId = ref(null)
|
|||||||
const contentMediaId = ref(null)
|
const contentMediaId = ref(null)
|
||||||
|
|
||||||
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
|
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
|
||||||
|
const contentMaxSize = computed(
|
||||||
|
() => COURSE_CONTENT_TYPE_MAX_SIZE[form.value.contentType] || 50 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
const { validate, validateAt, errors } = useYup(sessionSchema)
|
const { validate, validateAt, errors } = useYup(sessionSchema)
|
||||||
|
|
||||||
@@ -276,7 +286,8 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
const uploadMutation = useUploadMediaMutation()
|
const contentUploadMutation = useUploadMediaMutation()
|
||||||
|
const coverUploadMutation = useUploadMediaMutation()
|
||||||
|
|
||||||
const purposeForContentType = (contentType) => {
|
const purposeForContentType = (contentType) => {
|
||||||
if (contentType === 'video') return 'video'
|
if (contentType === 'video') return 'video'
|
||||||
@@ -293,7 +304,7 @@ const onContentSelect = async (files) => {
|
|||||||
purpose: purposeForContentType(form.value.contentType),
|
purpose: purposeForContentType(form.value.contentType),
|
||||||
context: 'session',
|
context: 'session',
|
||||||
})
|
})
|
||||||
const response = await uploadMutation.mutateAsync(formData)
|
const response = await contentUploadMutation.mutateAsync(formData)
|
||||||
const payload = response?.data ?? response
|
const payload = response?.data ?? response
|
||||||
const id = payload?.id ?? payload?.uploadId
|
const id = payload?.id ?? payload?.uploadId
|
||||||
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
|
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
|
||||||
@@ -313,7 +324,7 @@ const onContentError = (msg) => toast.error(msg)
|
|||||||
const onImageCropped = async (file) => {
|
const onImageCropped = async (file) => {
|
||||||
try {
|
try {
|
||||||
const formData = objectToFormData({ file, purpose: 'cover', context: 'session' })
|
const formData = objectToFormData({ file, purpose: 'cover', context: 'session' })
|
||||||
const response = await uploadMutation.mutateAsync(formData)
|
const response = await coverUploadMutation.mutateAsync(formData)
|
||||||
const payload = response?.data ?? response
|
const payload = response?.data ?? response
|
||||||
image.value = { url: payload?.url, ...payload }
|
image.value = { url: payload?.url, ...payload }
|
||||||
coverMediaId.value = payload?.id ?? null
|
coverMediaId.value = payload?.id ?? null
|
||||||
@@ -346,17 +357,35 @@ const buildPayload = (values) => {
|
|||||||
const addMutation = useAddAdminSessionMutation()
|
const addMutation = useAddAdminSessionMutation()
|
||||||
const updateMutation = useUpdateAdminSessionMutation()
|
const updateMutation = useUpdateAdminSessionMutation()
|
||||||
|
|
||||||
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
|
const submitting = computed(
|
||||||
|
() =>
|
||||||
|
contentUploadMutation.isPending.value ||
|
||||||
|
coverUploadMutation.isPending.value ||
|
||||||
|
addMutation.isPending.value ||
|
||||||
|
updateMutation.isPending.value
|
||||||
|
)
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
|
if (contentUploadMutation.isPending.value || coverUploadMutation.isPending.value) {
|
||||||
|
toast.info('لطفاً تا پایان بارگذاری محتوای جلسه منتظر بمانید.')
|
||||||
|
return
|
||||||
|
}
|
||||||
const { isValid } = await validate(form.value)
|
const { isValid } = await validate(form.value)
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
|
if (form.value.contentType && contentMediaId.value == null) {
|
||||||
|
toast.error('برای نوع محتوای انتخابشده، فایل جلسه را بارگذاری کنید.')
|
||||||
|
return
|
||||||
|
}
|
||||||
const payload = buildPayload(form.value)
|
const payload = buildPayload(form.value)
|
||||||
|
try {
|
||||||
if (isEditMode.value) {
|
if (isEditMode.value) {
|
||||||
await updateMutation.mutateAsync({ id: sessionId.value, payload })
|
await updateMutation.mutateAsync({ id: sessionId.value, payload })
|
||||||
} else {
|
} else {
|
||||||
await addMutation.mutateAsync(payload)
|
await addMutation.mutateAsync(payload)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
// The list query is inactive while we're on the form page, and the global
|
// The list query is inactive while we're on the form page, and the global
|
||||||
// `refetchOnMount: false` means navigating back won't refetch a merely-stale
|
// `refetchOnMount: false` means navigating back won't refetch a merely-stale
|
||||||
// query — so force a refetch of all matching queries, active or not.
|
// query — so force a refetch of all matching queries, active or not.
|
||||||
|
|||||||
@@ -109,7 +109,12 @@ const onFilterApply = () => resetPagination()
|
|||||||
const onFilterReset = () => resetPagination()
|
const onFilterReset = () => resetPagination()
|
||||||
|
|
||||||
const onAdd = () => {
|
const onAdd = () => {
|
||||||
router.push({ name: 'admin-add-session' }).catch(() => {})
|
router
|
||||||
|
.push({
|
||||||
|
name: 'admin-add-session',
|
||||||
|
query: routeCourseId.value ? { courseId: routeCourseId.value } : undefined,
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onEdit = (session) => {
|
const onEdit = (session) => {
|
||||||
|
|||||||
@@ -43,4 +43,8 @@ export const apiSaveRegisterData = (data) => http.post(endpoints.registerData, {
|
|||||||
export const apiGetRegistrationQuestionVideo = () =>
|
export const apiGetRegistrationQuestionVideo = () =>
|
||||||
http.get(endpoints.getRegistrationQuestionVideo)
|
http.get(endpoints.getRegistrationQuestionVideo)
|
||||||
|
|
||||||
export const apiUploadMedia = (formData) => http.post(endpoints.uploadMedia, formData)
|
// Media uploads (especially course videos) can legitimately take several
|
||||||
|
// minutes on slower connections. Keep the normal API timeout short, but give
|
||||||
|
// this endpoint enough time to complete the backend's 300 MB upload contract.
|
||||||
|
export const apiUploadMedia = (formData) =>
|
||||||
|
http.post(endpoints.uploadMedia, formData, { timeout: 10 * 60 * 1000 })
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ export function handleApiError(error_) {
|
|||||||
const data = error_?.response?.data
|
const data = error_?.response?.data
|
||||||
const status = error_?.response?.status
|
const status = error_?.response?.status
|
||||||
|
|
||||||
|
if (error_?.code === 'ECONNABORTED') {
|
||||||
|
toast.error(
|
||||||
|
'زمان بارگذاری فایل به پایان رسید. لطفاً اتصال اینترنت را بررسی و دوباره تلاش کنید.'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 413) {
|
||||||
|
toast.error('حجم فایل از حداکثر مجاز بیشتر است.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 422 && data?.errors) {
|
if (status === 422 && data?.errors) {
|
||||||
Object.values(data.errors).forEach((messages) => {
|
Object.values(data.errors).forEach((messages) => {
|
||||||
const list = Array.isArray(messages) ? messages : [messages]
|
const list = Array.isArray(messages) ? messages : [messages]
|
||||||
|
|||||||
Reference in New Issue
Block a user