feat(batch-1): multi-role sidebar, video display, certificate layout, upload progress, counselor access
- roleMenuItems.js: merged menu builder that unions items from all user roles (student + missionary, student + counselor, etc.) additively - auth store: expose `roles` array in addition to singular `role`; wire in WordPress auth-status cookie sync so the public site can show/hide the account button - router guards: use roles.includes() instead of role === for multi-role access - layouts (Student/Counselor/Missionary): delegate menuItems to buildMergedMenuItems so dual-role users see sections from both roles - StudentSessionDetailsPage: contentKind now returns 'video' (not 'link') when session.videoUrl is set; adds embed iframe branch for online sessions - CertificateItem.vue: fix CSS order/alignment for desktop + mobile layout - StudentCertificatesPage.vue: map backend payload fields to component props with Jalaali date formatting - SessionFormPage: upload progress bar (%) during video uploads; embed_code textarea for online sessions (frontend-only; dormant until batch-2 backend migration is deployed) - wp-sync/auth-status-cookie.js: cookie helper for WordPress login-state sync [batch-1-low-risk]
This commit is contained in:
@@ -77,6 +77,19 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- row 3b — embed code (online sessions only) -->
|
||||
<div class="session-form__row" v-if="form.type === 'online'">
|
||||
<TextareaField
|
||||
v-model="form.embedCode"
|
||||
name="embedCode"
|
||||
label="کد امبد (Embed Code)"
|
||||
placeholder="در صورت وجود، کد iframe/embed جلسه (مثلاً از آپارات یا سرویس ویدئوکنفرانس) را اینجا وارد کنید تا بهجای لینک، بهصورت مستقیم در صفحه نمایش داده شود."
|
||||
:row="3"
|
||||
:error="errors.embedCode"
|
||||
@blur="validateAt('embedCode', form.embedCode)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- row 4 — description -->
|
||||
<div class="session-form__row">
|
||||
<TextareaField
|
||||
@@ -101,6 +114,17 @@
|
||||
@remove="onContentRemove"
|
||||
@error="onContentError"
|
||||
/>
|
||||
<div v-if="contentUploadMutation.isPending.value" class="session-form__upload-progress">
|
||||
<div class="session-form__upload-progress-track">
|
||||
<div
|
||||
class="session-form__upload-progress-bar"
|
||||
:style="{ width: contentUploadProgress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="session-form__upload-progress-text">
|
||||
{{ contentUploadProgress }}% بارگذاری شده
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,6 +224,7 @@ const form = ref({
|
||||
contentType: '',
|
||||
type: '',
|
||||
link: '',
|
||||
embedCode: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
@@ -207,6 +232,7 @@ const image = ref(null)
|
||||
const contentFiles = ref([])
|
||||
const coverMediaId = ref(null)
|
||||
const contentMediaId = ref(null)
|
||||
const contentUploadProgress = ref(0)
|
||||
|
||||
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
|
||||
const contentMaxSize = computed(
|
||||
@@ -238,7 +264,10 @@ const onContentTypeChange = () => {
|
||||
}
|
||||
|
||||
const onSessionTypeChange = () => {
|
||||
if (form.value.type !== 'online') form.value.link = ''
|
||||
if (form.value.type !== 'online') {
|
||||
form.value.link = ''
|
||||
form.value.embedCode = ''
|
||||
}
|
||||
}
|
||||
|
||||
const { data: existingSession } = useAdminSessionQuery(sessionId, {
|
||||
@@ -267,6 +296,7 @@ watch(
|
||||
contentType,
|
||||
type: session.type ?? '',
|
||||
link: session.link ?? '',
|
||||
embedCode: session.embedCode ?? '',
|
||||
description: session.description ?? '',
|
||||
}
|
||||
coverMediaId.value = coverMedia?.id ?? null
|
||||
@@ -298,13 +328,20 @@ const purposeForContentType = (contentType) => {
|
||||
const onContentSelect = async (files) => {
|
||||
const file = files?.[0]
|
||||
if (!file) return
|
||||
contentUploadProgress.value = 0
|
||||
try {
|
||||
const formData = objectToFormData({
|
||||
file,
|
||||
purpose: purposeForContentType(form.value.contentType),
|
||||
context: 'session',
|
||||
})
|
||||
const response = await contentUploadMutation.mutateAsync(formData)
|
||||
const response = await contentUploadMutation.mutateAsync({
|
||||
formData,
|
||||
onUploadProgress: (event) => {
|
||||
if (!event.total) return
|
||||
contentUploadProgress.value = Math.round((event.loaded / event.total) * 100)
|
||||
},
|
||||
})
|
||||
const payload = response?.data ?? response
|
||||
const id = payload?.id ?? payload?.uploadId
|
||||
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
|
||||
@@ -342,6 +379,7 @@ const buildPayload = (values) => {
|
||||
courseId: values.courseId,
|
||||
type: values.type,
|
||||
link: values.type === 'online' ? values.link : undefined,
|
||||
embedCode: values.type === 'online' ? values.embedCode : undefined,
|
||||
description: values.description,
|
||||
mediaIds,
|
||||
}
|
||||
@@ -477,6 +515,38 @@ const onCancel = () => router.push({ name: 'admin-sessions' })
|
||||
color: var(--color-prim-gray);
|
||||
}
|
||||
|
||||
&__upload-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: -0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&__upload-progress-track {
|
||||
flex: 1;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-thd-gray);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__upload-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
&__upload-progress-text {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-en);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-sec-gray);
|
||||
min-width: 3.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&__preview {
|
||||
place-items: center center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user