diff --git a/src/features/admin/sessions/pages/SessionFormPage.vue b/src/features/admin/sessions/pages/SessionFormPage.vue
index d07764d..983279b 100644
--- a/src/features/admin/sessions/pages/SessionFormPage.vue
+++ b/src/features/admin/sessions/pages/SessionFormPage.vue
@@ -77,6 +77,19 @@
/>
+
+
+
+
+
@@ -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;
}
diff --git a/src/features/admin/sessions/schema/index.js b/src/features/admin/sessions/schema/index.js
index ecdc4f3..cf5682d 100644
--- a/src/features/admin/sessions/schema/index.js
+++ b/src/features/admin/sessions/schema/index.js
@@ -9,5 +9,6 @@ export const sessionSchema = object().shape({
then: (schema) => schema.required(),
otherwise: (schema) => schema.nullable().notRequired(),
}),
+ embedCode: string().nullable().notRequired(),
description: string().nullable().notRequired(),
})
diff --git a/src/features/student/certificates/components/CertificateItem.vue b/src/features/student/certificates/components/CertificateItem.vue
index e65c76a..d8574cc 100644
--- a/src/features/student/certificates/components/CertificateItem.vue
+++ b/src/features/student/certificates/components/CertificateItem.vue
@@ -120,7 +120,7 @@ const downloadItems = computed(() => [
@media (min-width: 1024px) {
flex: 1;
- order: 3;
+ order: 1;
min-width: 12rem;
}
}
@@ -146,8 +146,8 @@ const downloadItems = computed(() => [
&__course-info {
display: flex;
flex-direction: column;
- align-items: flex-start;
- text-align: end;
+ align-items: flex-end;
+ text-align: right;
gap: 0.25rem;
min-width: 0;
}
@@ -186,6 +186,7 @@ const downloadItems = computed(() => [
background: rgba(107, 107, 107, 4%);
font-family: var(--font-family-fa);
white-space: nowrap;
+ align-self: center;
@media (min-width: 1024px) {
order: 2;
@@ -222,15 +223,20 @@ const downloadItems = computed(() => [
justify-content: center;
@media (min-width: 1024px) {
- order: 1;
- justify-content: flex-start;
+ order: 3;
+ justify-content: flex-end;
}
}
&__download-btn {
- min-width: 9rem;
+ width: 100%;
height: 2rem;
padding: 0 1.25rem;
+
+ @media (min-width: 1024px) {
+ width: auto;
+ min-width: 9rem;
+ }
}
}
diff --git a/src/features/student/certificates/pages/StudentCertificatesPage.vue b/src/features/student/certificates/pages/StudentCertificatesPage.vue
index 3356bec..2f86b7e 100644
--- a/src/features/student/certificates/pages/StudentCertificatesPage.vue
+++ b/src/features/student/certificates/pages/StudentCertificatesPage.vue
@@ -35,6 +35,7 @@ import { computed, ref } from 'vue'
import { toast } from 'vue3-toastify'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import NoItems from '@/components/blocks/NoItems.vue'
+import { formatJalaaliDate } from '@/utils/date-utils'
import { usePagination } from '@/composables/usePagination'
import PaginationBlock from '@/components/blocks/PaginationBlock.vue'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
@@ -53,7 +54,17 @@ const { data, isLoading } = useStudentCertificatesQuery(filters, pagination, {
keepPreviousData: true,
})
-const certificates = computed(() => data.value?.data ?? [])
+// Bridge the backend payload (title/teacherName/startedAt/endedAt) into the
+// display shape CertificateItem expects, without touching its template.
+const certificates = computed(() =>
+ (data.value?.data ?? []).map((c) => ({
+ ...c,
+ courseTitle: c.title ?? '',
+ teacherName: c.teacherName ?? '',
+ startDate: formatJalaaliDate(c.startedAt) || '',
+ endDate: formatJalaaliDate(c.endedAt) || '',
+ }))
+)
const meta = computed(() => ({
page: pagination.value.page,
perPage: pagination.value.perPage,
diff --git a/src/features/student/sessions/pages/StudentSessionDetailsPage.vue b/src/features/student/sessions/pages/StudentSessionDetailsPage.vue
index 77fa8fd..bd22943 100644
--- a/src/features/student/sessions/pages/StudentSessionDetailsPage.vue
+++ b/src/features/student/sessions/pages/StudentSessionDetailsPage.vue
@@ -37,12 +37,23 @@
{{ session.text }}
+
+
+
- https://link
+ {{ session?.link || 'لینکی ثبت نشده است' }}
-
+
@@ -129,13 +140,17 @@ const homework = computed(() => (homeworksData.value?.data ?? [])[0] ?? null)
const contentKind = computed(() => {
const s = session.value
if (!s) return ''
- if (s.type === 'online') return 'link'
- if (s.videoUrl) return 'link'
+ if (s.type === 'online') return s.embedCode?.trim() ? 'embed' : 'link'
+ if (s.videoUrl) return 'video'
if (s.audioUrl) return 'audio'
if (s.text) return 'text'
return ''
})
+const openSessionLink = () => {
+ if (session.value?.link) window.open(session.value.link, '_blank', 'noopener,noreferrer')
+}
+
const homeworkAudio = ref(null)
const homeworkImage = ref(null)
const errors = reactive({ audio: '', image: '' })
@@ -260,6 +275,18 @@ const onSubmit = async () => {
}
}
+ &__embed {
+ width: 100%;
+ }
+
+ &__embed-frame {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ border: 0;
+ border-radius: 1rem;
+ background: #000;
+ }
+
&__link {
display: flex;
align-items: center;
diff --git a/src/layouts/CounselorLayout.vue b/src/layouts/CounselorLayout.vue
index 148d54a..cef7acc 100644
--- a/src/layouts/CounselorLayout.vue
+++ b/src/layouts/CounselorLayout.vue
@@ -40,13 +40,16 @@