Skip to content

Commit ca690d9

Browse files
Merge pull request #35 from chamilo/feature/mobile2-learning-path-runtime
Mobile: add learning path runtime player
2 parents 5f65088 + e1a3240 commit ca690d9

10 files changed

Lines changed: 1102 additions & 131 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<script setup lang="ts">
2+
import { computed, onBeforeUnmount, ref, watch } from "vue"
3+
import { useI18n } from "vue-i18n"
4+
5+
import type { LearningPathRuntimeItem } from "@/domain/learningPaths/types"
6+
7+
const props = defineProps<{
8+
blob: Blob
9+
item: LearningPathRuntimeItem
10+
}>()
11+
12+
const emit = defineEmits<{
13+
openExternal: []
14+
download: []
15+
}>()
16+
17+
const { t } = useI18n()
18+
const objectUrl = ref("")
19+
const textContent = ref("")
20+
21+
function extension(filename: string): string {
22+
const match = filename
23+
.trim()
24+
.toLowerCase()
25+
.match(/\.([a-z0-9]+)$/)
26+
27+
return match?.[1] ?? ""
28+
}
29+
30+
const mimeType = computed(() => props.blob.type.trim().toLowerCase())
31+
const fileExtension = computed(() => extension(props.item.title))
32+
33+
const viewerKind = computed<"image" | "video" | "audio" | "text" | "frame" | "unsupported">(() => {
34+
if (mimeType.value.startsWith("image/")) {
35+
return "image"
36+
}
37+
38+
if (mimeType.value.startsWith("video/")) {
39+
return "video"
40+
}
41+
42+
if (mimeType.value.startsWith("audio/")) {
43+
return "audio"
44+
}
45+
46+
if (mimeType.value === "text/plain" || ["txt", "md", "csv"].includes(fileExtension.value)) {
47+
return "text"
48+
}
49+
50+
if (
51+
mimeType.value === "application/pdf" ||
52+
mimeType.value === "text/html" ||
53+
mimeType.value === "application/xhtml+xml" ||
54+
["pdf", "html", "htm"].includes(fileExtension.value)
55+
) {
56+
return "frame"
57+
}
58+
59+
return "unsupported"
60+
})
61+
62+
async function refreshObjectUrl(): Promise<void> {
63+
if (objectUrl.value) {
64+
URL.revokeObjectURL(objectUrl.value)
65+
}
66+
67+
objectUrl.value = URL.createObjectURL(props.blob)
68+
textContent.value = viewerKind.value === "text" ? await props.blob.text() : ""
69+
}
70+
71+
watch(
72+
() => [props.blob, props.item.id] as const,
73+
() => {
74+
void refreshObjectUrl()
75+
},
76+
{ immediate: true },
77+
)
78+
79+
onBeforeUnmount(() => {
80+
if (objectUrl.value) {
81+
URL.revokeObjectURL(objectUrl.value)
82+
}
83+
})
84+
</script>
85+
86+
<template>
87+
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
88+
<img
89+
v-if="viewerKind === 'image'"
90+
:src="objectUrl"
91+
:alt="item.title"
92+
class="mx-auto max-h-[70dvh] w-auto max-w-full object-contain"
93+
/>
94+
95+
<video
96+
v-else-if="viewerKind === 'video'"
97+
:src="objectUrl"
98+
class="max-h-[70dvh] w-full bg-black"
99+
controls
100+
playsinline
101+
/>
102+
103+
<audio v-else-if="viewerKind === 'audio'" :src="objectUrl" class="w-full p-4" controls />
104+
105+
<pre
106+
v-else-if="viewerKind === 'text'"
107+
class="max-h-[70dvh] overflow-auto whitespace-pre-wrap break-words p-4 text-sm text-slate-800"
108+
>{{ textContent }}</pre
109+
>
110+
111+
<iframe
112+
v-else-if="viewerKind === 'frame'"
113+
:src="objectUrl"
114+
:title="item.title"
115+
class="h-[65dvh] min-h-[420px] w-full bg-white"
116+
sandbox="allow-same-origin"
117+
referrerpolicy="no-referrer"
118+
/>
119+
120+
<div v-else class="space-y-3 p-4">
121+
<p class="text-sm text-slate-700">
122+
{{ t("learningPaths.viewerUnsupported") }}
123+
</p>
124+
<div class="grid gap-2 sm:grid-cols-2">
125+
<button
126+
type="button"
127+
class="inline-flex min-h-touch items-center justify-center gap-2 rounded-xl bg-chamilo-700 px-4 py-3 font-semibold text-white"
128+
@click="emit('openExternal')"
129+
>
130+
<i class="pi pi-external-link" aria-hidden="true" />
131+
{{ t("learningPaths.openExternal") }}
132+
</button>
133+
<button
134+
type="button"
135+
class="inline-flex min-h-touch items-center justify-center gap-2 rounded-xl border border-slate-300 px-4 py-3 font-semibold text-slate-800"
136+
@click="emit('download')"
137+
>
138+
<i class="pi pi-download" aria-hidden="true" />
139+
{{ t("learningPaths.downloadContent") }}
140+
</button>
141+
</div>
142+
</div>
143+
</div>
144+
</template>
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
<script setup lang="ts">
2+
import { computed, ref, watch } from "vue"
3+
import { useI18n } from "vue-i18n"
4+
5+
import {
6+
isCompletedLearningPathStatus,
7+
isSupportedLearningPathItem,
8+
} from "@/domain/learningPaths/contracts"
9+
import type { LearningPathRuntimeItem } from "@/domain/learningPaths/types"
10+
11+
const props = defineProps<{
12+
items: LearningPathRuntimeItem[]
13+
currentItemId: number
14+
busy: boolean
15+
accordion: boolean
16+
}>()
17+
18+
const emit = defineEmits<{
19+
select: [itemId: number]
20+
}>()
21+
22+
const { t } = useI18n()
23+
const expandedSections = ref<Set<number>>(new Set())
24+
25+
const itemById = computed(() => new Map(props.items.map((item) => [item.id, item] as const)))
26+
27+
function ancestorIds(item: LearningPathRuntimeItem): number[] {
28+
const ancestors: number[] = []
29+
const visited = new Set<number>()
30+
let parentId = item.parentId
31+
32+
while (parentId > 0 && !visited.has(parentId)) {
33+
visited.add(parentId)
34+
ancestors.push(parentId)
35+
parentId = itemById.value.get(parentId)?.parentId ?? 0
36+
}
37+
38+
return ancestors
39+
}
40+
41+
function initializeExpandedSections(): void {
42+
const next = new Set<number>()
43+
44+
for (const item of props.items) {
45+
if (item.isSection) {
46+
next.add(item.id)
47+
}
48+
}
49+
50+
const current = itemById.value.get(props.currentItemId)
51+
52+
if (current) {
53+
for (const ancestorId of ancestorIds(current)) {
54+
next.add(ancestorId)
55+
}
56+
}
57+
58+
expandedSections.value = next
59+
}
60+
61+
watch(() => [props.items, props.currentItemId] as const, initializeExpandedSections, {
62+
immediate: true,
63+
deep: true,
64+
})
65+
66+
const visibleItems = computed(() =>
67+
props.items.filter((item) =>
68+
ancestorIds(item).every((ancestorId) => expandedSections.value.has(ancestorId)),
69+
),
70+
)
71+
72+
function toggleSection(item: LearningPathRuntimeItem): void {
73+
const next = new Set(expandedSections.value)
74+
75+
if (next.has(item.id)) {
76+
next.delete(item.id)
77+
} else {
78+
if (props.accordion) {
79+
for (const candidate of props.items) {
80+
if (candidate.isSection && candidate.parentId === item.parentId) {
81+
next.delete(candidate.id)
82+
}
83+
}
84+
}
85+
86+
next.add(item.id)
87+
}
88+
89+
expandedSections.value = next
90+
}
91+
92+
function statusIcon(item: LearningPathRuntimeItem): string {
93+
if (!item.available) {
94+
return "pi pi-lock"
95+
}
96+
97+
if (isCompletedLearningPathStatus(item.status)) {
98+
return item.status.trim().toLowerCase() === "failed"
99+
? "pi pi-times-circle"
100+
: "pi pi-check-circle"
101+
}
102+
103+
if (item.id === props.currentItemId) {
104+
return "pi pi-play-circle"
105+
}
106+
107+
return item.isSection ? "pi pi-folder" : "pi pi-circle"
108+
}
109+
110+
function statusLabel(item: LearningPathRuntimeItem): string {
111+
if (!item.available) {
112+
return t("learningPaths.status.locked")
113+
}
114+
115+
if (!isSupportedLearningPathItem(item) && !item.isSection) {
116+
return t("learningPaths.status.playerPending")
117+
}
118+
119+
const normalizedStatus = item.status.trim().toLowerCase().replace(/\s+/g, "_")
120+
const key = `learningPaths.status.${normalizedStatus}`
121+
122+
return t(key)
123+
}
124+
125+
function activate(item: LearningPathRuntimeItem): void {
126+
if (item.isSection) {
127+
toggleSection(item)
128+
return
129+
}
130+
131+
if (!props.busy && isSupportedLearningPathItem(item)) {
132+
emit("select", item.id)
133+
}
134+
}
135+
</script>
136+
137+
<template>
138+
<div class="space-y-2">
139+
<button
140+
v-for="item in visibleItems"
141+
:key="item.id"
142+
type="button"
143+
class="flex min-h-touch w-full items-center gap-3 rounded-xl border bg-white px-3 py-2.5 text-left shadow-sm transition"
144+
:class="[
145+
item.id === currentItemId
146+
? 'ring-chamilo-200 border-chamilo-500 ring-1'
147+
: 'border-slate-200',
148+
!item.available || (!item.isSection && !isSupportedLearningPathItem(item))
149+
? 'opacity-65'
150+
: 'hover:border-chamilo-300',
151+
]"
152+
:disabled="busy || !item.available || (!item.isSection && !isSupportedLearningPathItem(item))"
153+
:style="{ paddingLeft: `${12 + Math.min(item.level, 5) * 14}px` }"
154+
:aria-current="item.id === currentItemId ? 'step' : undefined"
155+
:aria-expanded="item.isSection ? expandedSections.has(item.id) : undefined"
156+
@click="activate(item)"
157+
>
158+
<i :class="statusIcon(item)" class="shrink-0 text-chamilo-700" aria-hidden="true" />
159+
160+
<span class="min-w-0 flex-1">
161+
<span class="block break-words font-medium text-slate-900">
162+
{{ item.title }}
163+
</span>
164+
<span class="mt-0.5 block text-xs text-slate-500">
165+
{{ statusLabel(item) }}
166+
</span>
167+
</span>
168+
169+
<i
170+
v-if="item.isSection"
171+
:class="expandedSections.has(item.id) ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
172+
class="text-xs text-slate-400"
173+
aria-hidden="true"
174+
/>
175+
<i
176+
v-else-if="item.available && isSupportedLearningPathItem(item)"
177+
class="pi pi-chevron-right text-xs text-slate-400"
178+
aria-hidden="true"
179+
/>
180+
</button>
181+
</div>
182+
</template>

0 commit comments

Comments
 (0)